mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-08-27 05:03:20 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2250ef96d | ||
|
|
50043ddb66 | ||
|
|
15a5618a6e | ||
|
|
34a01a5bd6 | ||
|
|
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
|
||||
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
|
||||
|
||||
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 purpose="location">
|
||||
<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 purpose="location">
|
||||
<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 purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/dashboard/dashboard.component.html</context>
|
||||
@@ -323,11 +323,11 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<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 purpose="location">
|
||||
<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 purpose="location">
|
||||
<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 purpose="location">
|
||||
<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 purpose="location">
|
||||
<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 purpose="location">
|
||||
<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>
|
||||
</trans-unit>
|
||||
<trans-unit id="5890330709052835856" datatype="html">
|
||||
@@ -727,11 +727,11 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<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 purpose="location">
|
||||
<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>
|
||||
</trans-unit>
|
||||
<trans-unit id="2272120016352772836" datatype="html">
|
||||
@@ -1142,11 +1142,11 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<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 purpose="location">
|
||||
<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 purpose="location">
|
||||
<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="linenumber">99</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">173</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">174</context>
|
||||
@@ -1651,6 +1655,10 @@
|
||||
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context>
|
||||
<context context-type="linenumber">100</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">177</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">178</context>
|
||||
@@ -1682,6 +1690,10 @@
|
||||
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context>
|
||||
<context context-type="linenumber">101</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">181</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">182</context>
|
||||
@@ -1711,7 +1723,11 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<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 purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/input/tags/tags.component.ts</context>
|
||||
@@ -1801,15 +1817,15 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<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 purpose="location">
|
||||
<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 purpose="location">
|
||||
<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>
|
||||
</trans-unit>
|
||||
<trans-unit id="6904866445262015585" datatype="html">
|
||||
@@ -1820,11 +1836,11 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<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 purpose="location">
|
||||
<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>
|
||||
</trans-unit>
|
||||
<trans-unit id="8492095365580052820" datatype="html">
|
||||
@@ -2087,7 +2103,7 @@
|
||||
</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">95</context>
|
||||
<context context-type="linenumber">96</context>
|
||||
</context-group>
|
||||
<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>
|
||||
@@ -2402,7 +2418,7 @@
|
||||
</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">80</context>
|
||||
<context context-type="linenumber">81</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="228975554646076615" datatype="html">
|
||||
@@ -2462,7 +2478,7 @@
|
||||
</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">122</context>
|
||||
<context context-type="linenumber">123</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="235571817610183244" datatype="html">
|
||||
@@ -2473,7 +2489,7 @@
|
||||
</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">84</context>
|
||||
<context context-type="linenumber">85</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="526966086395145275" datatype="html">
|
||||
@@ -2484,7 +2500,7 @@
|
||||
</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">76</context>
|
||||
<context context-type="linenumber">77</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="6520092939021340973" datatype="html">
|
||||
@@ -2601,11 +2617,11 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<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 purpose="location">
|
||||
<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>
|
||||
</trans-unit>
|
||||
<trans-unit id="3818027200170621545" datatype="html">
|
||||
@@ -2962,11 +2978,11 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<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 purpose="location">
|
||||
<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>
|
||||
</trans-unit>
|
||||
<trans-unit id="4569276013106377105" datatype="html">
|
||||
@@ -3275,104 +3291,104 @@
|
||||
<source>by Paperless-ngx</source>
|
||||
<context-group purpose="location">
|
||||
<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>
|
||||
</trans-unit>
|
||||
<trans-unit id="7228136119811576789" datatype="html">
|
||||
<source>User menu</source>
|
||||
<context-group purpose="location">
|
||||
<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>
|
||||
</trans-unit>
|
||||
<trans-unit id="2448391510242468907" datatype="html">
|
||||
<source>Logged in as <x id="INTERPOLATION" equiv-text="{{this.settingsService.displayName}}"/></source>
|
||||
<context-group purpose="location">
|
||||
<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>
|
||||
</trans-unit>
|
||||
<trans-unit id="2127032578120864096" datatype="html">
|
||||
<source>My Profile</source>
|
||||
<context-group purpose="location">
|
||||
<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>
|
||||
</trans-unit>
|
||||
<trans-unit id="3797778920049399855" datatype="html">
|
||||
<source>Logout</source>
|
||||
<context-group purpose="location">
|
||||
<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>
|
||||
</trans-unit>
|
||||
<trans-unit id="4895326106573044490" datatype="html">
|
||||
<source>Documentation</source>
|
||||
<context-group purpose="location">
|
||||
<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 purpose="location">
|
||||
<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 purpose="location">
|
||||
<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>
|
||||
</trans-unit>
|
||||
<trans-unit id="472206565520537964" datatype="html">
|
||||
<source>Saved views</source>
|
||||
<context-group purpose="location">
|
||||
<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 purpose="location">
|
||||
<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>
|
||||
</trans-unit>
|
||||
<trans-unit id="6988090220128974198" datatype="html">
|
||||
<source>Open documents</source>
|
||||
<context-group purpose="location">
|
||||
<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>
|
||||
</trans-unit>
|
||||
<trans-unit id="5687256342387781369" datatype="html">
|
||||
<source>Close all</source>
|
||||
<context-group purpose="location">
|
||||
<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 purpose="location">
|
||||
<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>
|
||||
</trans-unit>
|
||||
<trans-unit id="3897348120591552265" datatype="html">
|
||||
<source>Manage</source>
|
||||
<context-group purpose="location">
|
||||
<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>
|
||||
</trans-unit>
|
||||
<trans-unit id="8008131619909556709" datatype="html">
|
||||
<source>Attributes</source>
|
||||
<context-group purpose="location">
|
||||
<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 purpose="location">
|
||||
<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>
|
||||
</trans-unit>
|
||||
<trans-unit id="7437910965833684826" datatype="html">
|
||||
<source>Correspondents</source>
|
||||
<context-group purpose="location">
|
||||
<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 purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.html</context>
|
||||
@@ -3391,7 +3407,7 @@
|
||||
<source>Document types</source>
|
||||
<context-group purpose="location">
|
||||
<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 purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/manage/document-attributes/document-attributes.component.ts</context>
|
||||
@@ -3402,7 +3418,7 @@
|
||||
<source>Storage paths</source>
|
||||
<context-group purpose="location">
|
||||
<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 purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/manage/document-attributes/document-attributes.component.ts</context>
|
||||
@@ -3413,7 +3429,7 @@
|
||||
<source>Custom fields</source>
|
||||
<context-group purpose="location">
|
||||
<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 purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
|
||||
@@ -3432,11 +3448,11 @@
|
||||
<source>Workflows</source>
|
||||
<context-group purpose="location">
|
||||
<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 purpose="location">
|
||||
<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 purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/manage/workflows/workflows.component.html</context>
|
||||
@@ -3447,99 +3463,99 @@
|
||||
<source>Mail</source>
|
||||
<context-group purpose="location">
|
||||
<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 purpose="location">
|
||||
<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>
|
||||
</trans-unit>
|
||||
<trans-unit id="7844706011418789951" datatype="html">
|
||||
<source>Administration</source>
|
||||
<context-group purpose="location">
|
||||
<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>
|
||||
</trans-unit>
|
||||
<trans-unit id="3008420115644088420" datatype="html">
|
||||
<source>Configuration</source>
|
||||
<context-group purpose="location">
|
||||
<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 purpose="location">
|
||||
<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>
|
||||
</trans-unit>
|
||||
<trans-unit id="1534029177398918729" datatype="html">
|
||||
<source>GitHub</source>
|
||||
<context-group purpose="location">
|
||||
<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>
|
||||
</trans-unit>
|
||||
<trans-unit id="4112664765954374539" datatype="html">
|
||||
<source>is available.</source>
|
||||
<context-group purpose="location">
|
||||
<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>
|
||||
</trans-unit>
|
||||
<trans-unit id="1175891574282637937" datatype="html">
|
||||
<source>Click to view.</source>
|
||||
<context-group purpose="location">
|
||||
<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>
|
||||
</trans-unit>
|
||||
<trans-unit id="9811291095862612" datatype="html">
|
||||
<source>Paperless-ngx can automatically check for updates</source>
|
||||
<context-group purpose="location">
|
||||
<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>
|
||||
</trans-unit>
|
||||
<trans-unit id="894819944961861800" datatype="html">
|
||||
<source> How does this work? </source>
|
||||
<context-group purpose="location">
|
||||
<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>
|
||||
</trans-unit>
|
||||
<trans-unit id="509090351011426949" datatype="html">
|
||||
<source>Update available</source>
|
||||
<context-group purpose="location">
|
||||
<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>
|
||||
</trans-unit>
|
||||
<trans-unit id="1329827712962827905" datatype="html">
|
||||
<source>Configure update checking</source>
|
||||
<context-group purpose="location">
|
||||
<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>
|
||||
</trans-unit>
|
||||
<trans-unit id="1542489069631984294" datatype="html">
|
||||
<source>Sidebar views updated</source>
|
||||
<context-group purpose="location">
|
||||
<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>
|
||||
</trans-unit>
|
||||
<trans-unit id="3547923076537026828" datatype="html">
|
||||
<source>Error updating sidebar views</source>
|
||||
<context-group purpose="location">
|
||||
<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>
|
||||
</trans-unit>
|
||||
<trans-unit id="2526035785704676448" datatype="html">
|
||||
<source>An error occurred while saving update checking settings.</source>
|
||||
<context-group purpose="location">
|
||||
<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>
|
||||
</trans-unit>
|
||||
<trans-unit id="4580988005648117665" datatype="html">
|
||||
@@ -4332,7 +4348,7 @@
|
||||
</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">91</context>
|
||||
<context context-type="linenumber">92</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
@@ -5322,7 +5338,7 @@
|
||||
</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">137</context>
|
||||
<context context-type="linenumber">138</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<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-group>
|
||||
</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">
|
||||
<source>Consume Folder</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">72</context>
|
||||
<context context-type="linenumber">73</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="3553216189604488439" datatype="html">
|
||||
<source>Modified</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">99</context>
|
||||
<context context-type="linenumber">100</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/data/document.ts</context>
|
||||
@@ -5976,63 +6041,63 @@
|
||||
<source>Custom Field</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">103</context>
|
||||
<context context-type="linenumber">104</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="8696908693776094667" datatype="html">
|
||||
<source>Consumption Started</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">110</context>
|
||||
<context context-type="linenumber">111</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="7858311467093621703" datatype="html">
|
||||
<source>Document Added</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">114</context>
|
||||
<context context-type="linenumber">115</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="7955486237346046731" datatype="html">
|
||||
<source>Document Updated</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">118</context>
|
||||
<context context-type="linenumber">119</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="5502398334173581061" datatype="html">
|
||||
<source>Assignment</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">129</context>
|
||||
<context context-type="linenumber">130</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="6234812824772766804" datatype="html">
|
||||
<source>Removal</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">133</context>
|
||||
<context context-type="linenumber">134</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="4206419737792796794" datatype="html">
|
||||
<source>Webhook</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">141</context>
|
||||
<context context-type="linenumber">142</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="4824906895380506720" datatype="html">
|
||||
<source>Password removal</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">145</context>
|
||||
<context context-type="linenumber">146</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="2048798344356757326" datatype="html">
|
||||
<source>Move to trash</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">149</context>
|
||||
<context context-type="linenumber">150</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
@@ -6047,116 +6112,157 @@
|
||||
<source>Remote OCR</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">153</context>
|
||||
<context context-type="linenumber">154</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
||||
<context context-type="linenumber">58</context>
|
||||
</context-group>
|
||||
</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">
|
||||
<source>Has any of these tags</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">222</context>
|
||||
<context context-type="linenumber">254</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="4166903555074156852" datatype="html">
|
||||
<source>Has all of these tags</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">229</context>
|
||||
<context context-type="linenumber">261</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="6624363795312783141" datatype="html">
|
||||
<source>Does not have these tags</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">236</context>
|
||||
<context context-type="linenumber">268</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="7168528512669831184" datatype="html">
|
||||
<source>Has any of these correspondents</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">243</context>
|
||||
<context context-type="linenumber">275</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="5281365940563983618" datatype="html">
|
||||
<source>Has correspondent</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">251</context>
|
||||
<context context-type="linenumber">283</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="6884498632428600393" datatype="html">
|
||||
<source>Does not have correspondents</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">259</context>
|
||||
<context context-type="linenumber">291</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="4806713133917046341" datatype="html">
|
||||
<source>Has document type</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">267</context>
|
||||
<context context-type="linenumber">299</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="8801397520369995032" datatype="html">
|
||||
<source>Has any of these document types</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">275</context>
|
||||
<context context-type="linenumber">307</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="1507843981661822403" datatype="html">
|
||||
<source>Does not have document types</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">283</context>
|
||||
<context context-type="linenumber">315</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="4277260190522078330" datatype="html">
|
||||
<source>Has storage path</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">291</context>
|
||||
<context context-type="linenumber">323</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="8858580062214623097" datatype="html">
|
||||
<source>Has any of these storage paths</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">299</context>
|
||||
<context context-type="linenumber">331</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="6070943364927280151" datatype="html">
|
||||
<source>Does not have storage paths</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">307</context>
|
||||
<context context-type="linenumber">339</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="6250799006816371860" datatype="html">
|
||||
<source>Matches custom field query</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">315</context>
|
||||
<context context-type="linenumber">347</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="3138206142174978019" datatype="html">
|
||||
<source>Create new workflow</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">524</context>
|
||||
<context context-type="linenumber">556</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="5996779210524133604" datatype="html">
|
||||
<source>Edit workflow</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">528</context>
|
||||
<context context-type="linenumber">560</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="5457837313196342910" datatype="html">
|
||||
@@ -8021,35 +8127,35 @@
|
||||
<source>Paperless-ngx is running!</source>
|
||||
<context-group purpose="location">
|
||||
<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>
|
||||
</trans-unit>
|
||||
<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>
|
||||
<context-group purpose="location">
|
||||
<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>
|
||||
</trans-unit>
|
||||
<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>
|
||||
<context-group purpose="location">
|
||||
<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>
|
||||
</trans-unit>
|
||||
<trans-unit id="4294899532887357745" datatype="html">
|
||||
<source>Thanks for being a part of the Paperless-ngx community!</source>
|
||||
<context-group purpose="location">
|
||||
<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>
|
||||
</trans-unit>
|
||||
<trans-unit id="1415832194529539652" datatype="html">
|
||||
<source>Start the tour</source>
|
||||
<context-group purpose="location">
|
||||
<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>
|
||||
</trans-unit>
|
||||
<trans-unit id="7822640317427130239" datatype="html">
|
||||
@@ -8208,29 +8314,6 @@
|
||||
<context context-type="linenumber">164,165</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/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">
|
||||
<source>Date created</source>
|
||||
<context-group purpose="location">
|
||||
|
||||
@@ -10,16 +10,12 @@
|
||||
tourAnchor="tour.intro">
|
||||
@if (!hasCustomBranding) {
|
||||
<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">
|
||||
<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>
|
||||
<pngx-brand-mark class="brand-mark brand-mark-slim d-none"></pngx-brand-mark>
|
||||
} @else {
|
||||
@if (customAppLogo) {
|
||||
<img class="brand-logo" [src]="customAppLogo" alt="" />
|
||||
} @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">
|
||||
<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>
|
||||
<pngx-brand-mark class="brand-mark"></pngx-brand-mark>
|
||||
}
|
||||
<div class="brand-copy ms-2 text-truncate" [class.d-md-none]="slimSidebarEnabled">
|
||||
<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 { environment } from 'src/environments/environment'
|
||||
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 { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
|
||||
import { DocumentDetailComponent } from '../document-detail/document-detail.component'
|
||||
@@ -61,6 +62,7 @@ const SCROLL_THRESHOLD = 16
|
||||
imports: [
|
||||
GlobalSearchComponent,
|
||||
LogoComponent,
|
||||
BrandMarkComponent,
|
||||
DocumentTitlePipe,
|
||||
IfPermissionsDirective,
|
||||
ToastsDropdownComponent,
|
||||
|
||||
+39
@@ -462,6 +462,45 @@
|
||||
</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>
|
||||
</ng-template>
|
||||
|
||||
+124
-2
@@ -22,6 +22,7 @@ import {
|
||||
} from 'src/app/data/matching-model'
|
||||
import { Workflow } from 'src/app/data/workflow'
|
||||
import {
|
||||
AISuggestionField,
|
||||
WorkflowAction,
|
||||
WorkflowActionType,
|
||||
} 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 { EditDialogMode } from '../edit-dialog.component'
|
||||
import {
|
||||
AI_SUGGESTION_FIELD_OPTIONS,
|
||||
DOCUMENT_SOURCE_OPTIONS,
|
||||
SCHEDULE_DATE_FIELD_OPTIONS,
|
||||
TriggerFilterType,
|
||||
@@ -239,14 +241,15 @@ describe('WorkflowEditDialogComponent', () => {
|
||||
SCHEDULE_DATE_FIELD_OPTIONS
|
||||
)
|
||||
|
||||
// Email disabled
|
||||
// Email, remote OCR and AI all disabled
|
||||
jest.spyOn(settingsService, 'get').mockReturnValue(false)
|
||||
component.ngOnInit()
|
||||
expect(component.actionTypeOptions).toEqual(
|
||||
WORKFLOW_ACTION_OPTIONS.filter(
|
||||
(a) =>
|
||||
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', () => {
|
||||
component.object = workflow
|
||||
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 { Workflow } from 'src/app/data/workflow'
|
||||
import {
|
||||
AISuggestionField,
|
||||
WorkflowAction,
|
||||
WorkflowActionType,
|
||||
} from 'src/app/data/workflow-action'
|
||||
@@ -152,6 +153,37 @@ export const WORKFLOW_ACTION_OPTIONS = [
|
||||
id: WorkflowActionType.RemoteOcr,
|
||||
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 {
|
||||
@@ -576,6 +608,24 @@ export class WorkflowEditDialogComponent
|
||||
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 (
|
||||
this.allowedActionTypes?.length === allowed.length &&
|
||||
this.allowedActionTypes.every((a, i) => a.id === allowed[i].id)
|
||||
@@ -1227,6 +1277,11 @@ export class WorkflowEditDialogComponent
|
||||
passwords: new FormControl(
|
||||
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 }
|
||||
)
|
||||
@@ -1316,6 +1371,10 @@ export class WorkflowEditDialogComponent
|
||||
return this.actionTypeOptions.find((t) => t.id === type)?.name ?? ''
|
||||
}
|
||||
|
||||
get aiSuggestionFieldOptions() {
|
||||
return AI_SUGGESTION_FIELD_OPTIONS
|
||||
}
|
||||
|
||||
addAction() {
|
||||
if (!this.object) {
|
||||
this.object = Object.assign({}, this.objectForm.value)
|
||||
@@ -1369,6 +1428,9 @@ export class WorkflowEditDialogComponent
|
||||
include_document: false,
|
||||
},
|
||||
passwords: [],
|
||||
ai_suggestion_fields: [],
|
||||
ai_create_missing: false,
|
||||
ai_overwrite_existing: false,
|
||||
}
|
||||
this.object.actions.push(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)">
|
||||
<h4 class="alert-heading"><ng-container i18n>Paperless-ngx is running!</ng-container> 🎉</h4>
|
||||
<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 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>
|
||||
<hr>
|
||||
<div class="d-flex align-items-end">
|
||||
<p class="lead fs-6 m-0"><em i18n>Thanks for being a part of the Paperless-ngx community!</em></p>
|
||||
<button class="btn btn-primary ms-auto flex-shrink-0" (click)="tourService.start()"><ng-container i18n>Start the tour</ng-container> →</button>
|
||||
<ngb-alert class="welcome-widget bg-light shadow-sm pe-3 position-relative overflow-hidden" type="primary" [dismissible]="true" (closed)="dismiss.emit(true)">
|
||||
<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>
|
||||
<div class="d-flex align-items-start gap-3 gap-md-4 position-relative">
|
||||
<pngx-brand-mark class="d-none d-sm-block flex-shrink-0 text-primary" width="3rem" height="3rem"></pngx-brand-mark>
|
||||
<div class="flex-grow-1">
|
||||
<h4 class="alert-heading text-primary fw-semibold"><ng-container i18n>Paperless-ngx is running!</ng-container> 🎉</h4>
|
||||
<div class="welcome-copy text-dark">
|
||||
<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>
|
||||
</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 { NgbAlertModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { TourService } from 'ngx-ui-tour-ng-bootstrap'
|
||||
import { BrandMarkComponent } from '../../../common/logo/brand-mark/brand-mark.component'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-welcome-widget',
|
||||
templateUrl: './welcome-widget.component.html',
|
||||
styleUrls: ['./welcome-widget.component.scss'],
|
||||
imports: [NgbAlertModule],
|
||||
imports: [NgbAlertModule, BrandMarkComponent],
|
||||
})
|
||||
export class WelcomeWidgetComponent {
|
||||
readonly tourService = inject(TourService)
|
||||
|
||||
@@ -8,6 +8,17 @@ export enum WorkflowActionType {
|
||||
PasswordRemoval = 5,
|
||||
MoveToTrash = 6,
|
||||
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 {
|
||||
@@ -102,4 +113,10 @@ export interface WorkflowAction extends ObjectWithId {
|
||||
webhook?: WorkflowActionWebhook
|
||||
|
||||
passwords?: string[]
|
||||
|
||||
ai_suggestion_fields?: AISuggestionField[]
|
||||
|
||||
ai_create_missing?: boolean
|
||||
|
||||
ai_overwrite_existing?: boolean
|
||||
}
|
||||
|
||||
@@ -2,12 +2,15 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import pickle
|
||||
import time
|
||||
from binascii import hexlify
|
||||
from collections import OrderedDict
|
||||
from dataclasses import dataclass
|
||||
from hashlib import sha256
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import Any
|
||||
from typing import Final
|
||||
from uuid import uuid4
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
@@ -16,6 +19,7 @@ from django.core.cache import caches
|
||||
from documents.models import Document
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.cache.backends.base import BaseCache
|
||||
|
||||
from documents.classifier import DocumentClassifier
|
||||
@@ -52,6 +56,9 @@ CLASSIFIER_MODIFIED_KEY: Final[str] = "classifier_modified"
|
||||
# [...]} per taxonomy field (#13676)
|
||||
LLM_CACHE_CLASSIFIER_VERSION: Final[int] = 1001
|
||||
|
||||
# How often a request waiting on llm generation re-checks the cache
|
||||
LLM_SUGGESTION_POLL_INTERVAL: Final[float] = 0.5
|
||||
|
||||
CACHE_1_MINUTE: Final[int] = 60
|
||||
CACHE_5_MINUTES: Final[int] = 5 * CACHE_1_MINUTE
|
||||
CACHE_50_MINUTES: Final[int] = 50 * CACHE_1_MINUTE
|
||||
@@ -223,6 +230,68 @@ def get_llm_suggestion_cache(
|
||||
return None
|
||||
|
||||
|
||||
def retrieve_llm_suggestions(
|
||||
document: Document,
|
||||
user: User | None,
|
||||
output_language: str | None,
|
||||
*,
|
||||
backend: str,
|
||||
lock_timeout: int,
|
||||
) -> dict:
|
||||
"""Return cached LLM suggestions, generating them once across workers."""
|
||||
# Lazy import to avoid pulling in the whole AI stuff
|
||||
from paperless_ai.ai_classifier import get_ai_document_classification
|
||||
from paperless_ai.exceptions import LLMTimeoutError
|
||||
|
||||
lock_key = (
|
||||
f"{get_suggestion_cache_key(document.pk)}_llm_lock_"
|
||||
f"{sha256(backend.encode()).hexdigest()}"
|
||||
)
|
||||
waited = False
|
||||
|
||||
while True:
|
||||
cached = get_llm_suggestion_cache(document.pk, backend=backend)
|
||||
if cached is not None:
|
||||
refresh_suggestions_cache(document.pk)
|
||||
return cached.suggestions
|
||||
|
||||
lock_token = uuid4().hex
|
||||
if cache.add(lock_key, lock_token, lock_timeout):
|
||||
if waited:
|
||||
# The generation we were waiting on has ended without caching
|
||||
# anything so it either failed or outlived its lock. Give up
|
||||
# rather than re-running it
|
||||
cache.delete(lock_key)
|
||||
raise LLMTimeoutError
|
||||
|
||||
try:
|
||||
# The cache may have been populated while acquiring the lock.
|
||||
cached = get_llm_suggestion_cache(document.pk, backend=backend)
|
||||
if cached is not None:
|
||||
refresh_suggestions_cache(document.pk)
|
||||
return cached.suggestions
|
||||
|
||||
suggestions = get_ai_document_classification(
|
||||
document,
|
||||
user,
|
||||
output_language,
|
||||
)
|
||||
set_llm_suggestions_cache(
|
||||
document.pk,
|
||||
suggestions,
|
||||
backend=backend,
|
||||
)
|
||||
return suggestions
|
||||
finally:
|
||||
# Don't remove lock if this one expired while generation was still running
|
||||
if cache.get(lock_key) == lock_token:
|
||||
cache.delete(lock_key)
|
||||
|
||||
waited = True
|
||||
# Another worker is generating suggestions, poll to avoid another LLM request
|
||||
time.sleep(LLM_SUGGESTION_POLL_INTERVAL)
|
||||
|
||||
|
||||
def set_llm_suggestions_cache(
|
||||
document_id: int,
|
||||
suggestions: dict,
|
||||
|
||||
@@ -129,7 +129,7 @@ class DocumentMetadataOverrides:
|
||||
)
|
||||
overrides.custom_fields = {
|
||||
custom_field.field.id: custom_field.value
|
||||
for custom_field in doc.custom_fields.select_related("field").all()
|
||||
for custom_field in doc.custom_fields.all()
|
||||
}
|
||||
|
||||
groups_with_perms = get_groups_with_perms(
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -766,6 +766,7 @@ class PaperlessTask(ModelWithOwner):
|
||||
REPROCESS_DOCUMENT = "reprocess_document", _("Reprocess Document")
|
||||
BUILD_SHARE_LINK = "build_share_link", _("Build Share Link")
|
||||
BULK_DELETE = "bulk_delete", _("Bulk Delete")
|
||||
APPLY_AI_SUGGESTIONS = "apply_ai_suggestions", _("Apply AI Suggestions")
|
||||
|
||||
COMPLETE_STATUSES = (
|
||||
Status.SUCCESS,
|
||||
@@ -1674,6 +1675,18 @@ class WorkflowAction(models.Model):
|
||||
7,
|
||||
_("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(
|
||||
_("Workflow Action Type"),
|
||||
@@ -1912,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:
|
||||
verbose_name = _("workflow action")
|
||||
verbose_name_plural = _("workflow actions")
|
||||
|
||||
+48
-101
@@ -3,7 +3,6 @@ from __future__ import annotations
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
from collections.abc import Iterable
|
||||
from datetime import datetime
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
@@ -877,106 +876,8 @@ def validate_documentlink_targets(user, doc_ids):
|
||||
)
|
||||
|
||||
|
||||
# drf-writable-nested revalidates a document's custom_fields more than once
|
||||
# per request: once as the ordinary nested list, then again per-item while
|
||||
# matching existing vs. new CustomFieldInstance rows during save() -- and
|
||||
# that second pass builds a brand new serializer (and field) instance per
|
||||
# item (see its update_or_create_reverse_relations / _get_serializer_for_field),
|
||||
# so a cache on the field instance alone only helps the first pass. It does,
|
||||
# however, explicitly pass `context=self.context` to every one of those
|
||||
# fresh serializers -- the *same* dict object the outer DocumentSerializer
|
||||
# is using, not a copy. That context dict is already request-scoped (DRF
|
||||
# builds it fresh per request via get_serializer_context()), so stashing the
|
||||
# resolved CustomField objects there -- rather than in some new global/
|
||||
# thread-local cache -- lets every later pass reuse them for free while
|
||||
# staying entirely within DRF's existing, already-request-scoped machinery.
|
||||
_CUSTOM_FIELD_CONTEXT_CACHE_KEY = "_custom_field_lookup_cache"
|
||||
|
||||
|
||||
class _CachingCustomFieldPrimaryKeyField(serializers.PrimaryKeyRelatedField):
|
||||
"""
|
||||
Resolves CustomField ids with as few queries as possible: a per-instance
|
||||
cache for repeat lookups on this exact field instance, backed by a
|
||||
shared cache on the serializer context (see _CUSTOM_FIELD_CONTEXT_CACHE_KEY
|
||||
above) so later, separately-instantiated fields for the same request
|
||||
reuse what was already resolved instead of re-querying.
|
||||
"""
|
||||
|
||||
def __init__(self, **kwargs: Any) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self._cache: dict[int, CustomField] = {}
|
||||
|
||||
def _shared_cache(self) -> dict[int, CustomField]:
|
||||
return self.context.setdefault(_CUSTOM_FIELD_CONTEXT_CACHE_KEY, {})
|
||||
|
||||
@staticmethod
|
||||
def _normalize_pk(data: Any) -> int | None:
|
||||
"""
|
||||
Returns `data` coerced to the int a valid CustomField pk would be,
|
||||
or None if `data` isn't a plausible pk (wrong type, unhashable,
|
||||
non-numeric, or a bool -- DRF itself rejects bools as pks since
|
||||
`True == 1` would otherwise silently match). None tells callers to
|
||||
leave `data` alone and let `super().to_internal_value()` report the
|
||||
normal validation error instead of touching the cache/queryset with
|
||||
it directly.
|
||||
"""
|
||||
if isinstance(data, bool):
|
||||
return None
|
||||
try:
|
||||
return int(data)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
def prefetch(self, ids: Iterable[Any]) -> None:
|
||||
shared_cache = self._shared_cache()
|
||||
candidates = {pk for i in ids if (pk := self._normalize_pk(i)) is not None}
|
||||
missing = {
|
||||
i for i in candidates if i not in self._cache and i not in shared_cache
|
||||
}
|
||||
if missing:
|
||||
for obj in self.get_queryset().filter(pk__in=missing):
|
||||
shared_cache[obj.pk] = obj
|
||||
for i in candidates:
|
||||
obj = shared_cache.get(i)
|
||||
if obj is not None:
|
||||
self._cache[i] = obj
|
||||
|
||||
def to_internal_value(self, data: Any) -> CustomField:
|
||||
pk = self._normalize_pk(data)
|
||||
if pk is None:
|
||||
return super().to_internal_value(data)
|
||||
if pk in self._cache:
|
||||
return self._cache[pk]
|
||||
shared_cache = self._shared_cache()
|
||||
if pk in shared_cache:
|
||||
obj = shared_cache[pk]
|
||||
self._cache[pk] = obj
|
||||
return obj
|
||||
obj: CustomField = super().to_internal_value(data)
|
||||
self._cache[obj.pk] = obj
|
||||
shared_cache[obj.pk] = obj
|
||||
return obj
|
||||
|
||||
|
||||
class CustomFieldInstanceListSerializer(serializers.ListSerializer):
|
||||
def to_internal_value(self, data: Any) -> list[Any]:
|
||||
if isinstance(data, list):
|
||||
field_ids = []
|
||||
for item in data:
|
||||
if not isinstance(item, dict) or "field" not in item:
|
||||
continue
|
||||
try:
|
||||
hash(item["field"])
|
||||
except TypeError:
|
||||
continue
|
||||
field_ids.append(item["field"])
|
||||
if field_ids:
|
||||
self.child.fields["field"].prefetch(field_ids)
|
||||
return super().to_internal_value(data)
|
||||
|
||||
|
||||
class CustomFieldInstanceSerializer(serializers.ModelSerializer[CustomFieldInstance]):
|
||||
field = _CachingCustomFieldPrimaryKeyField(queryset=CustomField.objects.all())
|
||||
field = serializers.PrimaryKeyRelatedField(queryset=CustomField.objects.all())
|
||||
value = ReadWriteSerializerMethodField(allow_null=True)
|
||||
|
||||
def create(self, validated_data):
|
||||
@@ -1077,7 +978,6 @@ class CustomFieldInstanceSerializer(serializers.ModelSerializer[CustomFieldInsta
|
||||
|
||||
class Meta:
|
||||
model = CustomFieldInstance
|
||||
list_serializer_class = CustomFieldInstanceListSerializer
|
||||
fields = [
|
||||
"value",
|
||||
"field",
|
||||
@@ -3335,6 +3235,9 @@ class WorkflowActionSerializer(serializers.ModelSerializer[WorkflowAction]):
|
||||
"email",
|
||||
"webhook",
|
||||
"passwords",
|
||||
"ai_suggestion_fields",
|
||||
"ai_create_missing",
|
||||
"ai_overwrite_existing",
|
||||
]
|
||||
|
||||
def validate(self, attrs):
|
||||
@@ -3392,6 +3295,23 @@ class WorkflowActionSerializer(serializers.ModelSerializer[WorkflowAction]):
|
||||
"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
|
||||
|
||||
|
||||
@@ -3420,24 +3340,43 @@ class WorkflowSerializer(serializers.ModelSerializer[Workflow]):
|
||||
action.get("type") == WorkflowAction.WorkflowActionType.REMOTE_OCR
|
||||
for action in attrs["actions"]
|
||||
)
|
||||
has_ai_suggestions_action = any(
|
||||
action.get("type")
|
||||
== WorkflowAction.WorkflowActionType.APPLY_AI_SUGGESTIONS
|
||||
for action in attrs["actions"]
|
||||
)
|
||||
else:
|
||||
has_remote_ocr_action = self.instance is not None and (
|
||||
self.instance.actions.filter(
|
||||
type=WorkflowAction.WorkflowActionType.REMOTE_OCR,
|
||||
).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:
|
||||
has_consumption_trigger = any(
|
||||
trigger.get("type") == WorkflowTrigger.WorkflowTriggerType.CONSUMPTION
|
||||
for trigger in attrs["triggers"]
|
||||
)
|
||||
has_non_consumption_trigger = any(
|
||||
trigger.get("type") != WorkflowTrigger.WorkflowTriggerType.CONSUMPTION
|
||||
for trigger in attrs["triggers"]
|
||||
)
|
||||
else:
|
||||
has_consumption_trigger = self.instance is not None and (
|
||||
self.instance.triggers.filter(
|
||||
type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
|
||||
).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
|
||||
if has_remote_ocr_action and not has_consumption_trigger:
|
||||
@@ -3445,6 +3384,14 @@ class WorkflowSerializer(serializers.ModelSerializer[Workflow]):
|
||||
"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
|
||||
|
||||
def update_triggers_and_actions(
|
||||
|
||||
@@ -984,6 +984,28 @@ def run_workflows(
|
||||
"triggers, ignoring",
|
||||
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:
|
||||
# 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.build_share_link_bundle": PaperlessTask.TaskType.BUILD_SHARE_LINK,
|
||||
"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] = {
|
||||
@@ -1092,6 +1115,12 @@ def _extract_input_data(
|
||||
return {"account_ids": account_ids}
|
||||
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 {}
|
||||
|
||||
|
||||
|
||||
@@ -71,6 +71,7 @@ from paperless.config import RemoteOCRConfig
|
||||
from paperless.logging import consume_task_id
|
||||
from paperless.parsers import ParserContext
|
||||
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_remove_document
|
||||
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
|
||||
def update_document_in_llm_index(document) -> None:
|
||||
llm_index_add_or_update_document(document)
|
||||
|
||||
@@ -5,9 +5,7 @@ from unittest.mock import ANY
|
||||
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.contrib.auth.models import User
|
||||
from django.db import connection
|
||||
from django.test import override_settings
|
||||
from django.test.utils import CaptureQueriesContext
|
||||
from guardian.shortcuts import assign_perm
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APITestCase
|
||||
@@ -15,9 +13,6 @@ from rest_framework.test import APITestCase
|
||||
from documents.models import CustomField
|
||||
from documents.models import CustomFieldInstance
|
||||
from documents.models import Document
|
||||
from documents.serialisers import CustomFieldInstanceSerializer
|
||||
from documents.serialisers import DocumentSerializer
|
||||
from documents.tests.factories import DocumentFactory
|
||||
from documents.tests.utils import DirectoriesMixin
|
||||
|
||||
|
||||
@@ -535,136 +530,6 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase):
|
||||
doc.refresh_from_db()
|
||||
self.assertEqual(len(doc.custom_fields.all()), 10)
|
||||
|
||||
def test_document_serializer_custom_fields_validation_batches_field_lookup(
|
||||
self,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A document is being validated with several custom field values
|
||||
at once (as happens on every PATCH/PUT/POST)
|
||||
WHEN:
|
||||
- The serializer is validated
|
||||
THEN:
|
||||
- The referenced CustomField objects are resolved with a single
|
||||
query, not one query per custom field
|
||||
"""
|
||||
doc = DocumentFactory(mime_type="application/pdf")
|
||||
custom_fields = [
|
||||
CustomField.objects.create(
|
||||
name=f"Test Custom Field {i}",
|
||||
data_type=CustomField.FieldDataType.STRING,
|
||||
)
|
||||
for i in range(5)
|
||||
]
|
||||
|
||||
serializer = DocumentSerializer(
|
||||
doc,
|
||||
data={
|
||||
"custom_fields": [
|
||||
{"field": custom_field.id, "value": "test value"}
|
||||
for custom_field in custom_fields
|
||||
],
|
||||
},
|
||||
partial=True,
|
||||
)
|
||||
|
||||
with CaptureQueriesContext(connection) as ctx:
|
||||
self.assertTrue(serializer.is_valid(), serializer.errors)
|
||||
|
||||
custom_field_lookups = [
|
||||
query
|
||||
for query in ctx.captured_queries
|
||||
if 'FROM "documents_customfield" WHERE "documents_customfield"."id"'
|
||||
in query["sql"]
|
||||
]
|
||||
self.assertEqual(
|
||||
len(custom_field_lookups),
|
||||
1,
|
||||
"Expected a single batched query to resolve the custom fields, "
|
||||
f"got {len(custom_field_lookups)}: {custom_field_lookups}",
|
||||
)
|
||||
|
||||
def test_custom_field_lookup_reuses_shared_context_cache(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A CustomField has already been resolved once, by a serializer
|
||||
sharing a given `context` dict
|
||||
WHEN:
|
||||
- A second, separately-instantiated CustomFieldInstanceSerializer
|
||||
validates the same field id, sharing that same context
|
||||
(this is what drf-writable-nested does: it rebuilds a fresh
|
||||
serializer -- and fresh field instances -- per item while
|
||||
matching existing vs. new instances during save())
|
||||
THEN:
|
||||
- No additional query is issued to resolve the CustomField
|
||||
"""
|
||||
custom_field = CustomField.objects.create(
|
||||
name="Test Custom Field",
|
||||
data_type=CustomField.FieldDataType.STRING,
|
||||
)
|
||||
|
||||
context: dict = {}
|
||||
first_pass = CustomFieldInstanceSerializer(
|
||||
data={"field": custom_field.id, "value": "a"},
|
||||
context=context,
|
||||
)
|
||||
self.assertTrue(first_pass.is_valid(), first_pass.errors)
|
||||
|
||||
second_pass = CustomFieldInstanceSerializer(
|
||||
data={"field": custom_field.id, "value": "b"},
|
||||
context=context,
|
||||
)
|
||||
with CaptureQueriesContext(connection) as ctx:
|
||||
self.assertTrue(second_pass.is_valid(), second_pass.errors)
|
||||
|
||||
custom_field_lookups = [
|
||||
query
|
||||
for query in ctx.captured_queries
|
||||
if 'FROM "documents_customfield" WHERE "documents_customfield"."id"'
|
||||
in query["sql"]
|
||||
]
|
||||
self.assertEqual(
|
||||
len(custom_field_lookups),
|
||||
0,
|
||||
"Expected the second, separately-instantiated serializer to reuse "
|
||||
f"the already-resolved CustomField, got: {custom_field_lookups}",
|
||||
)
|
||||
|
||||
def test_custom_field_validation_rejects_malformed_field_value(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A document is being validated with a malformed custom_fields
|
||||
entry whose "field" value is neither a valid CustomField id
|
||||
nor a type DRF's own PrimaryKeyRelatedField can safely reject
|
||||
on its own (unhashable, or a non-numeric scalar)
|
||||
WHEN:
|
||||
- The serializer is validated
|
||||
THEN:
|
||||
- A normal validation error is raised, not an unhandled
|
||||
TypeError/ValueError escaping past DRF's validation layer
|
||||
"""
|
||||
doc = DocumentFactory(mime_type="application/pdf")
|
||||
|
||||
bad_field_values = {
|
||||
"unhashable-list": [],
|
||||
"unhashable-dict": {},
|
||||
"non-numeric-scalar": "abc",
|
||||
}
|
||||
for case_id, bad_field_value in bad_field_values.items():
|
||||
with self.subTest(case_id):
|
||||
serializer = DocumentSerializer(
|
||||
doc,
|
||||
data={
|
||||
"custom_fields": [
|
||||
{"field": bad_field_value, "value": "test value"},
|
||||
],
|
||||
},
|
||||
partial=True,
|
||||
)
|
||||
|
||||
self.assertFalse(serializer.is_valid())
|
||||
self.assertIn("custom_fields", serializer.errors)
|
||||
|
||||
def test_change_custom_field_instance_value(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
|
||||
@@ -11,6 +11,7 @@ from django.contrib.auth.models import User
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import FieldError
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.test import TestCase as DjangoTestCase
|
||||
from django.utils import timezone
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APITestCase
|
||||
@@ -21,6 +22,7 @@ from documents.filters import TitleContentFilter
|
||||
from documents.models import Document
|
||||
from documents.tests.utils import DirectoriesMixin
|
||||
from documents.tests.utils import read_streaming_response
|
||||
from documents.views import DocumentSelectionMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
@@ -923,3 +925,36 @@ class TestVersionAwareFilters(TestCase):
|
||||
|
||||
self.assertIs(result, queryset)
|
||||
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])
|
||||
|
||||
@@ -2486,7 +2486,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
||||
response = self.client.get("/api/documents/34676/suggestions/")
|
||||
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
|
||||
|
||||
@mock.patch("documents.views.get_ai_document_classification")
|
||||
@mock.patch("paperless_ai.ai_classifier.get_ai_document_classification")
|
||||
@override_settings(AI_ENABLED=True)
|
||||
def test_suggestions_still_uses_classifier_when_ai_enabled(
|
||||
self,
|
||||
|
||||
@@ -641,6 +641,145 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase):
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
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:
|
||||
"""
|
||||
GIVEN:
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import pickle
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from threading import Event
|
||||
from threading import Lock
|
||||
|
||||
import pytest
|
||||
|
||||
from documents.caching import StoredLRUCache
|
||||
from documents.caching import retrieve_llm_suggestions
|
||||
from paperless_ai.exceptions import LLMTimeoutError
|
||||
|
||||
|
||||
def test_lru_cache_entries() -> None:
|
||||
@@ -43,3 +50,119 @@ def test_stored_lru_cache_key_ttl(mocker) -> None:
|
||||
assert key == "test_key"
|
||||
assert timeout == 321
|
||||
assert pickle.loads(data) == {"x": "X", "y": "Y"}
|
||||
|
||||
|
||||
def test_llm_suggestions_are_generated_once_for_concurrent_requests(mocker) -> None:
|
||||
generation_started = Event()
|
||||
finish_generation = Event()
|
||||
waiter_started = Event()
|
||||
call_lock = Lock()
|
||||
calls = 0
|
||||
suggestions = {"title": "Generated once"}
|
||||
document = mocker.Mock(pk=42)
|
||||
user = mocker.Mock()
|
||||
|
||||
def generate(*args) -> dict:
|
||||
nonlocal calls
|
||||
with call_lock:
|
||||
calls += 1
|
||||
generation_started.set()
|
||||
assert finish_generation.wait(timeout=2)
|
||||
return suggestions
|
||||
|
||||
def wait_for_generation(_interval: float) -> None:
|
||||
waiter_started.set()
|
||||
assert finish_generation.wait(timeout=2)
|
||||
|
||||
mock_get_classification = mocker.patch(
|
||||
"paperless_ai.ai_classifier.get_ai_document_classification",
|
||||
side_effect=generate,
|
||||
)
|
||||
mocker.patch("documents.caching.time.sleep", side_effect=wait_for_generation)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
first = executor.submit(
|
||||
retrieve_llm_suggestions,
|
||||
document,
|
||||
user,
|
||||
None,
|
||||
backend="ollama:model",
|
||||
lock_timeout=10,
|
||||
)
|
||||
assert generation_started.wait(timeout=2)
|
||||
second = executor.submit(
|
||||
retrieve_llm_suggestions,
|
||||
document,
|
||||
user,
|
||||
None,
|
||||
backend="ollama:model",
|
||||
lock_timeout=10,
|
||||
)
|
||||
assert waiter_started.wait(timeout=2)
|
||||
finish_generation.set()
|
||||
|
||||
assert first.result(timeout=2) == suggestions
|
||||
assert second.result(timeout=2) == suggestions
|
||||
|
||||
assert calls == 1
|
||||
mock_get_classification.assert_called_once_with(document, user, None)
|
||||
|
||||
|
||||
def test_llm_suggestions_waiter_does_not_rerun_a_failed_generation(mocker) -> None:
|
||||
"""
|
||||
A request queued behind a generation that fails should give up, not take
|
||||
its turn at re-running a query that just failed.
|
||||
"""
|
||||
generation_started = Event()
|
||||
fail_generation = Event()
|
||||
waiter_started = Event()
|
||||
call_lock = Lock()
|
||||
calls = 0
|
||||
document = mocker.Mock(pk=43)
|
||||
user = mocker.Mock()
|
||||
|
||||
def generate(*args) -> dict:
|
||||
nonlocal calls
|
||||
with call_lock:
|
||||
calls += 1
|
||||
generation_started.set()
|
||||
assert fail_generation.wait(timeout=2)
|
||||
raise ValueError("Unknown model")
|
||||
|
||||
def wait_for_generation(_interval: float) -> None:
|
||||
waiter_started.set()
|
||||
assert fail_generation.wait(timeout=2)
|
||||
|
||||
mocker.patch(
|
||||
"paperless_ai.ai_classifier.get_ai_document_classification",
|
||||
side_effect=generate,
|
||||
)
|
||||
mocker.patch("documents.caching.time.sleep", side_effect=wait_for_generation)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
first = executor.submit(
|
||||
retrieve_llm_suggestions,
|
||||
document,
|
||||
user,
|
||||
None,
|
||||
backend="ollama:model",
|
||||
lock_timeout=10,
|
||||
)
|
||||
assert generation_started.wait(timeout=2)
|
||||
second = executor.submit(
|
||||
retrieve_llm_suggestions,
|
||||
document,
|
||||
user,
|
||||
None,
|
||||
backend="ollama:model",
|
||||
lock_timeout=10,
|
||||
)
|
||||
assert waiter_started.wait(timeout=2)
|
||||
fail_generation.set()
|
||||
|
||||
with pytest.raises(ValueError, match="Unknown model"):
|
||||
first.result(timeout=2)
|
||||
with pytest.raises(LLMTimeoutError):
|
||||
second.result(timeout=2)
|
||||
|
||||
assert calls == 1
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
from django.db import connection
|
||||
from django.test import TestCase
|
||||
from django.test.utils import CaptureQueriesContext
|
||||
|
||||
from documents.data_models import DocumentMetadataOverrides
|
||||
from documents.models import CustomField
|
||||
from documents.models import CustomFieldInstance
|
||||
from documents.tests.factories import DocumentFactory
|
||||
from documents.tests.utils import DirectoriesMixin
|
||||
|
||||
|
||||
class TestDocumentMetadataOverridesFromDocument(DirectoriesMixin, TestCase):
|
||||
def test_from_document_batches_custom_field_lookup_after_refresh_from_db(
|
||||
self,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A document has several custom field values
|
||||
- The document instance has just been refreshed from the database,
|
||||
which drops any prefetched related objects (as
|
||||
send_websocket_document_updated does before building overrides)
|
||||
WHEN:
|
||||
- DocumentMetadataOverrides.from_document() reads the document's
|
||||
custom field values
|
||||
THEN:
|
||||
- The referenced CustomField objects are resolved with a single
|
||||
query, not one query per custom field
|
||||
"""
|
||||
doc = DocumentFactory(mime_type="application/pdf")
|
||||
for i in range(5):
|
||||
CustomFieldInstance.objects.create(
|
||||
document=doc,
|
||||
field=CustomField.objects.create(
|
||||
name=f"Test Custom Field {i}",
|
||||
data_type=CustomField.FieldDataType.STRING,
|
||||
),
|
||||
value_text="value",
|
||||
)
|
||||
|
||||
doc.refresh_from_db()
|
||||
|
||||
with CaptureQueriesContext(connection) as ctx:
|
||||
overrides = DocumentMetadataOverrides.from_document(doc)
|
||||
|
||||
self.assertEqual(len(overrides.custom_fields), 5)
|
||||
unbatched_field_lookups = [
|
||||
query
|
||||
for query in ctx.captured_queries
|
||||
if 'FROM "documents_customfield" WHERE "documents_customfield"."id"'
|
||||
in query["sql"]
|
||||
]
|
||||
self.assertEqual(
|
||||
unbatched_field_lookups,
|
||||
[],
|
||||
"Expected CustomField data to come from the CustomFieldInstance "
|
||||
"join, not a separate per-instance lookup, "
|
||||
f"got: {unbatched_field_lookups}",
|
||||
)
|
||||
@@ -385,6 +385,25 @@ class TestTaskFailureHandler:
|
||||
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
|
||||
class TestTaskRevokedHandler:
|
||||
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 DocumentType
|
||||
from documents.models import Tag
|
||||
from documents.models import WorkflowAction
|
||||
from documents.sanity_checker import SanityCheckFailedException
|
||||
from documents.sanity_checker import SanityCheckMessages
|
||||
from documents.tests.test_classifier import dummy_preprocess
|
||||
@@ -447,3 +448,110 @@ class TestAIIndex(DirectoriesMixin, TestCase):
|
||||
rebuild=False,
|
||||
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))
|
||||
|
||||
@@ -441,7 +441,7 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
|
||||
self.assertEqual(response.json()["tags"], [])
|
||||
self.assertEqual(response.json()["suggested_tags"], [])
|
||||
|
||||
@patch("documents.views.get_ai_document_classification")
|
||||
@patch("paperless_ai.ai_classifier.get_ai_document_classification")
|
||||
@override_settings(
|
||||
AI_ENABLED=True,
|
||||
LLM_BACKEND="mock_backend",
|
||||
@@ -491,7 +491,7 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
|
||||
None,
|
||||
)
|
||||
|
||||
@patch("documents.views.get_ai_document_classification")
|
||||
@patch("paperless_ai.ai_classifier.get_ai_document_classification")
|
||||
@override_settings(
|
||||
AI_ENABLED=True,
|
||||
LLM_BACKEND="mock_backend",
|
||||
@@ -529,7 +529,7 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
|
||||
"KI Title",
|
||||
)
|
||||
|
||||
@patch("documents.views.get_ai_document_classification")
|
||||
@patch("paperless_ai.ai_classifier.get_ai_document_classification")
|
||||
@override_settings(
|
||||
AI_ENABLED=True,
|
||||
LLM_BACKEND="mock_backend",
|
||||
@@ -568,7 +568,7 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
|
||||
"Titre IA",
|
||||
)
|
||||
|
||||
@patch("documents.views.get_ai_document_classification")
|
||||
@patch("paperless_ai.ai_classifier.get_ai_document_classification")
|
||||
@override_settings(
|
||||
AI_ENABLED=True,
|
||||
LLM_BACKEND="mock_backend",
|
||||
@@ -604,7 +604,7 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
|
||||
),
|
||||
)
|
||||
|
||||
@patch("documents.views.get_ai_document_classification")
|
||||
@patch("paperless_ai.ai_classifier.get_ai_document_classification")
|
||||
@override_settings(
|
||||
AI_ENABLED=True,
|
||||
LLM_BACKEND="openai-like",
|
||||
@@ -633,7 +633,7 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
|
||||
get_llm_suggestion_cache(self.document.pk, backend="openai-like"),
|
||||
)
|
||||
|
||||
@patch("documents.views.get_ai_document_classification")
|
||||
@patch("paperless_ai.ai_classifier.get_ai_document_classification")
|
||||
@override_settings(
|
||||
AI_ENABLED=True,
|
||||
LLM_BACKEND="openai-like",
|
||||
@@ -660,7 +660,7 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
|
||||
get_llm_suggestion_cache(self.document.pk, backend="openai-like"),
|
||||
)
|
||||
|
||||
@patch("documents.views.get_ai_document_classification")
|
||||
@patch("paperless_ai.ai_classifier.get_ai_document_classification")
|
||||
@override_settings(
|
||||
AI_ENABLED=True,
|
||||
LLM_BACKEND="mock_backend",
|
||||
@@ -698,7 +698,7 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
|
||||
self.assertEqual(response.json()["tags"], [self.tag1.pk])
|
||||
self.assertEqual(response.json()["suggested_tags"], ["Follow-up"])
|
||||
|
||||
@patch("documents.views.get_ai_document_classification")
|
||||
@patch("paperless_ai.ai_classifier.get_ai_document_classification")
|
||||
@override_settings(
|
||||
AI_ENABLED=True,
|
||||
LLM_BACKEND="mock_backend",
|
||||
@@ -737,7 +737,7 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
|
||||
self.assertEqual(response.json()["tags"], [self.tag1.pk])
|
||||
self.assertEqual(response.json()["suggested_tags"], [])
|
||||
|
||||
@patch("documents.views.get_ai_document_classification")
|
||||
@patch("paperless_ai.ai_classifier.get_ai_document_classification")
|
||||
@override_settings(
|
||||
AI_ENABLED=True,
|
||||
LLM_BACKEND="mock_backend",
|
||||
|
||||
@@ -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_unique_filename
|
||||
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 paperless_ai.base_model import ClassificationSuggestions
|
||||
from paperless_ai.exceptions import LLMTimeoutError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
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))
|
||||
|
||||
|
||||
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))
|
||||
|
||||
+22
-28
@@ -115,7 +115,7 @@ from documents.caching import get_metadata_cache
|
||||
from documents.caching import get_suggestion_cache
|
||||
from documents.caching import refresh_metadata_cache
|
||||
from documents.caching import refresh_suggestions_cache
|
||||
from documents.caching import set_llm_suggestions_cache
|
||||
from documents.caching import retrieve_llm_suggestions
|
||||
from documents.caching import set_metadata_cache
|
||||
from documents.caching import set_suggestions_cache
|
||||
from documents.classifier import load_classifier
|
||||
@@ -246,7 +246,7 @@ from paperless.parsers.remote import RemoteEngineConfig
|
||||
from paperless.serialisers import GroupSerializer
|
||||
from paperless.serialisers import UserSerializer
|
||||
from paperless.views import StandardPagination
|
||||
from paperless_ai.ai_classifier import get_ai_document_classification
|
||||
from paperless_ai.ai_classifier import get_llm_output_language
|
||||
from paperless_ai.chat import stream_chat_with_documents
|
||||
from paperless_ai.exceptions import LLMTimeoutError
|
||||
from paperless_ai.matching import extract_unmatched_names
|
||||
@@ -665,20 +665,6 @@ class TagViewSet(PermissionsAwareDocumentCountMixin, ModelViewSet[Tag]):
|
||||
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))
|
||||
class DocumentTypeViewSet(
|
||||
PermissionsAwareDocumentCountMixin,
|
||||
@@ -1133,7 +1119,8 @@ class DocumentViewSet(
|
||||
"custom_fields",
|
||||
queryset=CustomFieldInstance.objects.select_related("field"),
|
||||
),
|
||||
"notes",
|
||||
# NotesSerializer nests the author, this avoids query per note
|
||||
Prefetch("notes", queryset=Note.objects.select_related("user")),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1541,7 +1528,10 @@ class DocumentViewSet(
|
||||
if not ai_config.ai_enabled:
|
||||
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(
|
||||
part
|
||||
for part in (
|
||||
@@ -1569,10 +1559,13 @@ class DocumentViewSet(
|
||||
llm_suggestions = cached_llm_suggestions.suggestions
|
||||
else:
|
||||
try:
|
||||
llm_suggestions = get_ai_document_classification(
|
||||
doc,
|
||||
request.user,
|
||||
output_language,
|
||||
llm_suggestions = retrieve_llm_suggestions(
|
||||
document=doc,
|
||||
user=request.user,
|
||||
output_language=output_language,
|
||||
backend=llm_cache_backend,
|
||||
# Classification, localization + 30s
|
||||
lock_timeout=(2 * ai_config.llm_request_timeout) + 30,
|
||||
)
|
||||
except ValueError as exc:
|
||||
logger.exception(
|
||||
@@ -1597,11 +1590,6 @@ class DocumentViewSet(
|
||||
{"ai": [_("AI backend request timed out.")]},
|
||||
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
)
|
||||
set_llm_suggestions_cache(
|
||||
doc.pk,
|
||||
llm_suggestions,
|
||||
backend=llm_cache_backend,
|
||||
)
|
||||
|
||||
tags_choice: TaxonomyChoiceDict = llm_suggestions["tags"]
|
||||
correspondents_choice: TaxonomyChoiceDict = llm_suggestions["correspondents"]
|
||||
@@ -2330,7 +2318,10 @@ class ChatStreamingView(GenericAPIView[Any]):
|
||||
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(
|
||||
stream_chat_with_documents(
|
||||
@@ -2794,8 +2785,11 @@ class DocumentSelectionMixin:
|
||||
for key, value in filters.items()
|
||||
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(
|
||||
id__in=permitted_document_ids(user),
|
||||
root_document__isnull=True,
|
||||
)
|
||||
# orm-filtered docs
|
||||
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
@@ -47,6 +47,22 @@ def get_language_name(language_code: str) -> str:
|
||||
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(
|
||||
document: Document,
|
||||
config: AIConfig,
|
||||
|
||||
@@ -11,7 +11,7 @@ from documents.models import Correspondent
|
||||
from documents.models import DocumentType
|
||||
from documents.models import StoragePath
|
||||
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
|
||||
|
||||
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(
|
||||
names: list[str],
|
||||
user: User,
|
||||
user: User | None,
|
||||
model: type[ModelT],
|
||||
perm: str,
|
||||
) -> 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)
|
||||
|
||||
|
||||
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")
|
||||
|
||||
|
||||
def match_correspondents_by_name(
|
||||
names: list[str],
|
||||
user: User,
|
||||
user: User | None,
|
||||
) -> list[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")
|
||||
|
||||
|
||||
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")
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from collections.abc import Callable
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
import pytest_mock
|
||||
@@ -45,33 +44,25 @@ class TestAIMatching(TestCase):
|
||||
self.storage_path1 = StoragePath.objects.create(name="Test Storage Path 1")
|
||||
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, mock_get_objects) -> None:
|
||||
mock_get_objects.return_value = Tag.objects.all()
|
||||
def test_match_tags_by_name(self) -> None:
|
||||
names = ["Test Tag 1", "Nonexistent Tag"]
|
||||
result = match_tags_by_name(names, user=None)
|
||||
self.assertEqual(len(result), 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, mock_get_objects) -> None:
|
||||
mock_get_objects.return_value = Correspondent.objects.all()
|
||||
def test_match_correspondents_by_name(self) -> None:
|
||||
names = ["Test Correspondent 1", "Nonexistent Correspondent"]
|
||||
result = match_correspondents_by_name(names, user=None)
|
||||
self.assertEqual(len(result), 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, mock_get_objects) -> None:
|
||||
mock_get_objects.return_value = DocumentType.objects.all()
|
||||
def test_match_document_types_by_name(self) -> None:
|
||||
names = ["Test Document Type 1", "Nonexistent Document Type"]
|
||||
result = match_document_types_by_name(names, user=None)
|
||||
self.assertEqual(len(result), 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, mock_get_objects) -> None:
|
||||
mock_get_objects.return_value = StoragePath.objects.all()
|
||||
def test_match_storage_paths_by_name(self) -> None:
|
||||
names = ["Test Storage Path 1", "Nonexistent Storage Path"]
|
||||
result = match_storage_paths_by_name(names, user=None)
|
||||
self.assertEqual(len(result), 1)
|
||||
@@ -83,16 +74,12 @@ class TestAIMatching(TestCase):
|
||||
unmatched_names = extract_unmatched_names(llm_names, matched_objects)
|
||||
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, mock_get_objects) -> None:
|
||||
mock_get_objects.return_value = Tag.objects.all()
|
||||
def test_match_tags_by_name_with_empty_names(self) -> None:
|
||||
names = [None, "", " "]
|
||||
result = match_tags_by_name(names, user=None)
|
||||
self.assertEqual(result, [])
|
||||
|
||||
@patch("paperless_ai.matching.get_objects_for_user_owner_aware")
|
||||
def test_match_tags_with_fuzzy_matching(self, mock_get_objects) -> None:
|
||||
mock_get_objects.return_value = Tag.objects.all()
|
||||
def test_match_tags_with_fuzzy_matching(self) -> None:
|
||||
names = ["Test Taag 1", "Teest Tag 2"]
|
||||
result = match_tags_by_name(names, user=None)
|
||||
self.assertEqual(len(result), 2)
|
||||
|
||||
Reference in New Issue
Block a user