Compare commits

..
21 changed files with 893 additions and 803 deletions
+53 -46
View File
@@ -662,7 +662,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">449,450</context>
<context context-type="linenumber">453,454</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component.html</context>
@@ -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">318</context>
<context context-type="linenumber">319</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">320</context>
<context context-type="linenumber">321</context>
</context-group>
</trans-unit>
<trans-unit id="2272120016352772836" datatype="html">
@@ -838,7 +838,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">468</context>
<context context-type="linenumber">472</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
@@ -1362,7 +1362,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">394,395</context>
<context context-type="linenumber">398,399</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
@@ -1611,7 +1611,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">169</context>
<context context-type="linenumber">173</context>
</context-group>
</trans-unit>
<trans-unit id="2691296884221415710" datatype="html">
@@ -1626,7 +1626,7 @@
</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>
<context context-type="linenumber">178</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
@@ -1661,7 +1661,7 @@
</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>
<context context-type="linenumber">182</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
@@ -1696,7 +1696,7 @@
</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>
<context context-type="linenumber">186</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
@@ -3330,11 +3330,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">326,327</context>
<context context-type="linenumber">327,328</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">329</context>
<context context-type="linenumber">330</context>
</context-group>
</trans-unit>
<trans-unit id="472206565520537964" datatype="html">
@@ -3492,49 +3492,49 @@
<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">336</context>
<context context-type="linenumber">337</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">346</context>
<context context-type="linenumber">347</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">346</context>
<context context-type="linenumber">347</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">350</context>
<context context-type="linenumber">351</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">358</context>
<context context-type="linenumber">359</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">370</context>
<context context-type="linenumber">371</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">375</context>
<context context-type="linenumber">376</context>
</context-group>
</trans-unit>
<trans-unit id="1542489069631984294" datatype="html">
@@ -5227,7 +5227,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">360,361</context>
<context context-type="linenumber">364,365</context>
</context-group>
</trans-unit>
<trans-unit id="8057014866157903311" datatype="html">
@@ -6134,7 +6134,7 @@
</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 context-type="linenumber">171</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
@@ -7627,6 +7627,13 @@
<context context-type="linenumber">120</context>
</context-group>
</trans-unit>
<trans-unit id="5700628356844396417" datatype="html">
<source>{VAR_PLURAL, plural, =1 {1 existing value suggested below} other {<x id="INTERPOLATION"/> existing values suggested below}}</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.html</context>
<context context-type="linenumber">53</context>
</context-group>
</trans-unit>
<trans-unit id="9180110319941008393" datatype="html">
<source>Environment</source>
<context-group purpose="location">
@@ -8314,21 +8321,21 @@
<source>Details</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">164,165</context>
<context context-type="linenumber">168,169</context>
</context-group>
</trans-unit>
<trans-unit id="5114742157723900905" datatype="html">
<source>Date created</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">171</context>
<context context-type="linenumber">175</context>
</context-group>
</trans-unit>
<trans-unit id="5607669932062416162" datatype="html">
<source>Default</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">183</context>
<context context-type="linenumber">187</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/saved-views/saved-views.component.html</context>
@@ -8339,14 +8346,14 @@
<source>Content</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">290,291</context>
<context context-type="linenumber">294,295</context>
</context-group>
</trans-unit>
<trans-unit id="218403386307979629" datatype="html">
<source>Metadata</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">299,300</context>
<context context-type="linenumber">303,304</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/metadata-collapse/metadata-collapse.component.ts</context>
@@ -8357,147 +8364,147 @@
<source>Date modified</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">306,307</context>
<context context-type="linenumber">310,311</context>
</context-group>
</trans-unit>
<trans-unit id="6392918669949841614" datatype="html">
<source>Date added</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">310,311</context>
<context context-type="linenumber">314,315</context>
</context-group>
</trans-unit>
<trans-unit id="146828917013192897" datatype="html">
<source>Media filename</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">314,315</context>
<context context-type="linenumber">318,319</context>
</context-group>
</trans-unit>
<trans-unit id="4500855521601039868" datatype="html">
<source>Original filename</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">318,319</context>
<context context-type="linenumber">322,323</context>
</context-group>
</trans-unit>
<trans-unit id="2659735245739197634" datatype="html">
<source>Original SHA256 checksum</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">322,323</context>
<context context-type="linenumber">326,327</context>
</context-group>
</trans-unit>
<trans-unit id="5888243105821763422" datatype="html">
<source>Original file size</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">326,327</context>
<context context-type="linenumber">330,331</context>
</context-group>
</trans-unit>
<trans-unit id="2696647325713149563" datatype="html">
<source>Original mime type</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">330,331</context>
<context context-type="linenumber">334,335</context>
</context-group>
</trans-unit>
<trans-unit id="6714358112223607756" datatype="html">
<source>Archive SHA256 checksum</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">335,336</context>
<context context-type="linenumber">339,340</context>
</context-group>
</trans-unit>
<trans-unit id="6033581412811562084" datatype="html">
<source>Archive file size</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">341,342</context>
<context context-type="linenumber">345,346</context>
</context-group>
</trans-unit>
<trans-unit id="6992781481378431874" datatype="html">
<source>Original document metadata</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">350</context>
<context context-type="linenumber">354</context>
</context-group>
</trans-unit>
<trans-unit id="2846565152091361585" datatype="html">
<source>Archived document metadata</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">353</context>
<context context-type="linenumber">357</context>
</context-group>
</trans-unit>
<trans-unit id="7206723502037428235" datatype="html">
<source>Notes <x id="START_BLOCK_IF" equiv-text="@if (document()?.notes.length) {"/><x id="START_TAG_SPAN" ctype="x-span" equiv-text="&lt;span class=&quot;badge text-bg-secondary ms-1&quot;&gt;"/><x id="INTERPOLATION" equiv-text="{{document().notes.length}}"/><x id="CLOSE_TAG_SPAN" ctype="x-span" equiv-text="&lt;/span&gt;"/><x id="CLOSE_BLOCK_IF" equiv-text="}"/></source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">372,375</context>
<context context-type="linenumber">376,379</context>
</context-group>
</trans-unit>
<trans-unit id="186236568870281953" datatype="html">
<source>History</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">383,384</context>
<context context-type="linenumber">387,388</context>
</context-group>
</trans-unit>
<trans-unit id="8236092845697214347" datatype="html">
<source> Duplicates <x id="START_TAG_SPAN" ctype="x-span" equiv-text="&lt;span class=&quot;badge text-bg-secondary ms-1&quot;&gt;"/><x id="INTERPOLATION" equiv-text="{{ document().duplicate_documents.length }}"/><x id="CLOSE_TAG_SPAN" ctype="x-span" equiv-text="&lt;/span&gt;"/></source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">406,409</context>
<context context-type="linenumber">410,413</context>
</context-group>
</trans-unit>
<trans-unit id="6449374629822973702" datatype="html">
<source>Duplicate documents detected:</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">411,412</context>
<context context-type="linenumber">415,416</context>
</context-group>
</trans-unit>
<trans-unit id="14058600336670816" datatype="html">
<source>In trash</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">422,423</context>
<context context-type="linenumber">426,427</context>
</context-group>
</trans-unit>
<trans-unit id="5129524307369213584" datatype="html">
<source>Save &amp; next</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">451</context>
<context context-type="linenumber">455</context>
</context-group>
</trans-unit>
<trans-unit id="4910102545766233758" datatype="html">
<source>Save &amp; close</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">453</context>
<context context-type="linenumber">457</context>
</context-group>
</trans-unit>
<trans-unit id="3823219296477075982" datatype="html">
<source>Discard</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">455,456</context>
<context context-type="linenumber">459,460</context>
</context-group>
</trans-unit>
<trans-unit id="1309556917227148591" datatype="html">
<source>Document loading...</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">463</context>
<context context-type="linenumber">467</context>
</context-group>
</trans-unit>
<trans-unit id="8191371354890763172" datatype="html">
<source>Enter Password</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">517</context>
<context context-type="linenumber">521</context>
</context-group>
</trans-unit>
<trans-unit id="5758784066858623886" datatype="html">
@@ -73,7 +73,7 @@
<div class="container-fluid">
<div class="row">
<nav id="sidebarMenu" class="d-md-block bg-light sidebar collapse"
[ngClass]="slimSidebarEnabled ? 'slim' : 'col-md-3 col-lg-2 col-xxxl-1'" [class.animating]="slimSidebarAnimating()"
[ngClass]="slimSidebarEnabled ? 'slim' : 'expanded'" [class.animating]="slimSidebarAnimating()"
[ngbCollapse]="isMenuCollapsed()">
@if (canSaveSettings) {
<button class="btn btn-sm btn-dark sidebar-slim-toggler" (click)="toggleSlimSidebar()" [aria-label]="slimSidebarEnabled ? 'Expand sidebar' : 'Collapse sidebar'" i18n-aria-label>
@@ -90,7 +90,7 @@
<a class="nav-link" routerLink="dashboard" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Dashboard" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-2" name="house"></i-bs><span><ng-container i18n>Dashboard</ng-container></span>
<i-bs class="me-2" name="house"></i-bs><span class="nav-link-label"><ng-container i18n>Dashboard</ng-container></span>
</a>
</li>
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
@@ -99,7 +99,7 @@
(click)="closeMenu()"
ngbPopover="Documents" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-2" name="files"></i-bs><span><ng-container i18n>Documents</ng-container></span>
<i-bs class="me-2" name="files"></i-bs><span class="nav-link-label"><ng-container i18n>Documents</ng-container></span>
</a>
</li>
</ul>
@@ -170,7 +170,7 @@
[class.text-truncate]="!slimSidebarEnabled" (click)="closeAll()"
ngbPopover="Close all" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-2" name="x"></i-bs><span><ng-container i18n>Close all</ng-container></span>
<i-bs class="me-2" name="x"></i-bs><span class="nav-link-label"><ng-container i18n>Close all</ng-container></span>
</button>
</li>
}
@@ -189,7 +189,7 @@
[routerLinkActiveOptions]="{ exact: !(slimSidebarEnabled || attributesSectionsCollapsed) }" (click)="closeMenu()"
ngbPopover="Attributes" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs name="stack"></i-bs><span class="ms-2"><ng-container i18n>Attributes</ng-container></span>
<i-bs name="stack"></i-bs><span class="nav-link-label ms-2"><ng-container i18n>Attributes</ng-container></span>
</a>
@if (!slimSidebarEnabled && canSaveSettings) {
<button
@@ -210,27 +210,27 @@
<ul class="nav flex-column">
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Tag }">
<a class="nav-link" routerLink="attributes/tags" routerLinkActive="active" (click)="closeMenu()">
<i-bs class="me-2" name="tags"></i-bs><span><ng-container i18n>Tags</ng-container></span>
<i-bs class="me-2" name="tags"></i-bs><span class="nav-link-label"><ng-container i18n>Tags</ng-container></span>
</a>
</li>
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Correspondent }">
<a class="nav-link" routerLink="attributes/correspondents" routerLinkActive="active" (click)="closeMenu()">
<i-bs class="me-2" name="person"></i-bs><span><ng-container i18n>Correspondents</ng-container></span>
<i-bs class="me-2" name="person"></i-bs><span class="nav-link-label"><ng-container i18n>Correspondents</ng-container></span>
</a>
</li>
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.DocumentType }">
<a class="nav-link" routerLink="attributes/documenttypes" routerLinkActive="active" (click)="closeMenu()">
<i-bs class="me-2" name="hash"></i-bs><span><ng-container i18n>Document types</ng-container></span>
<i-bs class="me-2" name="hash"></i-bs><span class="nav-link-label"><ng-container i18n>Document types</ng-container></span>
</a>
</li>
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.StoragePath }">
<a class="nav-link" routerLink="attributes/storagepaths" routerLinkActive="active" (click)="closeMenu()">
<i-bs class="me-2" name="folder"></i-bs><span><ng-container i18n>Storage paths</ng-container></span>
<i-bs class="me-2" name="folder"></i-bs><span class="nav-link-label"><ng-container i18n>Storage paths</ng-container></span>
</a>
</li>
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.CustomField }">
<a class="nav-link" routerLink="attributes/customfields" routerLinkActive="active" (click)="closeMenu()">
<i-bs class="me-2" name="ui-radios"></i-bs><span><ng-container i18n>Custom fields</ng-container></span>
<i-bs class="me-2" name="ui-radios"></i-bs><span class="nav-link-label"><ng-container i18n>Custom fields</ng-container></span>
</a>
</li>
</ul>
@@ -241,7 +241,7 @@
<a class="nav-link" routerLink="savedviews" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Saved Views" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-2" name="window-stack"></i-bs><span><ng-container i18n>Saved Views</ng-container></span>
<i-bs class="me-2" name="window-stack"></i-bs><span class="nav-link-label"><ng-container i18n>Saved Views</ng-container></span>
</a>
</li>
<li class="nav-item app-link"
@@ -250,7 +250,7 @@
<a class="nav-link" routerLink="workflows" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Workflows" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-2" name="boxes"></i-bs><span><ng-container i18n>Workflows</ng-container></span>
<i-bs class="me-2" name="boxes"></i-bs><span class="nav-link-label"><ng-container i18n>Workflows</ng-container></span>
</a>
</li>
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }"
@@ -258,14 +258,14 @@
<a class="nav-link" routerLink="mail" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Mail"
i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-2" name="envelope"></i-bs><span><ng-container i18n>Mail</ng-container></span>
<i-bs class="me-2" name="envelope"></i-bs><span class="nav-link-label"><ng-container i18n>Mail</ng-container></span>
</a>
</li>
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }">
<a class="nav-link" routerLink="trash" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Trash"
i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-2" name="trash"></i-bs><span><ng-container i18n>Trash</ng-container></span>
<i-bs class="me-2" name="trash"></i-bs><span class="nav-link-label"><ng-container i18n>Trash</ng-container></span>
</a>
</li>
</ul>
@@ -281,21 +281,21 @@
<a class="nav-link" routerLink="settings" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Settings" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-2" name="gear"></i-bs><span><ng-container i18n>Settings</ng-container></span>
<i-bs class="me-2" name="gear"></i-bs><span class="nav-link-label"><ng-container i18n>Settings</ng-container></span>
</a>
</li>
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.AppConfig }">
<a class="nav-link" routerLink="config" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Configuration" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-2" name="sliders2-vertical"></i-bs><span><ng-container i18n>Configuration</ng-container></span>
<i-bs class="me-2" name="sliders2-vertical"></i-bs><span class="nav-link-label"><ng-container i18n>Configuration</ng-container></span>
</a>
</li>
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.User }">
<a class="nav-link" routerLink="usersgroups" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Users & Groups" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-2" name="people"></i-bs><span><ng-container i18n>Users & Groups</ng-container></span>
<i-bs class="me-2" name="people"></i-bs><span class="nav-link-label"><ng-container i18n>Users & Groups</ng-container></span>
</a>
</li>
<li class="nav-item app-link"
@@ -304,9 +304,10 @@
<a class="nav-link" routerLink="tasks" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Tasks" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-2" name="list-task"></i-bs><span><ng-container i18n>Tasks</ng-container>@if (tasksService.needsAttentionTasks.length > 0) {
<span><span class="badge bg-danger ms-2 d-inline">{{tasksService.needsAttentionTasks.length}}</span></span>
}</span>
<i-bs class="me-2" name="list-task"></i-bs><span class="nav-link-label"><ng-container i18n>Tasks</ng-container></span>
@if (tasksService.needsAttentionTasks.length > 0 && !slimSidebarEnabled) {
<span class="badge bg-danger ms-2 d-inline flex-shrink-0">{{tasksService.needsAttentionTasks.length}}</span>
}
@if (tasksService.needsAttentionTasks.length > 0 && slimSidebarEnabled) {
<span class="badge bg-danger position-absolute top-0 end-0 d-none d-md-block">{{tasksService.needsAttentionTasks.length}}</span>
}
@@ -317,7 +318,7 @@
<a class="nav-link" routerLink="logs" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Logs"
i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-2" name="text-left"></i-bs><span><ng-container i18n>Logs</ng-container></span>
<i-bs class="me-2" name="text-left"></i-bs><span class="nav-link-label"><ng-container i18n>Logs</ng-container></span>
</a>
</li>
}
@@ -388,7 +389,7 @@
</nav>
<main role="main" class="ms-sm-auto px-md-4" [class.mobile-search-hidden]="mobileSearchHidden()"
[ngClass]="slimSidebarEnabled ? 'col-slim' : 'col-md-9 col-lg-10 col-xxxl-11'">
[ngClass]="slimSidebarEnabled ? 'col-slim' : 'col-sidebar-expanded'">
<router-outlet></router-outlet>
</main>
</div>
@@ -11,6 +11,7 @@
border-right: 1px solid color-mix(in srgb, var(--bs-border-color) 65%, transparent);
overflow-y: auto;
--pngx-sidebar-width: 100%;
width: var(--pngx-sidebar-width);
max-width: var(--pngx-sidebar-width);
transition: all .2s ease;
@@ -32,17 +33,10 @@
display: none !important;
}
// These come from the col-* classes for non-slim sidebar, needed for animation
@media (min-width: 768px) {
--pngx-sidebar-width: 25%;
}
@media (min-width: 992px) {
--pngx-sidebar-width: 16.66666667%;
}
@media (min-width: 2400px) {
--pngx-sidebar-width: 8.33333333%;
&.expanded {
--pngx-sidebar-width: var(--pngx-sidebar-expanded-width);
}
}
}
@media (max-width: 767.98px) {
@@ -260,6 +254,26 @@ main {
}
}
.sidebar .nav-link:has(> .nav-link-label) {
display: flex;
align-items: center;
min-width: 0;
> i-bs {
flex: 0 0 auto;
}
> .nav-link-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
}
.sidebar:not(.slim):not(.animating) .nav-link > .nav-link-label {
overflow: hidden;
}
.sidebar .nav-anchor, .sidebar .nav-label {
padding: .25rem .7rem;
}
@@ -385,7 +399,7 @@ main {
}
::ng-deep .navbar-official-logo {
will-change: filter; // Safari repaints the whole navbar on filter change without this
will-change: filter; // prevent resizing the filter region on hover and nudging the logo a pixel
filter: drop-shadow(0 1px 2px rgba(var(--pngx-navbar-brand-shadow-rgb), .3));
transition: filter .15s ease-in-out;
@@ -398,6 +412,8 @@ main {
width: 1.65rem;
height: 1.65rem;
flex: 0 0 auto;
will-change: filter; // prevent resizing the filter region on hover and nudging the logo a pixel
filter: drop-shadow(0 2px 3px rgba(var(--pngx-navbar-brand-shadow-rgb), 0));
transition: filter .15s ease-in-out;
}
@@ -430,6 +446,8 @@ main {
max-width: 5rem;
flex: 0 0 auto;
object-fit: contain;
will-change: filter; // prevent resizing the filter region on hover and nudging the logo a pixel
filter: drop-shadow(0 2px 3px rgba(var(--pngx-navbar-brand-shadow-rgb), 0));
transition: filter .15s ease-in-out, transform .15s ease-in-out;
}
@@ -25,29 +25,34 @@
<div ngbDropdownMenu aria-labelledby="suggestionsDropdown" class="shadow suggestions-dropdown">
<div class="list-group list-group-flush small pb-0">
@if (totalSuggestions === 0) {
@if (novelSuggestions === 0 && reusableSuggestions === 0) {
<div class="list-group-item text-muted fst-italic">
<small class="text-muted small fst-italic" i18n>No novel suggestions</small>
</div>
}
@if (suggestions()?.suggested_tags.length > 0) {
@if (suggestions()?.suggested_tags?.length > 0) {
<small class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="tags"></i-bs><ng-container i18n>Tags</ng-container></small>
@for (tag of suggestions().suggested_tags; track tag) {
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addTag.emit(tag)">{{ tag }}</button>
}
}
@if (suggestions()?.suggested_document_types.length > 0) {
@if (suggestions()?.suggested_document_types?.length > 0) {
<div class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="hash"></i-bs><ng-container i18n>Document Types</ng-container></div>
@for (type of suggestions().suggested_document_types; track type) {
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addDocumentType.emit(type)">{{ type }}</button>
}
}
@if (suggestions()?.suggested_correspondents.length > 0) {
@if (suggestions()?.suggested_correspondents?.length > 0) {
<div class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="person"></i-bs><ng-container i18n>Correspondents</ng-container></div>
@for (correspondent of suggestions().suggested_correspondents; track correspondent) {
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addCorrespondent.emit(correspondent)">{{ correspondent }}</button>
}
}
@if (reusableSuggestions > 0) {
<div class="list-group-item text-muted fst-italic">
<small class="text-muted small fst-italic" i18n>{reusableSuggestions, plural, =1 {1 existing value suggested below} other {{{reusableSuggestions}} existing values suggested below}}</small>
</div>
}
</div>
</div>
</div>
@@ -21,15 +21,115 @@ describe('SuggestionsDropdownComponent', () => {
fixture.detectChanges()
})
it('should calculate totalSuggestions', () => {
it('should exclude suggested storage path names from totalSuggestions', () => {
fixture.componentRef.setInput('suggestions', {
suggested_correspondents: ['John Doe'],
suggested_tags: ['Tag1', 'Tag2'],
suggested_document_types: ['Type1'],
suggested_storage_paths: ['Finance/Invoices'],
})
expect(component.totalSuggestions).toBe(4)
})
it('should count suggestions when a category is absent from the response', () => {
fixture.componentRef.setInput('suggestions', {
suggested_tags: ['Tag1'],
})
expect(component.totalSuggestions).toBe(1)
})
it('should count reused values the document does not have yet', () => {
fixture.componentRef.setInput('suggestions', {
tags: [1, 2, 3],
correspondents: [10],
document_types: [20],
suggested_tags: ['NewTag'],
})
fixture.componentRef.setInput('appliedTags', [2])
fixture.componentRef.setInput('appliedDocumentType', 20)
// tags 1 and 3 are not applied yet, correspondent 10 is not set, tag 2 and
// document type 20 already are.
expect(component.reusableSuggestions).toBe(3)
expect(component.novelSuggestions).toBe(1)
expect(component.totalSuggestions).toBe(4)
})
it('should not count reused values that are already applied', () => {
fixture.componentRef.setInput('suggestions', {
tags: [1],
correspondents: [10],
document_types: [20],
})
fixture.componentRef.setInput('appliedTags', [1])
fixture.componentRef.setInput('appliedCorrespondent', 10)
fixture.componentRef.setInput('appliedDocumentType', 20)
expect(component.totalSuggestions).toBe(0)
})
it('should point at the fields when suggestions are all reused', () => {
// The dropdown lists only values to create, so a response made entirely of
// reused existing objects used to render as "No novel suggestions".
fixture.componentRef.setInput('aiEnabled', true)
fixture.componentRef.setInput('suggestions', {
tags: [1, 2],
suggested_tags: [],
suggested_correspondents: [],
suggested_document_types: [],
})
fixture.detectChanges()
component.clickSuggest()
fixture.detectChanges()
expect(fixture.nativeElement.textContent).toContain(
'2 existing values suggested below'
)
expect(fixture.nativeElement.textContent).not.toContain(
'No novel suggestions'
)
})
it('should account for reused values alongside values to create', () => {
// The badge counts both, but only the novel names are listed here, so the
// dropdown has to say where the rest of the count came from.
fixture.componentRef.setInput('aiEnabled', true)
fixture.componentRef.setInput('suggestions', {
tags: [6, 3],
suggested_tags: ['Arbitration', 'New York'],
suggested_correspondents: [],
suggested_document_types: [],
})
fixture.detectChanges()
component.clickSuggest()
fixture.detectChanges()
expect(component.totalSuggestions).toBe(4)
expect(fixture.nativeElement.textContent).toContain('Arbitration')
expect(fixture.nativeElement.textContent).toContain(
'2 existing values suggested below'
)
})
it('should count classic (non-AI) suggestions, which are ids only', () => {
// /api/documents/{id}/suggestions/ returns only id arrays and no
// suggested_* keys at all, so every one of its suggestions is a reused
// existing object - including storage paths.
fixture.componentRef.setInput('suggestions', {
correspondents: [4],
tags: [6, 3],
document_types: [2],
storage_paths: [7],
dates: ['2005-01-01'],
})
expect(component.novelSuggestions).toBe(0)
expect(component.totalSuggestions).toBe(5)
fixture.componentRef.setInput('appliedStoragePath', 7)
expect(component.totalSuggestions).toBe(4)
})
it('should show when a completed request returned no suggestions', () => {
fixture.componentRef.setInput('suggestions', {
correspondents: [],
@@ -25,6 +25,11 @@ export class SuggestionsDropdownComponent {
readonly loading = input(false)
readonly disabled = input(false)
readonly appliedTags = input<number[]>([])
readonly appliedCorrespondent = input<number>(null)
readonly appliedDocumentType = input<number>(null)
readonly appliedStoragePath = input<number>(null)
@Output()
getSuggestions: EventEmitter<SuggestionsDropdownComponent> =
new EventEmitter()
@@ -54,14 +59,46 @@ export class SuggestionsDropdownComponent {
}
}
get totalSuggestions(): number {
get novelSuggestions(): number {
return (
this.suggestions()?.suggested_correspondents?.length +
this.suggestions()?.suggested_tags?.length +
this.suggestions()?.suggested_document_types?.length || 0
(this.suggestions()?.suggested_correspondents?.length ?? 0) +
(this.suggestions()?.suggested_tags?.length ?? 0) +
(this.suggestions()?.suggested_document_types?.length ?? 0)
)
}
get reusableSuggestions(): number {
const correspondent = this.appliedCorrespondent()
const documentType = this.appliedDocumentType()
const storagePath = this.appliedStoragePath()
// Storage paths count here but not in novelSuggestions: an existing one
// can be applied from the field, a suggested name cannot create one.
return (
this.countUnapplied(this.suggestions()?.tags, this.appliedTags()) +
this.countUnapplied(
this.suggestions()?.correspondents,
correspondent ? [correspondent] : []
) +
this.countUnapplied(
this.suggestions()?.document_types,
documentType ? [documentType] : []
) +
this.countUnapplied(
this.suggestions()?.storage_paths,
storagePath ? [storagePath] : []
)
)
}
get totalSuggestions(): number {
return this.novelSuggestions + this.reusableSuggestions
}
private countUnapplied(suggested: number[], applied: number[]): number {
return (suggested ?? []).filter((id) => !(applied ?? []).includes(id))
.length
}
get noSuggestions(): boolean {
const suggestions = this.suggestions()
return (
@@ -9,7 +9,7 @@
<input type="file" class="visually-hidden" (change)="onFileSelected($event)" multiple #fileUpload>
</form>
@if (getStatus().length > 0) {
<div class="fixed-bottom p-2 p-md-4 d-flex justify-content-end pe-none consumer-status-list" [ngClass]="slimSidebarEnabled ? 'col-slim' : 'offset-md-3 offset-lg-2'">
<div class="fixed-bottom p-2 p-md-4 d-flex justify-content-end pe-none consumer-status-list" [ngClass]="slimSidebarEnabled ? 'col-slim' : 'offset-sidebar-expanded'">
<div class="col col-lg-4 col-xl-3 ps-0 pe-0 ps-lg-3 pe-lg-0 pe-auto overflow-y-scroll">
<div class="card shadow-sm consumer-status-card">
<div class="card-body">
@@ -134,6 +134,10 @@
[loading]="suggestionsLoading()"
[suggestions]="suggestions()"
[aiEnabled]="aiEnabled"
[appliedTags]="documentForm.value.tags"
[appliedCorrespondent]="documentForm.value.correspondent"
[appliedDocumentType]="documentForm.value.document_type"
[appliedStoragePath]="documentForm.value.storage_path"
(getSuggestions)="getSuggestions()"
(addTag)="createTag($event)"
(addDocumentType)="createDocumentType($event)"
+3 -1
View File
@@ -12,9 +12,11 @@
margin-right: 0 !important;
}
main.col-lg-10 {
main {
max-width: 100%;
flex-basis: 100%;
width: 100% !important;
padding-left: 0 !important;
display: block;
}
+10 -11
View File
@@ -52,20 +52,10 @@ $grid-breakpoints: (
}
}
@media (min-width: 2400px) {
.col-xxxl-1 {
flex: 0 0 auto;
width: 8.33333333%;
}
.col-xxxl-11 {
flex: 0 0 auto;
width:91.66666667%
}
}
// Paperless-ngx styles
body {
--pngx-body-font-size: 0.875rem;
--pngx-sidebar-expanded-width: clamp(15rem, 16.66666667vw, 20rem);
font-size: var(--pngx-body-font-size);
height: 100vh;
letter-spacing: -0.005em;
@@ -107,6 +97,15 @@ body {
.col-slim {
padding-left: calc(56px + $grid-gutter-width) !important;
}
.col-sidebar-expanded {
flex: 0 0 auto;
width: calc(100% - var(--pngx-sidebar-expanded-width));
}
.offset-sidebar-expanded {
margin-left: var(--pngx-sidebar-expanded-width);
}
}
svg.logo {
+6 -4
View File
@@ -45,14 +45,16 @@ CLASSIFIER_HASH_KEY: Final[str] = "classifier_hash"
CLASSIFIER_MODIFIED_KEY: Final[str] = "classifier_modified"
# Marker distinguishing LLM suggestions from classifier-generated ones (whose
# FORMAT_VERSION lives in a much lower range - see DocumentClassifier). Bump
# this whenever the *shape* of the cached `suggestions` dict changes, so a
# cache entry written by a previous release can never be read back by code
# that expects a different shape:
# this whenever cached suggestions must not be reused, including changes to
# their shape or interpretation, so a previous release's result cannot leak
# incompatible or obsolete behavior into the new one:
# 1000 - initial LLM suggestions cache (flat lists of resolved object ids
# per taxonomy field)
# 1001 - suggestions reshaped to {"existing_ids": [...], "new_names":
# [...]} per taxonomy field (#13676)
LLM_CACHE_CLASSIFIER_VERSION: Final[int] = 1001
# 1002 - names are always generated and optional candidate mappings are
# validated separately, so candidate-anchored 1001 results are stale
LLM_CACHE_CLASSIFIER_VERSION: Final[int] = 1002
CACHE_1_MINUTE: Final[int] = 60
CACHE_5_MINUTES: Final[int] = 5 * CACHE_1_MINUTE
+77 -120
View File
@@ -1,18 +1,17 @@
import json
import logging
from django.conf import settings
from django.contrib.auth.models import User
from documents.models import Document
from documents.permissions import permitted_object_ids
from documents.permissions import restrict_queryset_to_visible
from documents.permissions import user_is_unrestricted
from documents.permissions import get_objects_for_user_owner_aware
from paperless.config import AIConfig
from paperless_ai.base_model import ClassificationSuggestions
from paperless_ai.base_model import TaxonomyChoiceDict
from paperless_ai.base_model import classification_suggestions_to_model
from paperless_ai.client import AIClient
from paperless_ai.db import db_connection_released
from paperless_ai.indexing import _node_document_ids
from paperless_ai.indexing import retrieve_similar_nodes
from paperless_ai.indexing import truncate_content
from paperless_ai.prompts.context import ClassificationPromptContext
@@ -20,9 +19,7 @@ from paperless_ai.prompts.context import LocalizationPromptContext
from paperless_ai.prompts.context import RagContextPromptContext
from paperless_ai.prompts.render import render_prompt
from paperless_ai.taxonomy import AssignedMetadata
from paperless_ai.taxonomy import SimilarDocument
from paperless_ai.taxonomy import TaxonomyCandidates
from paperless_ai.taxonomy import _node_document_weights
from paperless_ai.taxonomy import build_taxonomy_candidates
from paperless_ai.taxonomy import empty_taxonomy_candidates
from paperless_ai.taxonomy import format_taxonomy_for_prompt
@@ -42,48 +39,6 @@ logger = logging.getLogger("paperless_ai.rag_classifier")
TAXONOMY_CANDIDATE_TOP_K = 15
def _fulltext_similar_documents(
document: Document,
user: User | None,
top_k: int,
) -> list[SimilarDocument]:
"""Rank-based fallback when no embedding backend is configured. Uses
Tantivy's "More Like This" (term-overlap similarity) instead of vector
similarity - cruder, but far better than no candidates at all.
more_like_this_ids returns only a ranked ID list, no scores, so weight is
synthesized from rank (descending from top_k) rather than claiming a
similarity magnitude that doesn't exist. An unrestricted user (none, or an
active superuser - see user_is_unrestricted) is normalized to ``None``
before calling, since the backend's permission filter has no superuser
short-circuit of its own. Results are re-checked with
restrict_queryset_to_visible() since Tantivy's indexed permission fields
lag the DB via async reindexing.
"""
from documents.search import get_backend
unrestricted = user_is_unrestricted(user)
search_user = None if unrestricted else user
backend = get_backend()
similar_ids = backend.more_like_this_ids(
document.pk,
user=search_user,
limit=top_k,
)
if not unrestricted:
allowed_ids = set(
restrict_queryset_to_visible(
Document.objects.filter(pk__in=similar_ids),
user,
"view_document",
).values_list("pk", flat=True),
)
similar_ids = [doc_id for doc_id in similar_ids if doc_id in allowed_ids]
return [
SimilarDocument(document_id=doc_id, weight=float(top_k - rank))
for rank, doc_id in enumerate(similar_ids)
]
def get_language_name(language_code: str) -> str:
normalized_language_code = language_code.lower()
for code, name in settings.LANGUAGES:
@@ -112,7 +67,6 @@ def build_prompt_without_rag(
document: Document,
config: AIConfig,
candidates: TaxonomyCandidates | None = None,
assigned: AssignedMetadata | None = None,
) -> str:
filename = document.filename or ""
content = truncate_content(
@@ -122,9 +76,7 @@ def build_prompt_without_rag(
)
taxonomy_block = (
format_taxonomy_for_prompt(candidates, assigned)
if candidates is not None and assigned is not None
else ""
format_taxonomy_for_prompt(candidates) if candidates is not None else ""
)
has_candidates = candidates is not None and any(candidates.values())
@@ -142,14 +94,12 @@ def build_prompt_with_rag(
document: Document,
config: AIConfig,
candidates: TaxonomyCandidates | None = None,
assigned: AssignedMetadata | None = None,
context: str = "",
) -> str:
base_prompt = build_prompt_without_rag(
document,
config,
candidates=candidates,
assigned=assigned,
)
truncated_context = truncate_content(
context,
@@ -169,20 +119,16 @@ def build_localization_prompt(
suggestions: ClassificationSuggestions,
output_language: str,
) -> str:
"""``suggestions`` is the full nested-shape result of parse_ai_response
(each taxonomy field a ``{"existing_ids": [...], "new_names": [...]}``
dict) - passed through as-is so the model receives and returns the exact
DocumentClassifierSchema shape run_llm_query() always parses against.
Only each field's new_names (never existing_ids, which are plain
resolved-object IDs, not text) and title get used from the response; see
get_ai_document_classification's merge step, which always keeps the
*original* existing_ids regardless of what the model echoes back here.
"""Render internal suggestions in the same flat shape the model returns.
Only the name fields and title are used from the localized response; the
merge step always keeps the original ID fields.
"""
language_name = get_language_name(output_language)
model_suggestions = classification_suggestions_to_model(suggestions)
return render_prompt(
LocalizationPromptContext(
language_name=language_name,
suggestions_json=json.dumps(suggestions, ensure_ascii=False),
suggestions_json=model_suggestions.model_dump_json(),
),
)
@@ -192,53 +138,44 @@ def get_taxonomy_context(
user: User | None = None,
max_docs: int = 5,
) -> tuple[TaxonomyCandidates, AssignedMetadata, str]:
"""One retrieval feeds both taxonomy candidates and RAG text context. Uses
vector similarity when an embedding backend is configured, otherwise
falls back to Tantivy full-text "More Like This" similarity - see
_fulltext_similar_documents. On any retrieval failure, degrades to empty
candidates/context rather than propagating the exception - neither a
vector-store outage nor a search-index issue should block classification,
only its context-assisted enrichment.
"""One retrieval feeds both taxonomy candidates and RAG text context.
On any retrieval failure, degrades to empty candidates/context rather than
propagating the exception - a vector-store outage should not block
classification, only its RAG-assisted enrichment.
"""
assigned = get_assigned_metadata(document, user)
ai_config = AIConfig()
try:
if ai_config.llm_embedding_backend:
# None means "no restriction" to retrieve_similar_nodes. An
# unrestricted user (no user at all, or an active superuser -- see
# user_is_unrestricted) can see every document, so skip
# materializing every visible pk into a Python list and passing it
# through as an IN filter: for a large library that is a wasted
# quadratic scan in the vector store at best, and past ~32,763
# documents a hard sqlite3.OperationalError (SQLite's
# bound-parameter limit) at worst.
# permitted_object_ids() has its own superuser shortcut that would
# return every Document's id anyway, so this changes nothing about
# which documents are considered -- only how we get there.
visible_document_ids = (
None
if user_is_unrestricted(user)
else list(permitted_object_ids(user, Document, "view_document"))
)
nodes = retrieve_similar_nodes(
document,
top_k=TAXONOMY_CANDIDATE_TOP_K,
document_ids=visible_document_ids,
)
similar_documents = _node_document_weights(nodes)
else:
# See _fulltext_similar_documents: it applies its own permission
# filter via `user`, so no visible-document-id list is needed here.
similar_documents = _fulltext_similar_documents(
document,
user,
top_k=TAXONOMY_CANDIDATE_TOP_K,
# None means "no restriction" to retrieve_similar_nodes. A superuser
# (like no user at all) can see every document, so skip materializing
# every visible pk into a Python list and passing it through as an IN
# filter: for a large library that is a wasted quadratic scan in the
# vector store at best, and past ~32,763 documents a hard
# sqlite3.OperationalError (SQLite's bound-parameter limit) at worst.
# get_objects_for_user_owner_aware() would return every Document for a
# superuser anyway (guardian's own with_superuser shortcut), so this
# changes nothing about which documents are considered -- only how we
# get there.
visible_document_ids = (
None
if user is None or user.is_superuser
else list(
get_objects_for_user_owner_aware(
user,
"view_document",
Document,
).values_list("pk", flat=True),
)
)
nodes = retrieve_similar_nodes(
document,
top_k=TAXONOMY_CANDIDATE_TOP_K,
document_ids=visible_document_ids,
)
candidates = build_taxonomy_candidates(similar_documents, user)
candidates = build_taxonomy_candidates(nodes, user)
# similar_documents is already ordered by descending weight; don't lose it.
similar_document_ids = [s["document_id"] for s in similar_documents]
# ``nodes`` are already ordered by descending vector similarity; don't lose it.
similar_document_ids = list(dict.fromkeys(_node_document_ids(nodes)))
similar_documents_by_id = Document.objects.in_bulk(similar_document_ids)
similar_docs = [
similar_documents_by_id[document_id]
@@ -252,8 +189,8 @@ def get_taxonomy_context(
context_blocks.append(f"TITLE: {title}\n{text}")
except Exception:
logger.exception(
"Failed to retrieve similar-document context for document %s; "
"continuing without taxonomy candidates or similar-document context.",
"Failed to retrieve RAG neighbours for document %s; continuing "
"without taxonomy candidates or similar-document context.",
document.pk,
)
return empty_taxonomy_candidates(), assigned, ""
@@ -262,11 +199,9 @@ def get_taxonomy_context(
def parse_ai_response(raw: dict) -> ClassificationSuggestions:
"""``raw`` is AIClient.run_llm_query()'s return value - already a
DocumentClassifierSchema.model_dump(), so every key below is always
present with the right shape; this only exists to give the rest of the
module a named, typed boundary instead of passing the client's bare dict
straight through everywhere.
"""``raw`` is AIClient.run_llm_query()'s validated internal-shape result.
This gives the rest of the module a named, typed boundary instead of
passing the client's bare dict straight through everywhere.
"""
def _choice(value: dict | None) -> TaxonomyChoiceDict:
@@ -329,6 +264,22 @@ def _restrict_to_shown_candidates(
)
def _candidate_id_allowlist(
candidates: TaxonomyCandidates,
) -> dict[str, set[int]]:
"""Candidate IDs grouped by category for validating model mappings."""
return {
"tags": {candidate["id"] for candidate in candidates["tags"]},
"document_types": {
candidate["id"] for candidate in candidates["document_types"]
},
"correspondents": {
candidate["id"] for candidate in candidates["correspondents"]
},
"storage_paths": {candidate["id"] for candidate in candidates["storage_paths"]},
}
def get_ai_document_classification(
document: Document,
user: User | None = None,
@@ -336,20 +287,26 @@ def get_ai_document_classification(
) -> ClassificationSuggestions:
ai_config = AIConfig()
candidates, assigned, context = get_taxonomy_context(document, user)
prompt = build_prompt_with_rag(
document,
ai_config,
candidates=candidates,
assigned=assigned,
context=context,
)
if ai_config.llm_embedding_backend:
candidates, _assigned, context = get_taxonomy_context(document, user)
prompt = build_prompt_with_rag(
document,
ai_config,
candidates=candidates,
context=context,
)
else:
candidates = empty_taxonomy_candidates()
prompt = build_prompt_without_rag(document, ai_config, candidates=candidates)
client = AIClient()
# Hand the pooled DB connection back while the (slow) LLM query runs so it
# is not pinned for the call's duration; see paperless_ai.db and #12976.
with db_connection_released():
result = client.run_llm_query(prompt)
result = client.run_llm_query(
prompt,
allowed_candidate_ids=_candidate_id_allowlist(candidates),
)
suggestions = _restrict_to_shown_candidates(
parse_ai_response(result),
candidates,
+202 -73
View File
@@ -31,38 +31,9 @@ def _truncate_to_field_limit(value: Any, field: FieldInfo) -> Any:
)
# Docstrings and field descriptions on both models below are serialized into
# the schema handed to the LLM, so write them for the model. Code comments
# should go here only.
class TaxonomyChoice(BaseModel):
"""One field's suggestions: existing values to reuse, plus new ones to create."""
existing_ids: list[int] = Field(
default_factory=list,
max_length=MAX_EXISTING_IDS,
description=(
"IDs from the candidate list shown in the prompt that clearly "
"represent values you would suggest for this field. Never invent "
"an ID, select a weak match merely because it exists, or use an "
"ID when no candidates are shown."
),
)
new_names: list[str] = Field(
default_factory=list,
max_length=MAX_NEW_NAMES,
description=(
"Names for clearly supported values that no shown candidate "
"represents. When a candidate represents the same value, use its "
"ID instead so an existing value is not duplicated under a new name."
),
)
@field_validator("existing_ids", "new_names", mode="before")
@classmethod
def _truncate(cls, value: Any, info: ValidationInfo) -> Any:
return _truncate_to_field_limit(value, cls.model_fields[info.field_name])
# This model is serialized into the schema handed to the LLM, so its docstring
# and field descriptions are instructions for the model. Keep implementation
# details in code comments instead.
class DocumentClassifierSchema(BaseModel):
"""Classification suggestions for a single document."""
@@ -73,36 +44,111 @@ class DocumentClassifierSchema(BaseModel):
f"{MAX_TITLE_LENGTH} characters."
),
)
tags: TaxonomyChoice = Field(
default_factory=TaxonomyChoice,
tags: list[str] = Field(
default_factory=list,
max_length=MAX_NEW_NAMES,
description=(
"Topic labels describing what this document is about. A document "
"may have several, e.g. 'Insurance', 'Car', 'Warranty'."
"All topic labels you would suggest from the document itself, e.g. "
"'Insurance', 'Car', 'Warranty'. Always include every suggested "
"name here, even when it matches an available tag."
),
)
correspondents: TaxonomyChoice = Field(
default_factory=TaxonomyChoice,
matched_tags: list[str] = Field(
default_factory=list,
max_length=MAX_NEW_NAMES,
description=(
"The person, institution or company this document originates "
"from, or was sent to. Not every party merely mentioned in the "
"text, and not the subject of the document."
"Names copied exactly from tags that mean the same thing as an "
"available tag. Align each name by position with tag_ids."
),
)
document_types: TaxonomyChoice = Field(
default_factory=TaxonomyChoice,
tag_ids: list[int] = Field(
default_factory=list,
max_length=MAX_EXISTING_IDS,
description=(
"What kind of document this is, e.g. 'Invoice', 'Contract', "
"'Bank Statement', 'Letter'. Never its subject matter and never "
"who sent it."
"Available tag IDs matching matched_tags, in the same order. "
"Only use IDs shown in the prompt."
),
)
storage_paths: TaxonomyChoice = Field(
default_factory=TaxonomyChoice,
correspondents: list[str] = Field(
default_factory=list,
max_length=MAX_NEW_NAMES,
description=(
"A folder-style filing location for this document, e.g. "
"All people, institutions or companies you would suggest as who "
"this document is from or was sent to, not every party merely "
"mentioned. Always include every suggested name here, even when it "
"matches an available correspondent."
),
)
matched_correspondents: list[str] = Field(
default_factory=list,
max_length=MAX_NEW_NAMES,
description=(
"Names copied exactly from correspondents that identify the same "
"entity as an available correspondent. Align each name by position "
"with correspondent_ids."
),
)
correspondent_ids: list[int] = Field(
default_factory=list,
max_length=MAX_EXISTING_IDS,
description=(
"Available correspondent IDs matching matched_correspondents, in "
"the same order. Only use IDs shown in the prompt."
),
)
document_types: list[str] = Field(
default_factory=list,
max_length=MAX_NEW_NAMES,
description=(
"All names describing what kind of document this is, e.g. 'Invoice', "
"'Contract', 'Bank Statement', 'Letter'. Never use its subject or "
"sender as a document type. Always include every suggested name "
"here, even when it matches an available document type."
),
)
matched_document_types: list[str] = Field(
default_factory=list,
max_length=MAX_NEW_NAMES,
description=(
"Names copied exactly from document_types that mean the same thing "
"as an available document type. Align each name by position with "
"document_type_ids."
),
)
document_type_ids: list[int] = Field(
default_factory=list,
max_length=MAX_EXISTING_IDS,
description=(
"Available document type IDs matching matched_document_types, in "
"the same order. Only use IDs shown in the prompt."
),
)
storage_paths: list[str] = Field(
default_factory=list,
max_length=MAX_NEW_NAMES,
description=(
"All folder-style filing locations you would suggest, e.g. "
"'Finance/Invoices'. Leave empty unless a filing location is "
"clearly implied - never put tags, document types or "
"correspondents here."
"correspondents here. Always include every suggested name here, "
"even when it matches an available storage path."
),
)
matched_storage_paths: list[str] = Field(
default_factory=list,
max_length=MAX_NEW_NAMES,
description=(
"Names copied exactly from storage_paths that mean the same filing "
"location as an available storage path. Align each name by position "
"with storage_path_ids."
),
)
storage_path_ids: list[int] = Field(
default_factory=list,
max_length=MAX_EXISTING_IDS,
description=(
"Available storage path IDs matching matched_storage_paths, in the "
"same order. Only use IDs shown in the prompt."
),
)
dates: list[str] = Field(
@@ -115,41 +161,37 @@ class DocumentClassifierSchema(BaseModel):
),
)
@field_validator("title", "dates", mode="before")
@field_validator(
"title",
"tags",
"matched_tags",
"tag_ids",
"correspondents",
"matched_correspondents",
"correspondent_ids",
"document_types",
"matched_document_types",
"document_type_ids",
"storage_paths",
"matched_storage_paths",
"storage_path_ids",
"dates",
mode="before",
)
@classmethod
def _truncate(cls, value: Any, info: ValidationInfo) -> Any:
return _truncate_to_field_limit(value, cls.model_fields[info.field_name])
@classmethod
def model_json_schema(cls, *args: Any, **kwargs: Any) -> dict[str, Any]:
"""Inline TaxonomyChoice for backends that reject JSON Schema refs."""
schema = super().model_json_schema(*args, **kwargs)
taxonomy_choice = schema.pop("$defs")["TaxonomyChoice"]
for field in ("tags", "correspondents", "document_types", "storage_paths"):
# Pydantic emits a field's description as a sibling of its $ref;
# those keys must survive and win over the shared definition.
siblings = {
key: value
for key, value in schema["properties"][field].items()
if key != "$ref"
}
schema["properties"][field] = taxonomy_choice | siblings
return schema
class TaxonomyChoiceDict(TypedDict):
"""Plain-dict counterpart of TaxonomyChoice - what
TaxonomyChoice.model_dump() actually produces, typed for callers that
work with the dumped dict rather than the pydantic instance."""
"""Internal representation of names and existing IDs for one taxonomy."""
existing_ids: list[int]
new_names: list[str]
class ClassificationSuggestions(TypedDict):
"""Plain-dict counterpart of DocumentClassifierSchema.model_dump() -
the shape threaded through parse_ai_response, build_localization_prompt,
get_ai_document_classification, and the ai_suggestions view."""
"""Internal shape used after the flat LLM response is validated."""
title: str
tags: TaxonomyChoiceDict
@@ -157,3 +199,90 @@ class ClassificationSuggestions(TypedDict):
document_types: TaxonomyChoiceDict
storage_paths: TaxonomyChoiceDict
dates: list[str]
def model_to_classification_suggestions(
model: DocumentClassifierSchema,
allowed_candidate_ids: dict[str, set[int]] | None = None,
) -> ClassificationSuggestions:
"""Validate optional candidate mappings and convert to the internal shape.
A mapping is accepted only when its name is copied from the model's own
complete suggestion list and its ID was actually shown for that category.
Invalid or unpaired mappings leave the original name untouched.
"""
allowed_candidate_ids = allowed_candidate_ids or {}
def _choice(
names: list[str],
matched_names: list[str],
ids: list[int],
category: str,
) -> TaxonomyChoiceDict:
remaining_names = list(names)
existing_ids: list[int] = []
allowed_ids = allowed_candidate_ids.get(category, set())
for name, object_id in zip(matched_names, ids, strict=False):
if (
name not in remaining_names
or object_id not in allowed_ids
or object_id in existing_ids
):
continue
remaining_names.remove(name)
existing_ids.append(object_id)
return TaxonomyChoiceDict(
existing_ids=existing_ids,
new_names=remaining_names,
)
return ClassificationSuggestions(
title=model.title,
tags=_choice(
model.tags,
model.matched_tags,
model.tag_ids,
"tags",
),
correspondents=_choice(
model.correspondents,
model.matched_correspondents,
model.correspondent_ids,
"correspondents",
),
document_types=_choice(
model.document_types,
model.matched_document_types,
model.document_type_ids,
"document_types",
),
storage_paths=_choice(
model.storage_paths,
model.matched_storage_paths,
model.storage_path_ids,
"storage_paths",
),
dates=model.dates,
)
def classification_suggestions_to_model(
suggestions: ClassificationSuggestions,
) -> DocumentClassifierSchema:
"""Convert internal suggestions to the flat shape used for localization."""
return DocumentClassifierSchema(
title=suggestions["title"],
tags=suggestions["tags"]["new_names"],
matched_tags=[],
tag_ids=[],
correspondents=suggestions["correspondents"]["new_names"],
matched_correspondents=[],
correspondent_ids=[],
document_types=suggestions["document_types"]["new_names"],
matched_document_types=[],
document_type_ids=[],
storage_paths=suggestions["storage_paths"]["new_names"],
matched_storage_paths=[],
storage_path_ids=[],
dates=suggestions["dates"],
)
+16 -3
View File
@@ -19,7 +19,9 @@ from paperless.network import PinnedHostHTTPTransport
from paperless.network import create_pinned_async_httpx_client
from paperless.network import create_pinned_httpx_client
from paperless.network import validate_outbound_http_url
from paperless_ai.base_model import ClassificationSuggestions
from paperless_ai.base_model import DocumentClassifierSchema
from paperless_ai.base_model import model_to_classification_suggestions
from paperless_ai.exceptions import LLMTimeoutError
logger = logging.getLogger("paperless_ai.client")
@@ -115,7 +117,12 @@ class AIClient:
else:
raise ValueError(f"Unsupported LLM backend: {self.settings.llm_backend}")
def run_llm_query(self, prompt: str) -> str:
def run_llm_query(
self,
prompt: str,
*,
allowed_candidate_ids: dict[str, set[int]] | None = None,
) -> ClassificationSuggestions:
logger.debug(
"Running LLM query against %s with model %s",
self.settings.llm_backend,
@@ -134,7 +141,10 @@ class AIClient:
)
logger.debug("LLM query result: %s", result)
parsed = DocumentClassifierSchema(**json.loads(result.message.content))
return parsed.model_dump()
return model_to_classification_suggestions(
parsed,
allowed_candidate_ids,
)
from llama_index.core.program.function_program import get_function_tool
@@ -153,7 +163,10 @@ class AIClient:
)
logger.debug("LLM query result: %s", tool_calls)
parsed = DocumentClassifierSchema(**tool_calls[0].tool_kwargs)
return parsed.model_dump()
return model_to_classification_suggestions(
parsed,
allowed_candidate_ids,
)
@contextmanager
def _normalize_timeouts(self) -> Iterator[None]:
+1 -4
View File
@@ -13,10 +13,7 @@ Analyze the following document and fill in these fields:
- dates: up to 3 relevant dates in YYYY-MM-DD format
{% if has_candidates %}
For tags, correspondents, document types, and storage paths: first decide whether there is a useful, well-supported suggestion. If an available candidate clearly represents that suggestion, put its id in existing_ids instead of duplicating it in new_names. If no candidate represents the suggestion, put its name in new_names. Do not choose a weak candidate merely because it exists.
{% else %}
No candidates are shown for this document, so leave every existing_ids list empty and put each suggestion's name in new_names.
First produce the complete name suggestions from the document itself in tags, correspondents, document_types, and storage_paths. Always include every suggested name in those fields, even when an available candidate represents the same value. Then, as a separate reconciliation step, copy each name that means the same thing as an available candidate into the matching matched_* field and put that candidate's ID at the same position in the corresponding *_ids field. Candidates must not create, replace, or suppress suggestions. Do not match a candidate that is merely related.
{% endif %}
Filename:
+1 -1
View File
@@ -1,6 +1,6 @@
You are localizing document classification suggestions for display in Paperless-ngx.
Rewrite only the "title" field and each taxonomy field's "new_names" list in {{ language_name }}. Leave every "existing_ids" list exactly as given - these are database identifiers, not text, and are not used from your response even if changed.
Rewrite only the "title", "tags", "document_types", and "storage_paths" fields in {{ language_name }}.
Do not translate correspondents or dates.
Preserve proper nouns, organization names, product names, and exact official document names. Translate generic category words when a {{ language_name }} equivalent exists.
+21 -34
View File
@@ -33,11 +33,6 @@ class TaxonomyCandidate(TypedDict):
weight: float
class SimilarDocument(TypedDict):
document_id: int
weight: float
class TaxonomyCandidates(TypedDict):
tags: list[TaxonomyCandidate]
document_types: list[TaxonomyCandidate]
@@ -110,10 +105,10 @@ def get_assigned_metadata(document: Document, user: User | None) -> AssignedMeta
)
def _node_document_weights(nodes: list["NodeWithScore"]) -> list[SimilarDocument]:
"""Sum each node's similarity score into its document_id (a document can
appear via multiple chunks/nodes) and return one SimilarDocument per
distinct document_id."""
def _node_document_weights(nodes: list["NodeWithScore"]) -> dict[int, float]:
"""document_id -> that node's similarity score, summed if a document_id
appears more than once across the retrieved nodes (e.g. multiple chunks
of the same source document)."""
weights: dict[int, float] = defaultdict(float)
for node in nodes:
document_id = node.metadata.get("document_id")
@@ -126,14 +121,7 @@ def _node_document_weights(nodes: list["NodeWithScore"]) -> list[SimilarDocument
weights[int(document_id)] += float(node.score or 0.0)
except (TypeError, ValueError): # pragma: no cover
continue
return sorted(
(
SimilarDocument(document_id=document_id, weight=weight)
for document_id, weight in weights.items()
),
key=lambda similar: similar["weight"],
reverse=True,
)
return weights
def _visible_ranked_candidates(
@@ -169,25 +157,20 @@ def _visible_ranked_candidates(
def build_taxonomy_candidates(
similar_documents: list[SimilarDocument],
nodes: list["NodeWithScore"],
user: User | None,
) -> TaxonomyCandidates:
"""Resolve each similar document's id to a live Document, read its
*current* tags/type/correspondent/storage_path via the ORM (never any
possibly-stale names an adapter's source might have cached), weight each
distinct taxonomy object by aggregate similarity weight, permission-filter
"""Resolve each neighbour node's document_id to a live Document, read its
*current* tags/type/correspondent/storage_path via the ORM (never the
possibly-stale names cached in vector-index node metadata), weight each
distinct taxonomy object by aggregate neighbour similarity, permission-filter
against what ``user`` can see, and return each category ranked by weight
and capped. ``similar_documents`` may come from either the vector-RAG
adapter or the full-text fallback adapter - both produce this same shape.
and capped.
"""
if not similar_documents:
return empty_taxonomy_candidates()
# Both adapters guarantee at most one SimilarDocument per document_id, so
# this never silently drops a duplicate's weight.
document_weights: dict[int, float] = {
s["document_id"]: s["weight"] for s in similar_documents
}
document_weights = _node_document_weights(nodes)
if not document_weights:
return empty_taxonomy_candidates()
# Only .tags.all() needs prefetching (a reverse M2M, one extra query for
# the whole batch). document_type/correspondent/storage_path are read
@@ -262,7 +245,7 @@ def _assigned_block(assigned: AssignedMetadata) -> str:
def format_taxonomy_for_prompt(
candidates: TaxonomyCandidates,
assigned: AssignedMetadata,
assigned: AssignedMetadata | None = None,
) -> str:
"""Render assigned metadata and ranked candidates as labelled prompt
blocks. Candidate names are untrusted, user-controlled data, so they are
@@ -272,7 +255,7 @@ def format_taxonomy_for_prompt(
is nothing to say (no assigned metadata and no candidates), so callers can
treat the result the same as no hints at all.
"""
has_assigned = any(
has_assigned = assigned is not None and any(
[
assigned["tags"],
assigned["document_type"],
@@ -288,7 +271,11 @@ def format_taxonomy_for_prompt(
return render_prompt(
TaxonomyBlockPromptContext(
assigned_block=_assigned_block(assigned) if has_assigned else "",
assigned_block=(
_assigned_block(assigned)
if assigned is not None and has_assigned
else ""
),
candidate_payload_json=(
json.dumps(candidate_payload, ensure_ascii=False)
if candidate_payload
+66 -320
View File
@@ -1,5 +1,4 @@
import datetime
from collections.abc import Generator
from types import SimpleNamespace
from unittest.mock import MagicMock
from unittest.mock import patch
@@ -9,13 +8,10 @@ import pytest_mock
from django.test import override_settings
from documents.models import Document
from documents.search import TantivyBackend
from documents.tests.factories import DocumentFactory
from documents.tests.factories import TagFactory
from documents.tests.factories import UserFactory
from paperless.config import AIConfig
from paperless_ai.ai_classifier import TAXONOMY_CANDIDATE_TOP_K
from paperless_ai.ai_classifier import _fulltext_similar_documents
from paperless_ai.ai_classifier import _restrict_to_shown_candidates
from paperless_ai.ai_classifier import build_localization_prompt
from paperless_ai.ai_classifier import build_prompt_with_rag
@@ -25,7 +21,6 @@ from paperless_ai.ai_classifier import get_language_name
from paperless_ai.ai_classifier import get_taxonomy_context
from paperless_ai.base_model import ClassificationSuggestions
from paperless_ai.base_model import TaxonomyChoiceDict
from paperless_ai.taxonomy import SimilarDocument
from paperless_ai.taxonomy import TaxonomyCandidate
from paperless_ai.taxonomy import TaxonomyCandidates
from paperless_ai.taxonomy import empty_taxonomy_candidates
@@ -180,6 +175,7 @@ def test_get_ai_document_classification_failure(mock_run_llm_query, mock_documen
@pytest.mark.django_db
@patch("paperless_ai.client.AIClient.run_llm_query")
@patch("paperless_ai.ai_classifier.build_prompt_with_rag")
@patch("paperless_ai.ai_classifier.build_taxonomy_candidates")
@patch("paperless_ai.ai_classifier.retrieve_similar_nodes")
@override_settings(
LLM_EMBEDDING_BACKEND="huggingface",
@@ -189,6 +185,7 @@ def test_get_ai_document_classification_failure(mock_run_llm_query, mock_documen
)
def test_use_rag_if_configured(
mock_retrieve,
mock_build_candidates,
mock_build_prompt_with_rag,
mock_run_llm_query,
mock_document,
@@ -200,20 +197,39 @@ def test_use_rag_if_configured(
- get_ai_document_classification() is called
THEN:
- The RAG-augmented prompt builder is used
- Classification and candidate reconciliation happen in one LLM call
- Only candidate IDs from the permission-filtered candidate set are allowed
"""
mock_retrieve.return_value = []
mock_build_candidates.return_value = TaxonomyCandidates(
tags=[TaxonomyCandidate(id=12, name="Contractor", weight=1.0)],
document_types=[],
correspondents=[],
storage_paths=[],
)
mock_build_prompt_with_rag.return_value = "Prompt with RAG"
mock_run_llm_query.return_value = NESTED_SUGGESTIONS
get_ai_document_classification(mock_document)
mock_build_prompt_with_rag.assert_called_once()
mock_run_llm_query.assert_called_once_with(
"Prompt with RAG",
allowed_candidate_ids={
"tags": {12},
"document_types": set(),
"correspondents": set(),
"storage_paths": set(),
},
)
@pytest.mark.django_db
@patch("paperless_ai.client.AIClient.run_llm_query")
@patch("paperless_ai.ai_classifier.build_prompt_with_rag")
@patch("paperless_ai.ai_classifier.build_prompt_without_rag")
@patch("paperless_ai.ai_classifier.AIConfig")
@override_settings(LLM_BACKEND="ollama", LLM_MODEL="some_model")
def test_use_rag_prompt_even_without_embedding_backend(
mock_build_prompt_with_rag,
def test_use_without_rag_if_not_configured(
mock_ai_config,
mock_build_prompt_without_rag,
mock_run_llm_query,
mock_document,
):
@@ -223,13 +239,13 @@ def test_use_rag_prompt_even_without_embedding_backend(
WHEN:
- get_ai_document_classification() is called
THEN:
- The RAG-context prompt builder is still used (fed by the full-text
fallback's context/candidates instead of the vector store's)
- The non-RAG prompt builder is used
"""
mock_build_prompt_with_rag.return_value = "Prompt with RAG"
mock_ai_config.return_value.llm_embedding_backend = None
mock_build_prompt_without_rag.return_value = "Prompt without RAG"
mock_run_llm_query.return_value = NESTED_SUGGESTIONS
get_ai_document_classification(mock_document)
mock_build_prompt_with_rag.assert_called_once()
mock_build_prompt_without_rag.assert_called_once()
@pytest.mark.django_db
@@ -248,7 +264,7 @@ def test_prompt_with_without_rag(mock_document):
THEN:
- build_prompt_without_rag() has no similar-documents section
- build_prompt_with_rag() includes the similar-documents context
- build_localization_prompt() asks to rewrite only new_names/title and
- build_localization_prompt() asks to rewrite only names/title and
not to translate correspondents or dates
"""
config = AIConfig()
@@ -267,6 +283,7 @@ def test_prompt_with_without_rag(mock_document):
prompt = build_localization_prompt(NESTED_SUGGESTIONS, output_language="de-de")
assert "Rewrite only the" in prompt
assert "Do not translate correspondents or dates" in prompt
assert '"tag_ids":[]' in prompt
def test_get_language_name_falls_back_to_language_code():
@@ -307,7 +324,6 @@ def test_build_localization_prompt_preserves_unicode_characters():
@pytest.mark.django_db
@override_settings(LLM_EMBEDDING_BACKEND="huggingface")
def test_get_taxonomy_context_assembles_rag_text_and_candidates():
"""
GIVEN:
@@ -349,7 +365,6 @@ def test_get_taxonomy_context_assembles_rag_text_and_candidates():
@pytest.mark.django_db
@override_settings(LLM_EMBEDDING_BACKEND="huggingface")
def test_get_taxonomy_context_preserves_similarity_order_and_distinct_documents():
"""
GIVEN:
@@ -420,7 +435,6 @@ def test_get_taxonomy_context_preserves_similarity_order_and_distinct_documents(
@pytest.mark.django_db
@override_settings(LLM_EMBEDDING_BACKEND="huggingface")
def test_get_taxonomy_context_no_similar_docs():
"""
GIVEN:
@@ -444,67 +458,6 @@ def test_get_taxonomy_context_no_similar_docs():
}
@pytest.mark.django_db
def test_get_taxonomy_context_uses_fulltext_fallback_when_no_embedding_backend(
mocker: pytest_mock.MockerFixture,
) -> None:
"""
GIVEN:
- No LLM embedding backend is configured (the default test settings)
WHEN:
- get_taxonomy_context() is called
THEN:
- _fulltext_similar_documents() is called with the document, the user
and TAXONOMY_CANDIDATE_TOP_K
- retrieve_similar_nodes() (the vector path) is never called
"""
document = DocumentFactory.create(content="Some content")
mock_fulltext = mocker.patch(
"paperless_ai.ai_classifier._fulltext_similar_documents",
return_value=[],
)
mock_retrieve = mocker.patch("paperless_ai.ai_classifier.retrieve_similar_nodes")
get_taxonomy_context(document, user=None)
mock_fulltext.assert_called_once_with(
document,
None,
top_k=TAXONOMY_CANDIDATE_TOP_K,
)
mock_retrieve.assert_not_called()
@pytest.mark.django_db
@override_settings(LLM_EMBEDDING_BACKEND="huggingface")
def test_get_taxonomy_context_uses_vector_path_when_embedding_backend_configured(
mocker: pytest_mock.MockerFixture,
) -> None:
"""
GIVEN:
- An LLM embedding backend is configured
WHEN:
- get_taxonomy_context() is called
THEN:
- retrieve_similar_nodes() (the vector path) is called
- _fulltext_similar_documents() (the no-embedding-backend fallback)
is never called
"""
document = DocumentFactory.create(content="Some content")
mock_retrieve = mocker.patch(
"paperless_ai.ai_classifier.retrieve_similar_nodes",
return_value=[],
)
mock_fulltext = mocker.patch(
"paperless_ai.ai_classifier._fulltext_similar_documents",
)
get_taxonomy_context(document, user=None)
mock_retrieve.assert_called_once()
mock_fulltext.assert_not_called()
class TestGetTaxonomyContextVisibility:
"""get_taxonomy_context must not materialize every visible document id
for a user who can already see the whole library: a superuser (like no
@@ -517,7 +470,6 @@ class TestGetTaxonomyContextVisibility:
"""
@pytest.mark.django_db
@override_settings(LLM_EMBEDDING_BACKEND="huggingface")
def test_skips_permission_lookup_for_superuser(
self,
mocker: pytest_mock.MockerFixture,
@@ -536,18 +488,17 @@ class TestGetTaxonomyContextVisibility:
"paperless_ai.ai_classifier.retrieve_similar_nodes",
return_value=[],
)
mock_permitted = mocker.patch(
"paperless_ai.ai_classifier.permitted_object_ids",
mock_get_objects = mocker.patch(
"paperless_ai.ai_classifier.get_objects_for_user_owner_aware",
)
user = UserFactory.create(is_superuser=True)
get_taxonomy_context(document, user)
mock_permitted.assert_not_called()
mock_get_objects.assert_not_called()
assert mock_retrieve.call_args.kwargs["document_ids"] is None
@pytest.mark.django_db
@override_settings(LLM_EMBEDDING_BACKEND="huggingface")
def test_skips_permission_lookup_when_no_user(
self,
mocker: pytest_mock.MockerFixture,
@@ -566,17 +517,16 @@ class TestGetTaxonomyContextVisibility:
"paperless_ai.ai_classifier.retrieve_similar_nodes",
return_value=[],
)
mock_permitted = mocker.patch(
"paperless_ai.ai_classifier.permitted_object_ids",
mock_get_objects = mocker.patch(
"paperless_ai.ai_classifier.get_objects_for_user_owner_aware",
)
get_taxonomy_context(document, None)
mock_permitted.assert_not_called()
mock_get_objects.assert_not_called()
assert mock_retrieve.call_args.kwargs["document_ids"] is None
@pytest.mark.django_db
@override_settings(LLM_EMBEDDING_BACKEND="huggingface")
def test_restricts_to_visible_documents_for_non_superuser(
self,
mocker: pytest_mock.MockerFixture,
@@ -587,7 +537,7 @@ class TestGetTaxonomyContextVisibility:
WHEN:
- get_taxonomy_context() is called
THEN:
- The user's permitted document ids are looked up and passed to
- The user's visible document ids are looked up and passed to
retrieve_similar_nodes() as a restriction
"""
document = DocumentFactory.create(content="Some content")
@@ -595,186 +545,21 @@ class TestGetTaxonomyContextVisibility:
"paperless_ai.ai_classifier.retrieve_similar_nodes",
return_value=[],
)
mock_permitted = mocker.patch(
"paperless_ai.ai_classifier.permitted_object_ids",
return_value=[1, 2, 3],
mock_queryset = mocker.MagicMock()
mock_queryset.values_list.return_value = [1, 2, 3]
mock_get_objects = mocker.patch(
"paperless_ai.ai_classifier.get_objects_for_user_owner_aware",
return_value=mock_queryset,
)
user = UserFactory.create(is_superuser=False)
get_taxonomy_context(document, user)
mock_permitted.assert_called_once_with(user, Document, "view_document")
mock_get_objects.assert_called_once_with(user, "view_document", Document)
assert mock_retrieve.call_args.kwargs["document_ids"] == [1, 2, 3]
@pytest.mark.django_db
class TestFulltextSimilarDocuments:
"""_fulltext_similar_documents is the no-embedding-backend fallback: it
asks the Tantivy full-text index for "More Like This" neighbours instead
of the vector store, and synthesizes a rank-based weight since Tantivy's
more_like_this_ids returns only an ordered id list, no scores.
"""
@pytest.fixture
def fulltext_backend(
self,
mocker: pytest_mock.MockerFixture,
) -> Generator[TantivyBackend, None, None]:
"""An in-memory Tantivy backend, wired up as the module-level
singleton _fulltext_similar_documents resolves via get_backend()."""
backend = TantivyBackend(path=None)
backend.open()
mocker.patch("documents.search.get_backend", return_value=backend)
try:
yield backend
finally:
backend.close()
def test_ranks_by_rank_based_weight_descending(
self,
fulltext_backend: TantivyBackend,
) -> None:
"""
GIVEN:
- A source document and two similar documents indexed in Tantivy
WHEN:
- _fulltext_similar_documents() is called
THEN:
- Each result's weight reflects its rank (first result weighted
higher than the second), not a raw similarity score
"""
source = DocumentFactory.create(content="quarterly financial report details")
first = DocumentFactory.create(content="quarterly financial report details")
second = DocumentFactory.create(content="financial report")
for doc in (source, first, second):
fulltext_backend.add_or_update(doc)
result = _fulltext_similar_documents(source, user=None, top_k=5)
assert len(result) == 2
weight_by_id = {s["document_id"]: s["weight"] for s in result}
assert weight_by_id[first.pk] > weight_by_id[second.pk]
def test_excludes_source_document(
self,
fulltext_backend: TantivyBackend,
) -> None:
"""
GIVEN:
- A source document indexed in Tantivy with no other documents
WHEN:
- _fulltext_similar_documents() is called
THEN:
- An empty list is returned - the source document is never its
own similar document
"""
source = DocumentFactory.create(content="unique unrelated content")
fulltext_backend.add_or_update(source)
result = _fulltext_similar_documents(source, user=None, top_k=5)
assert result == []
def test_empty_index_returns_empty_list(
self,
fulltext_backend: TantivyBackend,
) -> None:
"""
GIVEN:
- A document that has never been indexed (fresh/empty Tantivy index)
WHEN:
- _fulltext_similar_documents() is called
THEN:
- An empty list is returned rather than raising
"""
source = DocumentFactory.create(content="never indexed")
result = _fulltext_similar_documents(source, user=None, top_k=5)
assert result == []
def test_respects_top_k_limit(
self,
fulltext_backend: TantivyBackend,
) -> None:
"""
GIVEN:
- A source document and four similar documents indexed
WHEN:
- _fulltext_similar_documents() is called with top_k=2
THEN:
- At most 2 results are returned
"""
source = DocumentFactory.create(content="shared overlapping keyword text")
fulltext_backend.add_or_update(source)
for _ in range(4):
fulltext_backend.add_or_update(
DocumentFactory.create(content="shared overlapping keyword text"),
)
result = _fulltext_similar_documents(source, user=None, top_k=2)
assert len(result) == 2
def test_result_shape_is_similar_document(
self,
fulltext_backend: TantivyBackend,
) -> None:
"""
GIVEN:
- A source document and one similar document indexed
WHEN:
- _fulltext_similar_documents() is called
THEN:
- Each result is a SimilarDocument (document_id + weight only)
"""
source = DocumentFactory.create(content="shared content phrase")
other = DocumentFactory.create(content="shared content phrase")
fulltext_backend.add_or_update(source)
fulltext_backend.add_or_update(other)
result = _fulltext_similar_documents(source, user=None, top_k=5)
# rank 0 (the only/best result) with top_k=5 -> weight = top_k - rank = 5.0,
# per the "first result gets top_k, the last gets 1" formula.
assert result == [SimilarDocument(document_id=other.pk, weight=5.0)]
def test_superuser_sees_other_users_documents(
self,
fulltext_backend: TantivyBackend,
) -> None:
"""
GIVEN:
- A source document owned by one user and a similar document
owned by a different user, with no sharing between them
WHEN:
- _fulltext_similar_documents() is called with a superuser
THEN:
- The other user's document is still returned as a similar
document - a superuser must not be narrowed by the backend's
owner-based permission filter
"""
owner = UserFactory.create()
other_owner = UserFactory.create()
superuser = UserFactory.create(is_superuser=True)
source = DocumentFactory.create(
content="shared content phrase",
owner=owner,
)
other = DocumentFactory.create(
content="shared content phrase",
owner=other_owner,
)
fulltext_backend.add_or_update(source)
fulltext_backend.add_or_update(other)
result = _fulltext_similar_documents(source, user=superuser, top_k=5)
assert [s["document_id"] for s in result] == [other.pk]
@pytest.mark.django_db
@override_settings(LLM_EMBEDDING_BACKEND="huggingface")
@patch("paperless_ai.ai_classifier.retrieve_similar_nodes")
def test_get_taxonomy_context_retrieval_failure_degrades_to_no_hints(mock_retrieve):
"""
@@ -801,7 +586,6 @@ def test_get_taxonomy_context_retrieval_failure_degrades_to_no_hints(mock_retrie
@pytest.mark.django_db
@override_settings(LLM_EMBEDDING_BACKEND="huggingface")
@patch("paperless_ai.ai_classifier.build_taxonomy_candidates")
@patch("paperless_ai.ai_classifier.retrieve_similar_nodes")
def test_get_taxonomy_context_candidate_building_failure_degrades_to_no_hints(
@@ -841,10 +625,11 @@ def test_build_prompt_without_rag_includes_taxonomy_block():
GIVEN:
- Non-empty taxonomy candidates
WHEN:
- build_prompt_without_rag() is called with candidates and assigned metadata
- build_prompt_without_rag() is called with candidates
THEN:
- The candidate's id and the existing_ids/new_names instructions appear
- Candidates are presented as deduplication options, not requirements
- The candidate and single-call reconciliation instructions appear
- Complete name suggestions remain mandatory
- Assigned metadata is not included
"""
document = DocumentFactory.create(content="Some content")
config = AIConfig()
@@ -854,40 +639,31 @@ def test_build_prompt_without_rag_includes_taxonomy_block():
"correspondents": [],
"storage_paths": [],
}
assigned = {
"tags": [],
"document_type": None,
"correspondent": None,
"storage_path": None,
}
prompt = build_prompt_without_rag(
document,
config,
candidates=candidates,
assigned=assigned,
)
assert '"id": 12' in prompt
assert "existing_ids" in prompt
assert "new_names" in prompt
assert "not requirements" in prompt
assert "weak candidate" in prompt
assert "Always include every suggested name" in prompt
assert "matched_*" in prompt
assert "corresponding *_ids" in prompt
assert "Candidates must not create, replace, or suppress suggestions" in prompt
assert "already assigned" not in prompt
@pytest.mark.django_db
def test_build_prompt_without_rag_identical_when_no_hints():
def test_build_prompt_without_rag_identical_when_no_candidates():
"""
GIVEN:
- Empty taxonomy candidates and empty assigned metadata
- Empty taxonomy candidates
WHEN:
- build_prompt_without_rag() is called with those empty values, and
separately with no candidates/assigned at all
separately with no candidates at all
THEN:
- Both prompts are identical
- Neither carries the "Available ..." candidate block or the
id-vs-name routing instruction
- Both still tell the model to leave existing_ids empty
- Neither carries candidate reconciliation instructions
"""
document = DocumentFactory.create(content="Some content")
config = AIConfig()
@@ -897,67 +673,37 @@ def test_build_prompt_without_rag_identical_when_no_hints():
"correspondents": [],
"storage_paths": [],
}
empty_assigned = {
"tags": [],
"document_type": None,
"correspondent": None,
"storage_path": None,
}
with_empty_hints = build_prompt_without_rag(
document,
config,
candidates=empty_candidates,
assigned=empty_assigned,
)
with_no_hints = build_prompt_without_rag(document, config)
assert with_empty_hints == with_no_hints
assert "Available " not in with_no_hints
assert "put its id in existing_ids" not in with_no_hints
assert "leave every existing_ids list empty" in with_no_hints
assert "matched_*" not in with_no_hints
@pytest.mark.django_db
def test_build_prompt_without_rag_tells_model_to_skip_ids_when_no_candidates():
def test_build_prompt_without_rag_never_includes_assigned_metadata():
"""
GIVEN:
- Assigned metadata but empty taxonomy candidates
- A document with assigned taxonomy metadata
WHEN:
- build_prompt_without_rag() is called with candidates and assigned metadata
- build_prompt_without_rag() is called
THEN:
- The assigned-metadata block appears (taxonomy_block is non-empty)
- The prompt tells the model to leave existing_ids empty
Staying silent about existing_ids here is not enough: the response schema
advertises the field whatever the prompt says, and models fill it with
placeholder ids that resolve to real but unrelated objects (#13831).
- Assigned metadata is absent so it cannot anchor classification
"""
document = DocumentFactory.create(content="Some content")
config = AIConfig()
empty_candidates = {
"tags": [],
"document_types": [],
"correspondents": [],
"storage_paths": [],
}
assigned = {
"tags": ["Bloodwork"],
"document_type": None,
"correspondent": None,
"storage_path": None,
}
assigned_tag = TagFactory.create(name="Bloodwork")
document.tags.add(assigned_tag)
prompt = build_prompt_without_rag(
document,
config,
candidates=empty_candidates,
assigned=assigned,
)
prompt = build_prompt_without_rag(document, config)
assert "already assigned" in prompt
assert "No candidates are shown" in prompt
assert "leave every existing_ids list empty" in prompt
assert "Bloodwork" not in prompt
assert "already assigned" not in prompt
@pytest.mark.django_db
+176 -98
View File
@@ -1,13 +1,13 @@
import json
from paperless_ai.base_model import MAX_DATES
from paperless_ai.base_model import MAX_EXISTING_IDS
from paperless_ai.base_model import MAX_NEW_NAMES
from paperless_ai.base_model import MAX_TITLE_LENGTH
from paperless_ai.base_model import ClassificationSuggestions
from paperless_ai.base_model import DocumentClassifierSchema
from paperless_ai.base_model import TaxonomyChoice
from paperless_ai.base_model import TaxonomyChoiceDict
from paperless_ai.base_model import classification_suggestions_to_model
from paperless_ai.base_model import model_to_classification_suggestions
def test_document_classifier_schema_declared_defaults():
@@ -18,29 +18,116 @@ def test_document_classifier_schema_declared_defaults():
WHEN:
- The schema is dumped to a dict via model_dump()
THEN:
- Every taxonomy field dumps as an empty existing_ids/new_names
dict, and dates dumps as an empty list
- Every optional name field, and dates, dump as empty lists
This is the one project-owned fact worth pinning down here: which
defaults this schema declares for a partial LLM response (see
client.py's DocumentClassifierSchema(**json.loads(...)) call sites,
which construct from whatever subset of fields the backend actually
returned). It deliberately hardcodes the expected literal rather than
re-deriving it from TaxonomyChoice()/[] - pydantic's own
default_factory machinery is not this project's to re-test, and a
test that recomputes the expected value from the model under test
can't ever catch a wrong default.
The model may omit optional fields, so the schema must provide the complete
empty shape expected by the conversion and matching pipeline.
"""
schema = DocumentClassifierSchema(title="Test Title")
dumped = schema.model_dump()
empty_choice = {"existing_ids": [], "new_names": []}
assert dumped["tags"] == empty_choice
assert dumped["correspondents"] == empty_choice
assert dumped["document_types"] == empty_choice
assert dumped["storage_paths"] == empty_choice
assert dumped["dates"] == []
assert dumped == {
"title": "Test Title",
"tags": [],
"matched_tags": [],
"tag_ids": [],
"correspondents": [],
"matched_correspondents": [],
"correspondent_ids": [],
"document_types": [],
"matched_document_types": [],
"document_type_ids": [],
"storage_paths": [],
"matched_storage_paths": [],
"storage_path_ids": [],
"dates": [],
}
def test_model_response_converts_names_to_internal_taxonomy_choices():
"""
GIVEN:
- A model response containing taxonomy names
WHEN:
- It is converted to Paperless' internal suggestion representation
THEN:
- Names enter the internal taxonomy representation as new names
- Existing IDs remain empty for deterministic application-side matching
"""
parsed = DocumentClassifierSchema(
title="Electricity Bill",
tags=["Utilities", "Electricity"],
correspondents=["Power Company"],
document_types=["Utility Bill"],
storage_paths=["Finance/Utilities"],
)
suggestions = model_to_classification_suggestions(parsed)
assert suggestions["tags"] == {
"existing_ids": [],
"new_names": ["Utilities", "Electricity"],
}
assert suggestions["correspondents"] == {
"existing_ids": [],
"new_names": ["Power Company"],
}
assert suggestions["document_types"] == {
"existing_ids": [],
"new_names": ["Utility Bill"],
}
assert suggestions["storage_paths"] == {
"existing_ids": [],
"new_names": ["Finance/Utilities"],
}
def test_valid_candidate_mappings_replace_only_the_matched_names():
parsed = DocumentClassifierSchema(
title="Electricity Bill",
tags=["Utilities", "Electricity"],
matched_tags=["Utilities"],
tag_ids=[12],
correspondents=["Power Company"],
matched_correspondents=["Power Company"],
correspondent_ids=[23],
)
suggestions = model_to_classification_suggestions(
parsed,
{
"tags": {12},
"correspondents": {23},
},
)
assert suggestions["tags"] == {
"existing_ids": [12],
"new_names": ["Electricity"],
}
assert suggestions["correspondents"] == {
"existing_ids": [23],
"new_names": [],
}
def test_invalid_or_unpaired_candidate_mappings_do_not_remove_names():
parsed = DocumentClassifierSchema(
title="Electricity Bill",
tags=["Utilities", "Electricity", "Energy"],
matched_tags=["Invented", "Utilities", "Electricity"],
tag_ids=[12, 999],
)
suggestions = model_to_classification_suggestions(
parsed,
{"tags": {12}},
)
assert suggestions["tags"] == {
"existing_ids": [],
"new_names": ["Utilities", "Electricity", "Energy"],
}
def test_document_classifier_schema_json_schema_is_self_contained():
@@ -50,23 +137,20 @@ def test_document_classifier_schema_json_schema_is_self_contained():
WHEN:
- Its JSON schema is generated via model_json_schema()
THEN:
- No $defs section and no $ref at any depth survives in the schema
- Each taxonomy property carries existing_ids/new_names inline
- The schema contains no definitions, references, or nested objects
- Every response field is a scalar or flat array
Regression guard: Google's function-declaration schema rejects the $ref
Pydantic normally emits for the nested TaxonomyChoice model.
This keeps the function declaration compatible with backends that reject
JSON Schema references and with smaller models that struggle with nesting.
"""
schema = DocumentClassifierSchema.model_json_schema()
assert "$defs" not in schema
assert "$ref" not in json.dumps(schema)
for field in ("tags", "correspondents", "document_types", "storage_paths"):
field_schema = schema["properties"][field]
assert "$ref" not in field_schema
assert set(field_schema["properties"].keys()) == {
"existing_ids",
"new_names",
}
assert all(
field_schema.get("type") != "object"
for field_schema in schema["properties"].values()
)
def test_every_field_describes_itself_to_the_model():
@@ -76,8 +160,7 @@ def test_every_field_describes_itself_to_the_model():
WHEN:
- Its JSON schema is generated via model_json_schema()
THEN:
- Every property, and every property of each inlined TaxonomyChoice,
carries a non-empty description
- Every property carries a non-empty description
In tool-calling mode the schema is most of what tells the model how to
fill these fields; on field names alone, small models can bin tags and
@@ -86,48 +169,14 @@ def test_every_field_describes_itself_to_the_model():
schema = DocumentClassifierSchema.model_json_schema()
undescribed = [
f"{owner}.{name}"
for owner, definition in [
("DocumentClassifierSchema", schema),
*(
(name, prop)
for name, prop in schema["properties"].items()
if prop.get("type") == "object"
),
]
for name, prop in definition.get("properties", {}).items()
name
for name, prop in schema["properties"].items()
if not prop.get("description")
]
assert undescribed == []
def test_inlining_keeps_each_taxonomy_fields_own_description():
"""
GIVEN:
- The DocumentClassifierSchema pydantic model
WHEN:
- Its JSON schema is generated via model_json_schema()
THEN:
- Each taxonomy field keeps its own description, not the shared one
- The inlined TaxonomyChoice properties survive underneath it
Pydantic emits a field's description as a sibling of its $ref, so
replacing the property outright collapses all four onto TaxonomyChoice's
docstring - which still passes a "has a description" check.
"""
properties = DocumentClassifierSchema.model_json_schema()["properties"]
taxonomy_fields = ("tags", "correspondents", "document_types", "storage_paths")
descriptions = {
field: properties[field]["description"] for field in taxonomy_fields
}
assert len(set(descriptions.values())) == len(taxonomy_fields)
for field in taxonomy_fields:
assert properties[field]["properties"]["existing_ids"]["description"]
def test_every_sequence_in_the_emitted_schema_is_bounded():
"""
GIVEN:
@@ -135,22 +184,13 @@ def test_every_sequence_in_the_emitted_schema_is_bounded():
WHEN:
- Its JSON schema is generated via model_json_schema()
THEN:
- Every array property in the schema, including those on each
inlined TaxonomyChoice, carries a maxItems
- Every array property in the schema carries a maxItems
"""
schema = DocumentClassifierSchema.model_json_schema()
unbounded = [
f"{owner}.{name}"
for owner, definition in [
("DocumentClassifierSchema", schema),
*(
(name, prop)
for name, prop in schema["properties"].items()
if prop.get("type") == "object"
),
]
for name, prop in definition.get("properties", {}).items()
name
for name, prop in schema["properties"].items()
if prop.get("type") == "array" and "maxItems" not in prop
]
@@ -182,17 +222,13 @@ def test_over_long_response_is_truncated_rather_than_rejected():
"""
parsed = DocumentClassifierSchema(
title="T" * (MAX_TITLE_LENGTH + 50),
tags=TaxonomyChoice(
existing_ids=list(range(MAX_EXISTING_IDS + 20)),
new_names=["n"] * (MAX_NEW_NAMES + 20),
),
tags=["n"] * (MAX_NEW_NAMES + 20),
dates=[f"2016-{month:02d}-01" for month in range(1, 13)],
)
assert len(parsed.title) == MAX_TITLE_LENGTH
assert len(parsed.dates) == MAX_DATES
assert len(parsed.tags.existing_ids) == MAX_EXISTING_IDS
assert len(parsed.tags.new_names) == MAX_NEW_NAMES
assert len(parsed.tags) == MAX_NEW_NAMES
def test_truncation_keeps_the_earliest_entries():
@@ -212,24 +248,66 @@ def test_truncation_keeps_the_earliest_entries():
assert parsed.dates == ["2016-10-01", "2016-09-01", "2016-08-01"]
def test_model_dump_matches_typed_dict_keys():
def test_model_conversion_matches_internal_typed_dict_keys():
"""
GIVEN:
- A DocumentClassifierSchema instance
WHEN:
- It is dumped to a dict via model_dump()
- It is converted to ClassificationSuggestions
THEN:
- The dumped dict's keys exactly match ClassificationSuggestions'
- The converted dict's keys exactly match ClassificationSuggestions'
declared keys
- The dumped tags dict's keys exactly match TaxonomyChoiceDict's
- The converted tags dict's keys exactly match TaxonomyChoiceDict's
declared keys
"""
# TaxonomyChoiceDict/ClassificationSuggestions are the static-typing
# counterparts of TaxonomyChoice/DocumentClassifierSchema - this pins
# down that .model_dump()'s actual runtime keys are exactly what the
# TypedDicts declare, so the two don't silently drift apart.
schema = DocumentClassifierSchema(title="T", tags=TaxonomyChoice(existing_ids=[1]))
dumped = schema.model_dump()
schema = DocumentClassifierSchema(title="T", tags=["Tag"])
suggestions = model_to_classification_suggestions(schema)
assert set(dumped.keys()) == set(ClassificationSuggestions.__annotations__.keys())
assert set(dumped["tags"].keys()) == set(TaxonomyChoiceDict.__annotations__.keys())
assert set(suggestions.keys()) == set(
ClassificationSuggestions.__annotations__.keys(),
)
assert set(suggestions["tags"].keys()) == set(
TaxonomyChoiceDict.__annotations__.keys(),
)
def test_internal_suggestions_convert_to_names_only_model():
suggestions = ClassificationSuggestions(
title="Electricity Bill",
tags=TaxonomyChoiceDict(existing_ids=[1], new_names=["Utilities"]),
correspondents=TaxonomyChoiceDict(
existing_ids=[2],
new_names=["Power Company"],
),
document_types=TaxonomyChoiceDict(
existing_ids=[3],
new_names=["Utility Bill"],
),
storage_paths=TaxonomyChoiceDict(
existing_ids=[4],
new_names=["Finance/Utilities"],
),
dates=["2026-08-30"],
)
model = classification_suggestions_to_model(suggestions)
converted = model_to_classification_suggestions(model)
assert converted == ClassificationSuggestions(
title="Electricity Bill",
tags=TaxonomyChoiceDict(existing_ids=[], new_names=["Utilities"]),
correspondents=TaxonomyChoiceDict(
existing_ids=[],
new_names=["Power Company"],
),
document_types=TaxonomyChoiceDict(
existing_ids=[],
new_names=["Utility Bill"],
),
storage_paths=TaxonomyChoiceDict(
existing_ids=[],
new_names=["Finance/Utilities"],
),
dates=["2026-08-30"],
)
+22 -12
View File
@@ -123,19 +123,24 @@ def test_run_llm_query_ollama_uses_structured_json(mock_ai_config, mock_ollama_l
mock_llm_instance.chat.return_value.message.content = json.dumps(
{
"title": "Test Title",
"tags": {"existing_ids": [1], "new_names": ["document"]},
"correspondents": {"existing_ids": [], "new_names": ["John Doe"]},
"document_types": {"existing_ids": [], "new_names": ["report"]},
"storage_paths": {"existing_ids": [], "new_names": ["Reports"]},
"tags": ["document"],
"matched_tags": ["document"],
"tag_ids": [1],
"correspondents": ["John Doe"],
"document_types": ["report"],
"storage_paths": ["Reports"],
"dates": ["2023-01-01"],
},
)
client = AIClient()
result = client.run_llm_query("test_prompt")
result = client.run_llm_query(
"test_prompt",
allowed_candidate_ids={"tags": {1}},
)
assert result["title"] == "Test Title"
assert result["tags"] == {"existing_ids": [1], "new_names": ["document"]}
assert result["tags"] == {"existing_ids": [1], "new_names": []}
mock_llm_instance.chat.assert_called_once_with(
[ANY],
format=ANY,
@@ -156,10 +161,12 @@ def test_run_llm_query_openai_uses_tools(mock_ai_config, mock_openai_llm):
tool_name="DocumentClassifierSchema",
tool_kwargs={
"title": "Test Title",
"tags": {"existing_ids": [1], "new_names": ["document"]},
"correspondents": {"existing_ids": [], "new_names": ["John Doe"]},
"document_types": {"existing_ids": [], "new_names": ["report"]},
"storage_paths": {"existing_ids": [], "new_names": ["Reports"]},
"tags": ["document"],
"matched_tags": ["document"],
"tag_ids": [1],
"correspondents": ["John Doe"],
"document_types": ["report"],
"storage_paths": ["Reports"],
"dates": ["2023-01-01"],
},
)
@@ -168,10 +175,13 @@ def test_run_llm_query_openai_uses_tools(mock_ai_config, mock_openai_llm):
mock_llm_instance.get_tool_calls_from_response.return_value = [tool_selection]
client = AIClient()
result = client.run_llm_query("test_prompt")
result = client.run_llm_query(
"test_prompt",
allowed_candidate_ids={"tags": {1}},
)
assert result["title"] == "Test Title"
assert result["tags"] == {"existing_ids": [1], "new_names": ["document"]}
assert result["tags"] == {"existing_ids": [1], "new_names": []}
mock_llm_instance.chat_with_tools.assert_called_once()
+31 -33
View File
@@ -1,4 +1,5 @@
import json
from types import SimpleNamespace
import pytest
import pytest_mock
@@ -10,7 +11,6 @@ from documents.tests.factories import StoragePathFactory
from documents.tests.factories import TagFactory
from documents.tests.factories import UserFactory
from paperless_ai.taxonomy import AssignedMetadata
from paperless_ai.taxonomy import SimilarDocument
from paperless_ai.taxonomy import TaxonomyCandidates
from paperless_ai.taxonomy import build_taxonomy_candidates
from paperless_ai.taxonomy import format_taxonomy_for_prompt
@@ -132,8 +132,9 @@ class TestGetAssignedMetadata:
assert result["tags"] == ["Owned By Someone Else"]
def make_similar(document_id: int, weight: float) -> SimilarDocument:
return SimilarDocument(document_id=document_id, weight=weight)
def make_node(document_id: int, score: float) -> SimpleNamespace:
"""A stand-in for NodeWithScore: only ``.metadata``/``.score`` are read."""
return SimpleNamespace(metadata={"document_id": str(document_id)}, score=score)
@pytest.mark.django_db
@@ -169,9 +170,9 @@ class TestBuildTaxonomyCandidates:
doc_a.tags.add(tag)
doc_b = DocumentFactory.create()
doc_b.tags.add(tag)
similar_documents = [make_similar(doc_a.pk, 0.9), make_similar(doc_b.pk, 0.4)]
nodes = [make_node(doc_a.pk, 0.9), make_node(doc_b.pk, 0.4)]
result = build_taxonomy_candidates(similar_documents, user=None)
result = build_taxonomy_candidates(nodes, user=None)
assert len(result["tags"]) == 1
assert result["tags"][0]["id"] == tag.pk
@@ -196,9 +197,9 @@ class TestBuildTaxonomyCandidates:
document.tags.add(tag)
tag.name = "New Name"
tag.save()
similar_documents = [make_similar(document.pk, 0.5)]
nodes = [make_node(document.pk, 0.5)]
result = build_taxonomy_candidates(similar_documents, user=None)
result = build_taxonomy_candidates(nodes, user=None)
assert result["tags"][0]["name"] == "New Name"
@@ -218,9 +219,9 @@ class TestBuildTaxonomyCandidates:
document = DocumentFactory.create()
document.tags.add(tag)
tag.delete()
similar_documents = [make_similar(document.pk, 0.5)]
nodes = [make_node(document.pk, 0.5)]
result = build_taxonomy_candidates(similar_documents, user=None)
result = build_taxonomy_candidates(nodes, user=None)
assert result["tags"] == []
@@ -239,12 +240,9 @@ class TestBuildTaxonomyCandidates:
strong_doc.tags.add(strong_tag)
weak_doc = DocumentFactory.create()
weak_doc.tags.add(weak_tag)
similar_documents = [
make_similar(strong_doc.pk, 0.9),
make_similar(weak_doc.pk, 0.1),
]
nodes = [make_node(strong_doc.pk, 0.9), make_node(weak_doc.pk, 0.1)]
result = build_taxonomy_candidates(similar_documents, user=None)
result = build_taxonomy_candidates(nodes, user=None)
assert [c["name"] for c in result["tags"]] == ["Strong", "Weak"]
@@ -260,9 +258,9 @@ class TestBuildTaxonomyCandidates:
document = DocumentFactory.create()
for i in range(15):
document.tags.add(TagFactory.create(name=f"Tag{i}"))
similar_documents = [make_similar(document.pk, 0.5)]
nodes = [make_node(document.pk, 0.5)]
result = build_taxonomy_candidates(similar_documents, user=None)
result = build_taxonomy_candidates(nodes, user=None)
assert len(result["tags"]) == 10
@@ -276,12 +274,12 @@ class TestBuildTaxonomyCandidates:
- Only 5 correspondents are returned
"""
correspondents = CorrespondentFactory.create_batch(7)
similar_documents = [
make_similar(DocumentFactory.create(correspondent=c).pk, 0.5)
nodes = [
make_node(DocumentFactory.create(correspondent=c).pk, 0.5)
for c in correspondents
]
result = build_taxonomy_candidates(similar_documents, user=None)
result = build_taxonomy_candidates(nodes, user=None)
assert len(result["correspondents"]) == 5
@@ -296,9 +294,9 @@ class TestBuildTaxonomyCandidates:
"""
document_type = DocumentTypeFactory.create(name="Invoice")
document = DocumentFactory.create(document_type=document_type)
similar_documents = [make_similar(document.pk, 0.5)]
nodes = [make_node(document.pk, 0.5)]
result = build_taxonomy_candidates(similar_documents, user=None)
result = build_taxonomy_candidates(nodes, user=None)
assert len(result["document_types"]) == 1
assert result["document_types"][0]["id"] == document_type.pk
@@ -314,12 +312,12 @@ class TestBuildTaxonomyCandidates:
- Only 5 document_types are returned
"""
document_types = DocumentTypeFactory.create_batch(7)
similar_documents = [
make_similar(DocumentFactory.create(document_type=dt).pk, 0.5)
nodes = [
make_node(DocumentFactory.create(document_type=dt).pk, 0.5)
for dt in document_types
]
result = build_taxonomy_candidates(similar_documents, user=None)
result = build_taxonomy_candidates(nodes, user=None)
assert len(result["document_types"]) == 5
@@ -334,9 +332,9 @@ class TestBuildTaxonomyCandidates:
"""
storage_path = StoragePathFactory.create(name="Invoices")
document = DocumentFactory.create(storage_path=storage_path)
similar_documents = [make_similar(document.pk, 0.5)]
nodes = [make_node(document.pk, 0.5)]
result = build_taxonomy_candidates(similar_documents, user=None)
result = build_taxonomy_candidates(nodes, user=None)
assert len(result["storage_paths"]) == 1
assert result["storage_paths"][0]["id"] == storage_path.pk
@@ -352,12 +350,12 @@ class TestBuildTaxonomyCandidates:
- Only 5 storage_paths are returned
"""
storage_paths = StoragePathFactory.create_batch(7)
similar_documents = [
make_similar(DocumentFactory.create(storage_path=sp).pk, 0.5)
nodes = [
make_node(DocumentFactory.create(storage_path=sp).pk, 0.5)
for sp in storage_paths
]
result = build_taxonomy_candidates(similar_documents, user=None)
result = build_taxonomy_candidates(nodes, user=None)
assert len(result["storage_paths"]) == 5
@@ -377,14 +375,14 @@ class TestBuildTaxonomyCandidates:
tag = TagFactory.create(name="Restricted")
document = DocumentFactory.create()
document.tags.add(tag)
similar_documents = [make_similar(document.pk, 0.5)]
nodes = [make_node(document.pk, 0.5)]
user = UserFactory.create()
mocker.patch(
"documents.permissions.permitted_object_ids",
return_value=[], # user cannot see this tag
)
result = build_taxonomy_candidates(similar_documents, user=user)
result = build_taxonomy_candidates(nodes, user=user)
assert result["tags"] == []
@@ -414,10 +412,10 @@ class TestBuildTaxonomyCandidates:
tag.save()
document = DocumentFactory.create()
document.tags.add(tag)
similar_documents = [make_similar(document.pk, 0.5)]
nodes = [make_node(document.pk, 0.5)]
spy = mocker.patch("documents.permissions.permitted_object_ids")
result = build_taxonomy_candidates(similar_documents, user=None)
result = build_taxonomy_candidates(nodes, user=None)
assert result["tags"][0]["name"] == "Owned"
spy.assert_not_called()