Compare commits

..
Author SHA1 Message Date
Trenton Holmes f3c7dd9f80 sort similar documents by weight, use unrestricted-user check consistently (existed before, but good to be consistent) 2026-09-05 11:25:20 -07:00
stumpylog 03f6c6b5b9 Not bad catches from Copilot, if a little extra secure 2026-09-05 11:25:20 -07:00
stumpylog 138b18382e feature: add Tantivy full-text fallback adapter for taxonomy candidates
This brings users without an embedding backend configured to closer
parity with those who do.  Reuse the search backend to locate similar
documents and use them to provide the LLM with the better suggestion pool
to draw from
2026-09-05 11:25:20 -07:00
35 changed files with 535 additions and 383 deletions
+23 -50
View File
@@ -5,79 +5,52 @@ on:
jobs:
Anti-slop:
# Note: peakoss/anti-slop does not support the `issues` event yet (all of its
# issue inputs are still commented out upstream), so the checks that the PR Bot
# workflow gets from the action are implemented manually here.
# issue inputs are still commented out upstream), so the honeypot check that
# the PR Bot workflow gets from the action is implemented manually here.
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- name: Check for slop signals
- name: Check for honeypot token
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const issue = context.payload.issue;
if (['OWNER', 'MEMBER', 'COLLABORATOR'].includes(issue.author_association)) {
core.info('Skipping checks: user is a maintainer');
core.info('Skipping check: user is a maintainer');
return;
}
if (issue.user.type === 'Bot') {
core.info('Skipping checks: user is a bot');
core.info('Skipping check: user is a bot');
return;
}
const haystack = `${issue.title}\n${issue.body ?? ''}`;
if (!haystack.toUpperCase().includes('ASLOP-PR-VERIFY')) {
core.info('Honeypot token not found');
return;
}
core.info('Honeypot token found, closing issue');
const common = {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
};
const contributing =
'https://github.com/paperless-ngx/paperless-ngx/blob/main/CONTRIBUTING.md#use-of-ai-tools';
const codeOfConduct =
'https://github.com/paperless-ngx/paperless-ngx/blob/main/CODE_OF_CONDUCT.md';
const newIssue = 'https://github.com/paperless-ngx/paperless-ngx/issues/new/choose';
await github.rest.issues.createComment({
...common,
body:
"This issue was automatically closed because it contains a marker that is only visible to " +
"automated tools, which indicates it was generated by an AI agent without being disclosed as such.\n\n" +
"Please see our [contributing guidelines](https://github.com/paperless-ngx/paperless-ngx/blob/main/CONTRIBUTING.md#use-of-ai-tools) " +
"and [Code of Conduct](https://github.com/paperless-ngx/paperless-ngx/blob/main/CODE_OF_CONDUCT.md). " +
"You are welcome to open a new issue that describes the problem you observed in your own words.",
});
// Honeypot: a token only an AI agent reading the raw issue template would include.
const haystack = `${issue.title}\n${issue.body ?? ''}`;
const honeypot = haystack.toUpperCase().includes('ASLOP-PR-VERIFY');
// Issues opened through the form always get the template's default labels. GitHub
// applies them a second or two *after* creation, so re-read them instead of trusting
// the webhook payload, and retry before concluding that there are none.
const templateLabels = ['bug', 'unconfirmed'];
let labels = [];
for (const delay of [0, 15000, 30000]) {
if (delay) await new Promise((resolve) => setTimeout(resolve, delay));
const { data } = await github.rest.issues.get({ ...common });
labels = data.labels.map((label) => (typeof label === 'string' ? label : label.name));
if (labels.some((label) => templateLabels.includes(label))) break;
}
const bypassedTemplate = !labels.some((label) => templateLabels.includes(label));
core.info(`Labels: [${labels.join(', ')}], honeypot: ${honeypot}, bypassed template: ${bypassedTemplate}`);
if (!honeypot && !bypassedTemplate) {
core.info('No slop signals found');
return;
}
// The honeypot is only ever tripped deliberately, so that message can name the cause.
// A missing template label only tells us the issue did not come from the form, which
// is reason enough to close it, but not proof of how it was written.
const body = honeypot
? 'This issue was automatically closed because it contains a marker that is only visible to ' +
'automated tools, which indicates it was generated by an AI agent without being disclosed as such.\n\n' +
`Please see our [contributing guidelines](${contributing}) and [Code of Conduct](${codeOfConduct}). ` +
'You are welcome to open a new issue that describes the problem you observed in your own words.'
: 'This issue was automatically closed because it was not opened using our bug report form. ' +
'Issues have to be created through the form so that the details we need to investigate are included.\n\n' +
`If the problem is still there, please [open a new issue](${newIssue}) using the form — that is all it takes ` +
'to get it looked at, and no other action is needed here.\n\n' +
'If any part of your report was written by an AI tool or agent, you must say so: undisclosed AI-generated ' +
`contributions are a violation of our [Code of Conduct](${codeOfConduct}), and such reports must describe the ` +
`behavior you observed only, without code analysis or suggested fixes. See our [contributing guidelines](${contributing}).`;
await github.rest.issues.createComment({ ...common, body });
await github.rest.issues.addLabels({ ...common, labels: ['ai'] });
await github.rest.issues.update({ ...common, state: 'closed', state_reason: 'not_planned' });
+47 -58
View File
@@ -331,7 +331,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.html</context>
<context context-type="linenumber">87,88</context>
<context context-type="linenumber">86,87</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.html</context>
@@ -1880,7 +1880,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
<context context-type="linenumber">167,168</context>
<context context-type="linenumber">162,163</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/filterable-dropdown/filterable-dropdown.component.html</context>
@@ -4104,7 +4104,7 @@
<source>Open date picker</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
<context context-type="linenumber">42</context>
<context context-type="linenumber">41</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/input/date/date.component.html</context>
@@ -4115,7 +4115,7 @@
<source>Today</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
<context context-type="linenumber">47,48</context>
<context context-type="linenumber">46,47</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/dates-dropdown/dates-dropdown.component.html</context>
@@ -4146,7 +4146,7 @@
<source>Close</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
<context context-type="linenumber">48,49</context>
<context context-type="linenumber">47,48</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/dates-dropdown/dates-dropdown.component.html</context>
@@ -4168,10 +4168,6 @@
<context context-type="sourcefile">src/app/components/common/input/date/date.component.html</context>
<context context-type="linenumber">22,23</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.ts</context>
<context context-type="linenumber">81</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.html</context>
<context context-type="linenumber">155,156</context>
@@ -4189,55 +4185,55 @@
<source>True</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
<context context-type="linenumber">55,56</context>
<context context-type="linenumber">54,55</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
<context context-type="linenumber">107,108</context>
<context context-type="linenumber">103,104</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
<context context-type="linenumber">113,114</context>
<context context-type="linenumber">109,110</context>
</context-group>
</trans-unit>
<trans-unit id="3800326155195149498" datatype="html">
<source>False</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
<context context-type="linenumber">56,57</context>
<context context-type="linenumber">55,56</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
<context context-type="linenumber">108,109</context>
<context context-type="linenumber">104,105</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
<context context-type="linenumber">114,115</context>
<context context-type="linenumber">110,111</context>
</context-group>
</trans-unit>
<trans-unit id="7551700625201096185" datatype="html">
<source>Search docs...</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
<context context-type="linenumber">73</context>
<context context-type="linenumber">71</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
<context context-type="linenumber">130</context>
<context context-type="linenumber">126</context>
</context-group>
</trans-unit>
<trans-unit id="2328549728463836983" datatype="html">
<source>Remove query</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
<context context-type="linenumber">154</context>
<context context-type="linenumber">149</context>
</context-group>
</trans-unit>
<trans-unit id="3184700926171002527" datatype="html">
<source>Any</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
<context context-type="linenumber">165,166</context>
<context context-type="linenumber">160,161</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/filterable-dropdown/filterable-dropdown.component.html</context>
@@ -4248,28 +4244,28 @@
<source>Not</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
<context context-type="linenumber">170,171</context>
<context context-type="linenumber">165,166</context>
</context-group>
</trans-unit>
<trans-unit id="6548676277933116532" datatype="html">
<source>Add query</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
<context context-type="linenumber">189</context>
<context context-type="linenumber">184</context>
</context-group>
</trans-unit>
<trans-unit id="5599577087865387184" datatype="html">
<source>Add expression</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
<context context-type="linenumber">192</context>
<context context-type="linenumber">187</context>
</context-group>
</trans-unit>
<trans-unit id="7394978038852129121" datatype="html">
<source>Remove expression</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
<context context-type="linenumber">196</context>
<context context-type="linenumber">191</context>
</context-group>
</trans-unit>
<trans-unit id="6630533861307510614" datatype="html">
@@ -5977,53 +5973,46 @@
<context context-type="linenumber">468,469</context>
</context-group>
</trans-unit>
<trans-unit id="7160869275743386191" datatype="html">
<source>This action runs in the background, so the suggestions may be applied after the other actions of this workflow have finished.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">469,470</context>
</context-group>
</trans-unit>
<trans-unit id="3473589590476379802" datatype="html">
<source>Apply suggestions for</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">472,473</context>
<context context-type="linenumber">471,472</context>
</context-group>
</trans-unit>
<trans-unit id="6859826074028847234" datatype="html">
<source>Suggestions for fields that are not selected are discarded.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">477,478</context>
<context context-type="linenumber">476,477</context>
</context-group>
</trans-unit>
<trans-unit id="1137167921211640884" datatype="html">
<source>Create missing items</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">487,488</context>
<context context-type="linenumber">486,487</context>
</context-group>
</trans-unit>
<trans-unit id="449779688394460071" datatype="html">
<source>Create suggested tags, correspondents and document types that do not exist yet.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">489,490</context>
<context context-type="linenumber">488,489</context>
</context-group>
</trans-unit>
<trans-unit id="5192325026233872238" datatype="html">
<source>Overwrite existing values</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">497,498</context>
<context context-type="linenumber">496,497</context>
</context-group>
</trans-unit>
<trans-unit id="9010697250406492150" datatype="html">
<source>Apply suggestions even if the document already has a value. Tags are always added, never replaced.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">499,500</context>
<context context-type="linenumber">498,499</context>
</context-group>
</trans-unit>
<trans-unit id="4626030417479279989" datatype="html">
@@ -6703,10 +6692,6 @@
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
<context context-type="linenumber">162,163</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.html</context>
<context context-type="linenumber">85,86</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/share-links-dialog/share-links-dialog.component.html</context>
<context context-type="linenumber">39,40</context>
@@ -7255,7 +7240,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.html</context>
<context context-type="linenumber">89,90</context>
<context context-type="linenumber">88,89</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.html</context>
@@ -7350,7 +7335,7 @@
<source>Never</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.html</context>
<context context-type="linenumber">95,96</context>
<context context-type="linenumber">94,95</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.html</context>
@@ -7365,7 +7350,7 @@
<source>File version</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.html</context>
<context context-type="linenumber">98,99</context>
<context context-type="linenumber">97,98</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.html</context>
@@ -7376,7 +7361,7 @@
<source>Size</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.html</context>
<context context-type="linenumber">101,102</context>
<context context-type="linenumber">100,101</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.html</context>
@@ -7387,14 +7372,14 @@
<source>A zip file containing the selected documents will be created for this share link bundle. This process happens in the background and may take some time, especially for large bundles.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.html</context>
<context context-type="linenumber">111</context>
<context context-type="linenumber">110</context>
</context-group>
</trans-unit>
<trans-unit id="7274497464693086242" datatype="html">
<source>Manage share link bundles</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.html</context>
<context context-type="linenumber">115,116</context>
<context context-type="linenumber">114,115</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
@@ -7405,14 +7390,25 @@
<source>Create share link bundle</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.ts</context>
<context context-type="linenumber">59</context>
<context context-type="linenumber">61</context>
</context-group>
</trans-unit>
<trans-unit id="2362798555008696742" datatype="html">
<source>Create link</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.ts</context>
<context context-type="linenumber">60</context>
<context context-type="linenumber">62</context>
</context-group>
</trans-unit>
<trans-unit id="6826468215808575823" datatype="html">
<source>Share link copied to clipboard.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.ts</context>
<context context-type="linenumber">96</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.ts</context>
<context context-type="linenumber">111</context>
</context-group>
</trans-unit>
<trans-unit id="2044920992186463191" datatype="html">
@@ -7496,13 +7492,6 @@
<context context-type="linenumber">67</context>
</context-group>
</trans-unit>
<trans-unit id="6826468215808575823" datatype="html">
<source>Share link copied to clipboard.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.ts</context>
<context context-type="linenumber">111</context>
</context-group>
</trans-unit>
<trans-unit id="7268517108785510779" datatype="html">
<source>Share link bundle deleted.</source>
<context-group purpose="location">
@@ -8328,7 +8317,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/main.ts</context>
<context context-type="linenumber">469</context>
<context context-type="linenumber">466</context>
</context-group>
</trans-unit>
<trans-unit id="5028777105388019087" datatype="html">
@@ -12751,14 +12740,14 @@
<source>Prev</source>
<context-group purpose="location">
<context context-type="sourcefile">src/main.ts</context>
<context context-type="linenumber">468</context>
<context context-type="linenumber">465</context>
</context-group>
</trans-unit>
<trans-unit id="1241348629231510663" datatype="html">
<source>End</source>
<context-group purpose="location">
<context context-type="sourcefile">src/main.ts</context>
<context context-type="linenumber">470</context>
<context context-type="linenumber">467</context>
</context-group>
</trans-unit>
</body>
@@ -6,7 +6,7 @@
<div class="list-group list-group-flush" (keydown)="listKeyDown($event)">
<div class="list-group-item">
<div class="input-group input-group-sm">
<input class="form-control" type="text" [(ngModel)]="filterText" [ngModelOptions]="{standalone: true}" placeholder="Search fields" i18n-placeholder (keyup.enter)="listFilterEnter()" #listFilterTextInput>
<input class="form-control" type="text" [(ngModel)]="filterText" placeholder="Search fields" i18n-placeholder (keyup.enter)="listFilterEnter()" #listFilterTextInput>
</div>
</div>
@for (field of filteredFields; track field.id) {
@@ -35,7 +35,6 @@
@if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Date) {
<input class="form-control" placeholder="yyyy-mm-dd"
[(ngModel)]="atom.value"
[ngModelOptions]="{standalone: true}"
ngbDatepicker
#d="ngbDatepicker"
[footerTemplate]="datePickerFooterTemplate" />
@@ -49,9 +48,9 @@
</div>
</ng-template>
} @else if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Float || getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Integer) {
<input class="w-25 form-control rounded-end" type="number" [(ngModel)]="atom.value" [ngModelOptions]="{standalone: true}" [disabled]="disabled">
<input class="w-25 form-control rounded-end" type="number" [(ngModel)]="atom.value" [disabled]="disabled">
} @else if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Boolean) {
<select class="w-25 form-select rounded-end" [(ngModel)]="atom.value" [ngModelOptions]="{standalone: true}" [disabled]="disabled">
<select class="w-25 form-select rounded-end" [(ngModel)]="atom.value" [disabled]="disabled">
<option value="true" i18n>True</option>
<option value="false" i18n>False</option>
</select>
@@ -62,7 +61,6 @@
bindLabel="label"
bindValue="id"
[(ngModel)]="atom.value"
[ngModelOptions]="{standalone: true}"
[disabled]="disabled"
[virtualScroll]="getSelectOptionsForField(atom.field)?.length > 100"
[searchFn]="selectOptionSearchFn"
@@ -70,15 +68,14 @@
(mousedown)="$event.stopImmediatePropagation()"
></ng-select>
} @else if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.DocumentLink) {
<pngx-input-document-link [(ngModel)]="atom.value" [ngModelOptions]="{standalone: true}" class="w-25 form-select doc-link-select p-0" placeholder="Search docs..." i18n-placeholder [minimal]="true" [appendTo]="selectAppendTo"></pngx-input-document-link>
<pngx-input-document-link [(ngModel)]="atom.value" class="w-25 form-select doc-link-select p-0" placeholder="Search docs..." i18n-placeholder [minimal]="true" [appendTo]="selectAppendTo"></pngx-input-document-link>
} @else if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Monetary) {
<input class="w-25 form-control rounded-end" type="text" inputmode="decimal"
[ngModel]="atom.value"
[ngModelOptions]="{standalone: true}"
(ngModelChange)="setMonetaryValue(atom, $event)"
[disabled]="disabled">
} @else {
<input class="w-25 form-control rounded-end" type="text" [(ngModel)]="atom.value" [ngModelOptions]="{standalone: true}" [disabled]="disabled">
<input class="w-25 form-control rounded-end" type="text" [(ngModel)]="atom.value" [disabled]="disabled">
}
</ng-template>
@@ -88,7 +85,6 @@
class="paperless-input-select"
[items]="customFields()"
[(ngModel)]="atom.field"
[ngModelOptions]="{standalone: true}"
[disabled]="disabled"
bindLabel="name"
bindValue="id"
@@ -96,20 +92,20 @@
[appendTo]="selectAppendTo"
(mousedown)="$event.stopImmediatePropagation()"
></ng-select>
<select class="w-25 form-select" [(ngModel)]="atom.operator" [ngModelOptions]="{standalone: true}" [disabled]="disabled">
<select class="w-25 form-select" [(ngModel)]="atom.operator" [disabled]="disabled">
@for (operator of getOperatorsForField(atom.field); track operator.label) {
<option [ngValue]="operator.value">{{operator.label}}</option>
}
</select>
@switch (atom.operator) {
@case (CustomFieldQueryOperator.Exists) {
<select class="w-25 form-select rounded-end" [(ngModel)]="atom.value" [ngModelOptions]="{standalone: true}" [disabled]="disabled">
<select class="w-25 form-select rounded-end" [(ngModel)]="atom.value" [disabled]="disabled">
<option value="true" i18n>True</option>
<option value="false" i18n>False</option>
</select>
}
@case (CustomFieldQueryOperator.IsNull) {
<select class="w-25 form-select rounded-end" [(ngModel)]="atom.value" [ngModelOptions]="{standalone: true}" [disabled]="disabled">
<select class="w-25 form-select rounded-end" [(ngModel)]="atom.value" [disabled]="disabled">
<option value="true" i18n>True</option>
<option value="false" i18n>False</option>
</select>
@@ -127,7 +123,7 @@
<ng-container *ngTemplateOutlet="comparisonValueTemplate; context: { atom: atom }"></ng-container>
}
@case (CustomFieldQueryOperator.Contains) {
<pngx-input-document-link [(ngModel)]="atom.value" [ngModelOptions]="{standalone: true}" class="w-25 form-select doc-link-select p-0" placeholder="Search docs..." i18n-placeholder [minimal]="true" [appendTo]="selectAppendTo"></pngx-input-document-link>
<pngx-input-document-link [(ngModel)]="atom.value" class="w-25 form-select doc-link-select p-0" placeholder="Search docs..." i18n-placeholder [minimal]="true" [appendTo]="selectAppendTo"></pngx-input-document-link>
}
@case (CustomFieldQueryOperator.In) {
<ng-select
@@ -136,7 +132,6 @@
bindLabel="label"
bindValue="id"
[(ngModel)]="atom.value"
[ngModelOptions]="{standalone: true}"
[disabled]="disabled"
[multiple]="true"
[searchFn]="selectOptionSearchFn"
@@ -148,7 +143,7 @@
<ng-container *ngTemplateOutlet="comparisonValueTemplate; context: { atom: atom }"></ng-container>
}
@default {
<input class="w-25 form-control rounded-end" type="text" [(ngModel)]="atom.value" [ngModelOptions]="{standalone: true}" [disabled]="disabled">
<input class="w-25 form-control rounded-end" type="text" [(ngModel)]="atom.value" [disabled]="disabled">
}
}
<button class="btn btn-link btn-sm text-danger pe-0" type="button" (click)="removeElement(atom)" [disabled]="disabled" aria-label="Remove query" i18n-aria-label>
@@ -161,12 +156,12 @@
<div class="d-flex w-100">
<div class="d-flex flex-grow-1 flex-column">
<div class="btn-group btn-group-xs" role="group">
<input [(ngModel)]="expression.operator" [ngModelOptions]="{standalone: true}" type="radio" class="btn-check" id="logicalOperatorOr_{{expression.id}}" name="logicalOperatorOr_{{expression.id}}" value="OR" [disabled]="expression.depth > 0 && expression.value.length < 2">
<input [(ngModel)]="expression.operator" type="radio" class="btn-check" id="logicalOperatorOr_{{expression.id}}" name="logicalOperatorOr_{{expression.id}}" value="OR" [disabled]="expression.depth > 0 && expression.value.length < 2">
<label class="btn btn-outline-primary" for="logicalOperatorOr_{{expression.id}}" i18n>Any</label>
<input [(ngModel)]="expression.operator" [ngModelOptions]="{standalone: true}" type="radio" class="btn-check" id="logicalOperatorAnd_{{expression.id}}" name="logicalOperatorAnd_{{expression.id}}" value="AND" [disabled]="expression.depth > 0 && expression.value.length < 2">
<input [(ngModel)]="expression.operator" type="radio" class="btn-check" id="logicalOperatorAnd_{{expression.id}}" name="logicalOperatorAnd_{{expression.id}}" value="AND" [disabled]="expression.depth > 0 && expression.value.length < 2">
<label class="btn btn-outline-primary" for="logicalOperatorAnd_{{expression.id}}" i18n>All</label>
@if (expression.negatable) {
<input [(ngModel)]="expression.operator" [ngModelOptions]="{standalone: true}" type="radio" class="btn-check" id="logicalOperatorNot_{{expression.id}}" name="logicalOperatorNot_{{expression.id}}" value="NOT">
<input [(ngModel)]="expression.operator" type="radio" class="btn-check" id="logicalOperatorNot_{{expression.id}}" name="logicalOperatorNot_{{expression.id}}" value="NOT">
<label class="btn btn-outline-secondary" for="logicalOperatorNot_{{expression.id}}" i18n>Not</label>
}
</div>
@@ -466,7 +466,6 @@
<div class="row">
<div class="col">
<p class="text-muted small" i18n>The document will be sent to the configured AI service for suggestions. Consider costs and privacy.</p>
<p class="text-muted small" i18n>This action runs in the background, so the suggestions may be applied after the other actions of this workflow have finished.</p>
<pngx-input-select
i18n-title
title="Apply suggestions for"
@@ -12,7 +12,7 @@
}
<div [ngClass]="{'col-md-9': horizontal, 'align-items-center': horizontal, 'd-flex': horizontal}">
<div class="form-check">
<input #inputField type="checkbox" class="form-check-input" [id]="inputId" [(ngModel)]="value" [ngModelOptions]="{standalone: true}" (change)="onChange(value)" (blur)="onTouched()" [disabled]="disabled">
<input #inputField type="checkbox" class="form-check-input" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" (blur)="onTouched()" [disabled]="disabled">
@if (!horizontal) {
<label class="form-check-label" [for]="inputId">{{title}}</label>
}
@@ -12,7 +12,7 @@
</div>
</ng-template>
<input #inputField class="form-control" [class.is-invalid]="error" [id]="inputId" [(ngModel)]="value" [ngModelOptions]="{standalone: true}" (change)="onChange(value)" [autoClose]="'outside'" [ngbPopover]="popContent" #colorPicker="ngbPopover" placement="bottom" popoverClass="shadow">
<input #inputField class="form-control" [class.is-invalid]="error" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" [autoClose]="'outside'" [ngbPopover]="popContent" #colorPicker="ngbPopover" placement="bottom" popoverClass="shadow">
<button class="btn btn-outline-secondary" type="button" (click)="randomize()" aria-label="Choose a random color" i18n-aria-label>
<i-bs name="dice5"></i-bs>
@@ -6,26 +6,26 @@
align-items-center">
@switch (getCustomField(fieldId)?.data_type) {
@case (CustomFieldDataType.String) {
<pngx-input-text [(ngModel)]="value[fieldId]" [ngModelOptions]="{standalone: true}" (ngModelChange)="onChange(value)"
<pngx-input-text [(ngModel)]="value[fieldId]" (ngModelChange)="onChange(value)"
[title]="getCustomField(fieldId)?.name"
class="flex-grow-1"
[horizontal]="true"></pngx-input-text>
}
@case (CustomFieldDataType.Date) {
<pngx-input-date [(ngModel)]="value[fieldId]" [ngModelOptions]="{standalone: true}" (ngModelChange)="onChange(value)"
<pngx-input-date [(ngModel)]="value[fieldId]" (ngModelChange)="onChange(value)"
[title]="getCustomField(fieldId)?.name"
class="flex-grow-1"
[horizontal]="true"></pngx-input-date>
}
@case (CustomFieldDataType.Integer) {
<pngx-input-number [(ngModel)]="value[fieldId]" [ngModelOptions]="{standalone: true}" (ngModelChange)="onChange(value)"
<pngx-input-number [(ngModel)]="value[fieldId]" (ngModelChange)="onChange(value)"
[title]="getCustomField(fieldId)?.name"
class="flex-grow-1"
[horizontal]="true"
[showAdd]="false"></pngx-input-number>
}
@case (CustomFieldDataType.Float) {
<pngx-input-number [(ngModel)]="value[fieldId]" [ngModelOptions]="{standalone: true}" (ngModelChange)="onChange(value)"
<pngx-input-number [(ngModel)]="value[fieldId]" (ngModelChange)="onChange(value)"
[title]="getCustomField(fieldId)?.name"
class="flex-grow-1"
[horizontal]="true"
@@ -33,7 +33,7 @@
[step]=".1"></pngx-input-number>
}
@case (CustomFieldDataType.Monetary) {
<pngx-input-monetary [(ngModel)]="value[fieldId]" [ngModelOptions]="{standalone: true}" (ngModelChange)="onChange(value)"
<pngx-input-monetary [(ngModel)]="value[fieldId]" (ngModelChange)="onChange(value)"
[title]="getCustomField(fieldId)?.name"
class="flex-grow-1"
[defaultCurrency]="getCustomField(fieldId)?.extra_data?.default_currency"
@@ -41,25 +41,25 @@
[horizontal]="true"></pngx-input-monetary>
}
@case (CustomFieldDataType.Boolean) {
<pngx-input-check [(ngModel)]="value[fieldId]" [ngModelOptions]="{standalone: true}" (ngModelChange)="onChange(value)"
<pngx-input-check [(ngModel)]="value[fieldId]" (ngModelChange)="onChange(value)"
[title]="getCustomField(fieldId)?.name"
class="flex-grow-1"
[horizontal]="true"></pngx-input-check>
}
@case (CustomFieldDataType.Url) {
<pngx-input-url [(ngModel)]="value[fieldId]" [ngModelOptions]="{standalone: true}" (ngModelChange)="onChange(value)"
<pngx-input-url [(ngModel)]="value[fieldId]" (ngModelChange)="onChange(value)"
[title]="getCustomField(fieldId)?.name"
class="flex-grow-1"
[horizontal]="true"></pngx-input-url>
}
@case (CustomFieldDataType.DocumentLink) {
<pngx-input-document-link [(ngModel)]="value[fieldId]" [ngModelOptions]="{standalone: true}" (ngModelChange)="onChange(value)"
<pngx-input-document-link [(ngModel)]="value[fieldId]" (ngModelChange)="onChange(value)"
[title]="getCustomField(fieldId)?.name"
class="flex-grow-1"
[horizontal]="true"></pngx-input-document-link>
}
@case (CustomFieldDataType.Select) {
<pngx-input-select [(ngModel)]="value[fieldId]" [ngModelOptions]="{standalone: true}" (ngModelChange)="onChange(value)"
<pngx-input-select [(ngModel)]="value[fieldId]" (ngModelChange)="onChange(value)"
[title]="getCustomField(fieldId)?.name"
class="flex-grow-1"
[items]="getCustomField(fieldId)?.extra_data.select_options"
@@ -69,7 +69,7 @@
[horizontal]="true"></pngx-input-select>
}
@case (CustomFieldDataType.LongText) {
<pngx-input-textarea [(ngModel)]="value[fieldId]" [ngModelOptions]="{standalone: true}" (ngModelChange)="onChange(value)"
<pngx-input-textarea [(ngModel)]="value[fieldId]" (ngModelChange)="onChange(value)"
[title]="getCustomField(fieldId)?.name"
class="flex-grow-1"></pngx-input-textarea>
}
@@ -12,7 +12,7 @@
<div class="input-group" [class.is-invalid]="error">
<input #inputField class="form-control" [class.is-invalid]="error" [placeholder]="placeholder" [id]="inputId" maxlength="10"
(dateSelect)="onChange(value)" (change)="onChange(value)" (keypress)="onKeyPress($event)" (paste)="onPaste($event)"
name="dp" [(ngModel)]="value" [ngModelOptions]="{standalone: true}" ngbDatepicker #datePicker="ngbDatepicker" #datePickerContent="ngModel" [disabled]="disabled" [footerTemplate]="datePickerFooterTemplate">
name="dp" [(ngModel)]="value" ngbDatepicker #datePicker="ngbDatepicker" #datePickerContent="ngModel" [disabled]="disabled" [footerTemplate]="datePickerFooterTemplate">
<button class="btn btn-outline-secondary calendar" (click)="datePicker.toggle()" type="button" [disabled]="disabled" aria-label="Open date picker" i18n-aria-label>
<i-bs width="1.2em" height="1.2em" name="calendar"></i-bs>
</button>
@@ -24,7 +24,7 @@
}
<ng-template #select>
<ng-select name="inputId" [(ngModel)]="selectedDocuments" [ngModelOptions]="{standalone: true}"
<ng-select name="inputId" [(ngModel)]="selectedDocuments"
[disabled]="disabled"
[items]="foundDocuments$ | async"
[placeholder]="placeholder"
@@ -11,8 +11,8 @@
<div class="position-relative">
@for (entry of entries; let i = $index; track entry[0]) {
<div class="input-group mb-3">
<input type="text" class="form-control" [(ngModel)]="entry[0]" [ngModelOptions]="{standalone: true}" (change)="inputChange()" [disabled]="disabled" autocomplete="off">
<input type="text" class="form-control" [(ngModel)]="entry[1]" [ngModelOptions]="{standalone: true}" (change)="inputChange()" [disabled]="disabled" autocomplete="off">
<input type="text" class="form-control" [(ngModel)]="entry[0]" (change)="inputChange()" [disabled]="disabled" autocomplete="off">
<input type="text" class="form-control" [(ngModel)]="entry[1]" (change)="inputChange()" [disabled]="disabled" autocomplete="off">
<button type="button" class="btn btn-outline-secondary" (click)="removeEntry(i)" aria-label="Remove entry" i18n-aria-label>
<i-bs class="text-danger" name="trash"></i-bs>
</button>
@@ -22,7 +22,7 @@
</button>
</div>
}
<input #inputField type="hidden" class="form-control small" [(ngModel)]="value" [ngModelOptions]="{standalone: true}" [disabled]="true">
<input #inputField type="hidden" class="form-control small" [(ngModel)]="value" [disabled]="true">
@if (hint) {
<small class="form-text text-muted" [innerHTML]="hint"></small>
}
@@ -13,8 +13,8 @@
<div class="position-relative" [class.col-md-9]="horizontal">
<div class="input-group" [class.is-invalid]="error">
<span class="input-group-text fw-bold bg-light">{{ monetaryValue | currency: currency }}</span>
<input #currencyField class="form-control text-muted mw-60" [(ngModel)]="currency" [ngModelOptions]="{standalone: true}" (input)="currencyChange()" maxlength="3" [class.is-invalid]="error" [disabled]="disabled">
<input #monetaryValueField type="number" class="form-control text-muted" step=".01" [(ngModel)]="monetaryValue" [ngModelOptions]="{standalone: true}" (input)="monetaryValueChange()" (change)="monetaryValueChange(true)" [class.is-invalid]="error" [disabled]="disabled">
<input #currencyField class="form-control text-muted mw-60" [(ngModel)]="currency" (input)="currencyChange()" maxlength="3" [class.is-invalid]="error" [disabled]="disabled">
<input #monetaryValueField type="number" class="form-control text-muted" step=".01" [(ngModel)]="monetaryValue" (input)="monetaryValueChange()" (change)="monetaryValueChange(true)" [class.is-invalid]="error" [disabled]="disabled">
</div>
<div class="invalid-feedback position-absolute top-100">
{{error}}
@@ -12,7 +12,7 @@
</div>
<div class="position-relative" [class.col-md-9]="horizontal">
<div class="input-group" [class.is-invalid]="error">
<input #inputField type="number" class="form-control" [step]="step" [id]="inputId" [(ngModel)]="value" [ngModelOptions]="{standalone: true}" (change)="onChange(value)" [class.is-invalid]="error" [disabled]="disabled">
<input #inputField type="number" class="form-control" [step]="step" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" [class.is-invalid]="error" [disabled]="disabled">
@if (showAdd) {
<button class="btn btn-outline-secondary" type="button" id="button-addon1" (click)="nextAsn()" [disabled]="disabled">+1</button>
}
@@ -7,7 +7,7 @@
</div>
<div class="position-relative" [class.col-md-9]="horizontal">
<div class="input-group" [class.is-invalid]="error">
<input #inputField [type]="showReveal && textVisible ? 'text' : 'password'" class="form-control" [class.is-invalid]="error" [id]="inputId" [(ngModel)]="value" [ngModelOptions]="{standalone: true}" (focus)="onFocus()" (focusout)="onFocusOut()" (change)="onChange(value)" [disabled]="disabled" [autocomplete]="autocomplete">
<input #inputField [type]="showReveal && textVisible ? 'text' : 'password'" class="form-control" [class.is-invalid]="error" [id]="inputId" [(ngModel)]="value" (focus)="onFocus()" (focusout)="onFocusOut()" (change)="onChange(value)" [disabled]="disabled" [autocomplete]="autocomplete">
@if (showReveal) {
<button type="button" class="btn btn-outline-secondary" (click)="toggleVisibility()" i18n-title title="Show password" [disabled]="disabled || disableRevealToggle">
<i-bs name="eye"></i-bs>
@@ -1,6 +1,6 @@
<div class="paperless-input-select" [class.disabled]="disabled">
<div>
<ng-select name="inputId" [(ngModel)]="value" [ngModelOptions]="{standalone: true}"
<ng-select name="inputId" [(ngModel)]="value"
[disabled]="disabled"
clearable="true"
[items]="groups()"
@@ -1,6 +1,6 @@
<div class="paperless-input-select" [class.disabled]="disabled">
<div>
<ng-select name="inputId" [(ngModel)]="value" [ngModelOptions]="{standalone: true}"
<ng-select name="inputId" [(ngModel)]="value"
[disabled]="disabled"
clearable="true"
[items]="users()"
@@ -14,7 +14,7 @@
}
<div [class.col-md-9]="horizontal">
<div [class.input-group]="allowCreateNew || showFilter" [class.is-invalid]="error">
<ng-select name="inputId" [(ngModel)]="value" [ngModelOptions]="{standalone: true}"
<ng-select name="inputId" [(ngModel)]="value"
[disabled]="disabled"
[style.color]="textColor"
[style.background]="backgroundColor"
@@ -17,7 +17,7 @@
}
<div [ngClass]="{'align-items-center': horizontal, 'd-flex': horizontal}">
<div class="form-check form-switch">
<input #inputField type="checkbox" class="form-check-input" [id]="inputId" [(ngModel)]="value" [ngModelOptions]="{standalone: true}" (change)="onChange(value)" (blur)="onTouched()" [disabled]="disabled">
<input #inputField type="checkbox" class="form-check-input" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" (blur)="onTouched()" [disabled]="disabled">
@if (horizontal) {
<label class="form-check-label" [class.text-muted]="showUnsetNote && isUnset" [for]="inputId" [ngbTooltip]="showUnsetNote && isUnset ? tipContent: null" placement="end">
{{title}}
@@ -7,7 +7,7 @@
}
<div class="position-relative" [class.col-md-9]="horizontal">
<div class="input-group flex-nowrap">
<ng-select #tagSelect name="tags" [items]="tags" bindLabel="name" bindValue="id" [(ngModel)]="value" [ngModelOptions]="{standalone: true}"
<ng-select #tagSelect name="tags" [items]="tags" bindLabel="name" bindValue="id" [(ngModel)]="value"
[disabled]="disabled"
[multiple]="multiple"
[closeOnSelect]="false"
@@ -11,7 +11,7 @@
}
</div>
<div class="position-relative" [class.col-md-9]="horizontal">
<input #inputField type="text" class="form-control" [class.is-invalid]="error" [id]="inputId" [(ngModel)]="value" [ngModelOptions]="{standalone: true}" (change)="onChange(value)" [disabled]="disabled" [autocomplete]="autocomplete" [placeholder]="placeholder">
<input #inputField type="text" class="form-control" [class.is-invalid]="error" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" [disabled]="disabled" [autocomplete]="autocomplete" [placeholder]="placeholder">
@if (hint) {
<small class="form-text text-muted" [innerHTML]="hint"></small>
}
@@ -17,7 +17,6 @@
[class.is-invalid]="error"
[class.font-monospace]="monospace"
[(ngModel)]="value"
[ngModelOptions]="{standalone: true}"
(change)="onChange(value)"
[disabled]="disabled"
[placeholder]="placeholder"
@@ -10,7 +10,7 @@
</div>
<div [class.col-md-9]="horizontal">
<div class="input-group" [class.is-invalid]="error">
<input #inputField type="url" class="form-control" [class.is-invalid]="error" placeholder="https://" [id]="inputId" [(ngModel)]="value" [ngModelOptions]="{standalone: true}" (change)="onChange(value)" [disabled]="disabled">
<input #inputField type="url" class="form-control" [class.is-invalid]="error" placeholder="https://" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" [disabled]="disabled">
<a class="btn btn-outline-secondary rounded-end" title="Open link" i18n-title [href]="value" target="_blank">
<i-bs width="1.2em" height="1.2em" name="box-arrow-up-right"></i-bs>
</a>
@@ -64,7 +64,7 @@
<dt class="col-sm-4" i18n>Slug</dt>
<dd class="col-sm-8"><code>{{ createdBundle.slug }}</code></dd>
<dt class="col-sm-4" i18n>Link</dt>
<dd class="col-sm-8 position-relative">
<dd class="col-sm-8">
<label class="visually-hidden" for="shareBundleLink" i18n>Share link</label>
<div class="input-group input-group-sm">
<input id="shareBundleLink" class="form-control" type="text" [value]="getShareUrl(createdBundle)" readonly>
@@ -82,7 +82,6 @@
<span class="visually-hidden" i18n>Copy link</span>
</button>
</div>
<span class="badge bg-primary fade position-absolute top-50 end-0 translate-middle-y me-5 pe-none z-3" [class.show]="copied()" i18n>Copied!</span>
</dd>
<dt class="col-sm-4" i18n>Documents</dt>
<dd class="col-sm-8">{{ createdBundle.document_count }}</dd>
@@ -115,7 +115,7 @@ describe('ShareLinkBundleDialogComponent', () => {
expect(copySpy).toHaveBeenCalledWith(component.getShareUrl(bundle))
expect(component.copied()).toBe(true)
expect(toastService.showInfo).not.toHaveBeenCalled()
expect(toastService.showInfo).toHaveBeenCalled()
jest.advanceTimersByTime(3000)
expect(component.copied()).toBe(false)
@@ -17,6 +17,7 @@ import {
} from 'src/app/data/share-link-bundle'
import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
import { FileSizePipe } from 'src/app/pipes/file-size.pipe'
import { ToastService } from 'src/app/services/toast.service'
import { environment } from 'src/environments/environment'
import { ConfirmDialogComponent } from '../confirm-dialog/confirm-dialog.component'
@@ -35,6 +36,7 @@ import { ConfirmDialogComponent } from '../confirm-dialog/confirm-dialog.compone
export class ShareLinkBundleDialogComponent extends ConfirmDialogComponent {
private readonly formBuilder = inject(FormBuilder)
private readonly clipboard = inject(Clipboard)
private readonly toastService = inject(ToastService)
readonly documents = signal<Document[]>([])
readonly selectionCount = signal(0)
@@ -78,7 +80,6 @@ export class ShareLinkBundleDialogComponent extends ConfirmDialogComponent {
}
this.buttonsEnabled.set(false)
super.confirm()
this.cancelBtnCaption = $localize`Close`
}
getShareUrl(bundle: ShareLinkBundleSummary): string {
@@ -92,6 +93,7 @@ export class ShareLinkBundleDialogComponent extends ConfirmDialogComponent {
const success = this.clipboard.copy(this.getShareUrl(bundle))
if (success) {
this.copied.set(true)
this.toastService.showInfo($localize`Share link copied to clipboard.`)
setTimeout(() => {
this.copied.set(false)
}, 3000)
@@ -1,84 +0,0 @@
import { LOCALE_ID } from '@angular/core'
import { TestBed } from '@angular/core/testing'
import { NgbInputDatepickerConfig } from '@ng-bootstrap/ng-bootstrap'
import { PngxDatePickerConfig } from './ngb-input-date-picker-config'
describe('PngxDatePickerConfig', () => {
const configureLocale = (locale: string) => {
TestBed.configureTestingModule({
providers: [
{ provide: LOCALE_ID, useValue: locale },
{
provide: NgbInputDatepickerConfig,
useClass: PngxDatePickerConfig,
},
],
})
}
it('uses Sunday as the first day of the week for en-US', () => {
configureLocale('en-US')
const config = TestBed.inject(NgbInputDatepickerConfig)
expect(config).toBeInstanceOf(PngxDatePickerConfig)
expect(config.firstDayOfWeek).toEqual(7)
})
it('uses Monday as the first day of the week for de-DE', () => {
configureLocale('de-DE')
const config = TestBed.inject(NgbInputDatepickerConfig)
expect(config.firstDayOfWeek).toEqual(1)
})
it('supports browsers that provide getWeekInfo() and weekInfo', () => {
const getWeekInfo = jest.fn().mockReturnValue({ firstDay: 6 })
const originalDescriptor = Object.getOwnPropertyDescriptor(
Intl.Locale.prototype,
'getWeekInfo'
)
Object.defineProperty(Intl.Locale.prototype, 'getWeekInfo', {
configurable: true,
value: getWeekInfo,
})
let config: NgbInputDatepickerConfig
try {
configureLocale('ar-EG')
config = TestBed.inject(NgbInputDatepickerConfig)
} finally {
if (originalDescriptor) {
Object.defineProperty(
Intl.Locale.prototype,
'getWeekInfo',
originalDescriptor
)
} else {
delete Intl.Locale.prototype['getWeekInfo']
}
}
expect(getWeekInfo).toHaveBeenCalledTimes(1)
expect(config.firstDayOfWeek).toEqual(6)
Object.defineProperty(Intl.Locale.prototype, 'weekInfo', {
configurable: true,
value: { firstDay: 5 },
})
Object.defineProperty(Intl.Locale.prototype, 'getWeekInfo', {
configurable: true,
value: undefined,
})
try {
TestBed.resetTestingModule()
configureLocale('ar-EG')
config = TestBed.inject(NgbInputDatepickerConfig)
} finally {
delete Intl.Locale.prototype['weekInfo']
delete Intl.Locale.prototype['getWeekInfo']
}
expect(config.firstDayOfWeek).toEqual(5)
})
})
@@ -1,20 +0,0 @@
import { inject, Injectable, LOCALE_ID } from '@angular/core'
import { NgbInputDatepickerConfig } from '@ng-bootstrap/ng-bootstrap'
@Injectable()
export class PngxDatePickerConfig extends NgbInputDatepickerConfig {
currentLocale = inject(LOCALE_ID)
constructor() {
super()
const localeInfo = new Intl.Locale(this.currentLocale) as any
let firstDay
if (localeInfo?.getWeekInfo) firstDay = localeInfo.getWeekInfo?.().firstDay
else if (localeInfo?.weekInfo?.firstDay)
firstDay = localeInfo.weekInfo.firstDay
if (firstDay !== undefined) {
this.firstDayOfWeek = firstDay
}
}
}
-3
View File
@@ -18,7 +18,6 @@ import { BrowserModule, bootstrapApplication } from '@angular/platform-browser'
import {
NgbDateAdapter,
NgbDateParserFormatter,
NgbInputDatepickerConfig,
NgbModule,
} from '@ng-bootstrap/ng-bootstrap'
import { NgSelectModule } from '@ng-select/ng-select'
@@ -228,7 +227,6 @@ import { provideUiTour } from 'ngx-ui-tour-ng-bootstrap'
import { CorrespondentNamePipe } from './app/pipes/correspondent-name.pipe'
import { DocumentTypeNamePipe } from './app/pipes/document-type-name.pipe'
import { StoragePathNamePipe } from './app/pipes/storage-path-name.pipe'
import { PngxDatePickerConfig } from './app/utils/ngb-input-date-picker-config'
registerLocaleData(localeAf)
registerLocaleData(localeAr)
@@ -441,7 +439,6 @@ bootstrapApplication(AppComponent, {
CookieService,
FilterPipe,
DocumentTitlePipe,
{ provide: NgbInputDatepickerConfig, useClass: PngxDatePickerConfig },
{ provide: NgbDateAdapter, useClass: ISODateAdapter },
{ provide: NgbDateParserFormatter, useClass: LocalizedDateParserFormatter },
PermissionsGuard,
@@ -598,7 +598,6 @@ class TestDocumentVersioningApi(DirectoriesMixin, APITestCase):
self.assertEqual(input_doc.root_document_id, root.id)
self.assertEqual(input_doc.source, DocumentSource.ApiUpload)
self.assertEqual(overrides.version_label, "New Version")
self.assertEqual(overrides.owner_id, self.user.id)
self.assertEqual(overrides.actor_id, self.user.id)
def test_update_version_with_version_pk_normalizes_to_root(self) -> None:
-1
View File
@@ -2091,7 +2091,6 @@ class DocumentViewSet(
if version_label:
overrides.version_label = version_label.strip()
if request.user is not None:
overrides.owner_id = request.user.id
overrides.actor_id = request.user.id
async_task = consume_file.apply_async(
+97 -47
View File
@@ -4,21 +4,24 @@ from django.conf import settings
from django.contrib.auth.models import User
from documents.models import Document
from documents.permissions import get_objects_for_user_owner_aware
from documents.permissions import permitted_object_ids
from documents.permissions import restrict_queryset_to_visible
from documents.permissions import user_is_unrestricted
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
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 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
@@ -37,6 +40,48 @@ 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:
@@ -136,43 +181,52 @@ def get_taxonomy_context(
user: User | None = None,
max_docs: int = 5,
) -> tuple[TaxonomyCandidates, str]:
"""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.
"""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.
"""
ai_config = AIConfig()
try:
# 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),
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,
)
)
nodes = retrieve_similar_nodes(
document,
top_k=TAXONOMY_CANDIDATE_TOP_K,
document_ids=visible_document_ids,
)
candidates = build_taxonomy_candidates(nodes, user)
candidates = build_taxonomy_candidates(similar_documents, user)
# ``nodes`` are already ordered by descending vector similarity; don't lose it.
similar_document_ids = list(dict.fromkeys(_node_document_ids(nodes)))
# similar_documents is already ordered by descending weight; don't lose it.
similar_document_ids = [s["document_id"] for s in similar_documents]
similar_documents_by_id = Document.objects.in_bulk(similar_document_ids)
similar_docs = [
similar_documents_by_id[document_id]
@@ -186,8 +240,8 @@ def get_taxonomy_context(
context_blocks.append(f"TITLE: {title}\n{text}")
except Exception:
logger.exception(
"Failed to retrieve RAG neighbours for document %s; continuing "
"without taxonomy candidates or similar-document context.",
"Failed to retrieve similar-document context for document %s; "
"continuing without taxonomy candidates or similar-document context.",
document.pk,
)
return empty_taxonomy_candidates(), ""
@@ -241,17 +295,13 @@ def get_ai_document_classification(
) -> ClassificationSuggestions:
ai_config = AIConfig()
if ai_config.llm_embedding_backend:
candidates, 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)
candidates, context = get_taxonomy_context(document, user)
prompt = build_prompt_with_rag(
document,
ai_config,
candidates=candidates,
context=context,
)
client = AIClient()
# Hand the pooled DB connection back while the (slow) LLM query runs so it
+31 -14
View File
@@ -31,6 +31,11 @@ class TaxonomyCandidate(TypedDict):
weight: float
class SimilarDocument(TypedDict):
document_id: int
weight: float
class TaxonomyCandidates(TypedDict):
tags: list[TaxonomyCandidate]
document_types: list[TaxonomyCandidate]
@@ -49,10 +54,10 @@ def empty_taxonomy_candidates() -> TaxonomyCandidates:
)
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)."""
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."""
weights: dict[int, float] = defaultdict(float)
for node in nodes:
document_id = node.metadata.get("document_id")
@@ -65,7 +70,14 @@ def _node_document_weights(nodes: list["NodeWithScore"]) -> dict[int, float]:
weights[int(document_id)] += float(node.score or 0.0)
except (TypeError, ValueError): # pragma: no cover
continue
return weights
return sorted(
(
SimilarDocument(document_id=document_id, weight=weight)
for document_id, weight in weights.items()
),
key=lambda similar: similar["weight"],
reverse=True,
)
def _visible_ranked_candidates(
@@ -101,21 +113,26 @@ def _visible_ranked_candidates(
def build_taxonomy_candidates(
nodes: list["NodeWithScore"],
similar_documents: list[SimilarDocument],
user: User | None,
) -> TaxonomyCandidates:
"""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
"""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
against what ``user`` can see, and return each category ranked by weight
and capped.
and capped. ``similar_documents`` may come from either the vector-RAG
adapter or the full-text fallback adapter - both produce this same shape.
"""
document_weights = _node_document_weights(nodes)
if not document_weights:
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
}
# Only .tags.all() needs prefetching (a reverse M2M, one extra query for
# the whole batch). document_type/correspondent/storage_path are read
# below via their *_id columns (neighbour.document_type_id, etc.), which
+258 -22
View File
@@ -1,4 +1,5 @@
import datetime
from collections.abc import Generator
from types import SimpleNamespace
from unittest.mock import MagicMock
from unittest.mock import patch
@@ -8,16 +9,20 @@ 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 build_localization_prompt
from paperless_ai.ai_classifier import build_prompt_with_rag
from paperless_ai.ai_classifier import build_prompt_without_rag
from paperless_ai.ai_classifier import get_ai_document_classification
from paperless_ai.ai_classifier import get_language_name
from paperless_ai.ai_classifier import get_taxonomy_context
from paperless_ai.taxonomy import SimilarDocument
from paperless_ai.taxonomy import TaxonomyCandidate
from paperless_ai.taxonomy import TaxonomyCandidates
@@ -220,12 +225,10 @@ def test_use_rag_if_configured(
@pytest.mark.django_db
@patch("paperless_ai.client.AIClient.run_llm_query")
@patch("paperless_ai.ai_classifier.build_prompt_without_rag")
@patch("paperless_ai.ai_classifier.AIConfig")
@patch("paperless_ai.ai_classifier.build_prompt_with_rag")
@override_settings(LLM_BACKEND="ollama", LLM_MODEL="some_model")
def test_use_without_rag_if_not_configured(
mock_ai_config,
mock_build_prompt_without_rag,
def test_use_rag_prompt_even_without_embedding_backend(
mock_build_prompt_with_rag,
mock_run_llm_query,
mock_document,
):
@@ -235,13 +238,13 @@ def test_use_without_rag_if_not_configured(
WHEN:
- get_ai_document_classification() is called
THEN:
- The non-RAG prompt builder is used
- The RAG-context prompt builder is still used (fed by the full-text
fallback's context/candidates instead of the vector store's)
"""
mock_ai_config.return_value.llm_embedding_backend = None
mock_build_prompt_without_rag.return_value = "Prompt without RAG"
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_without_rag.assert_called_once()
mock_build_prompt_with_rag.assert_called_once()
@pytest.mark.django_db
@@ -320,6 +323,7 @@ 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:
@@ -354,6 +358,7 @@ 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:
@@ -424,6 +429,7 @@ 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:
@@ -447,6 +453,67 @@ 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
@@ -459,6 +526,7 @@ class TestGetTaxonomyContextVisibility:
"""
@pytest.mark.django_db
@override_settings(LLM_EMBEDDING_BACKEND="huggingface")
def test_skips_permission_lookup_for_superuser(
self,
mocker: pytest_mock.MockerFixture,
@@ -477,17 +545,18 @@ class TestGetTaxonomyContextVisibility:
"paperless_ai.ai_classifier.retrieve_similar_nodes",
return_value=[],
)
mock_get_objects = mocker.patch(
"paperless_ai.ai_classifier.get_objects_for_user_owner_aware",
mock_permitted = mocker.patch(
"paperless_ai.ai_classifier.permitted_object_ids",
)
user = UserFactory.create(is_superuser=True)
get_taxonomy_context(document, user)
mock_get_objects.assert_not_called()
mock_permitted.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,
@@ -506,16 +575,17 @@ class TestGetTaxonomyContextVisibility:
"paperless_ai.ai_classifier.retrieve_similar_nodes",
return_value=[],
)
mock_get_objects = mocker.patch(
"paperless_ai.ai_classifier.get_objects_for_user_owner_aware",
mock_permitted = mocker.patch(
"paperless_ai.ai_classifier.permitted_object_ids",
)
get_taxonomy_context(document, None)
mock_get_objects.assert_not_called()
mock_permitted.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,
@@ -526,7 +596,7 @@ class TestGetTaxonomyContextVisibility:
WHEN:
- get_taxonomy_context() is called
THEN:
- The user's visible document ids are looked up and passed to
- The user's permitted document ids are looked up and passed to
retrieve_similar_nodes() as a restriction
"""
document = DocumentFactory.create(content="Some content")
@@ -534,21 +604,186 @@ class TestGetTaxonomyContextVisibility:
"paperless_ai.ai_classifier.retrieve_similar_nodes",
return_value=[],
)
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,
mock_permitted = mocker.patch(
"paperless_ai.ai_classifier.permitted_object_ids",
return_value=[1, 2, 3],
)
user = UserFactory.create(is_superuser=False)
get_taxonomy_context(document, user)
mock_get_objects.assert_called_once_with(user, "view_document", Document)
mock_permitted.assert_called_once_with(user, Document, "view_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):
"""
@@ -575,6 +810,7 @@ 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(
+33 -31
View File
@@ -1,5 +1,4 @@
import json
from types import SimpleNamespace
import pytest
import pytest_mock
@@ -10,14 +9,14 @@ from documents.tests.factories import DocumentTypeFactory
from documents.tests.factories import StoragePathFactory
from documents.tests.factories import TagFactory
from documents.tests.factories import UserFactory
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
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)
def make_similar(document_id: int, weight: float) -> SimilarDocument:
return SimilarDocument(document_id=document_id, weight=weight)
@pytest.mark.django_db
@@ -53,9 +52,9 @@ class TestBuildTaxonomyCandidates:
doc_a.tags.add(tag)
doc_b = DocumentFactory.create()
doc_b.tags.add(tag)
nodes = [make_node(doc_a.pk, 0.9), make_node(doc_b.pk, 0.4)]
similar_documents = [make_similar(doc_a.pk, 0.9), make_similar(doc_b.pk, 0.4)]
result = build_taxonomy_candidates(nodes, user=None)
result = build_taxonomy_candidates(similar_documents, user=None)
assert len(result["tags"]) == 1
assert result["tags"][0]["id"] == tag.pk
@@ -80,9 +79,9 @@ class TestBuildTaxonomyCandidates:
document.tags.add(tag)
tag.name = "New Name"
tag.save()
nodes = [make_node(document.pk, 0.5)]
similar_documents = [make_similar(document.pk, 0.5)]
result = build_taxonomy_candidates(nodes, user=None)
result = build_taxonomy_candidates(similar_documents, user=None)
assert result["tags"][0]["name"] == "New Name"
@@ -102,9 +101,9 @@ class TestBuildTaxonomyCandidates:
document = DocumentFactory.create()
document.tags.add(tag)
tag.delete()
nodes = [make_node(document.pk, 0.5)]
similar_documents = [make_similar(document.pk, 0.5)]
result = build_taxonomy_candidates(nodes, user=None)
result = build_taxonomy_candidates(similar_documents, user=None)
assert result["tags"] == []
@@ -123,9 +122,12 @@ class TestBuildTaxonomyCandidates:
strong_doc.tags.add(strong_tag)
weak_doc = DocumentFactory.create()
weak_doc.tags.add(weak_tag)
nodes = [make_node(strong_doc.pk, 0.9), make_node(weak_doc.pk, 0.1)]
similar_documents = [
make_similar(strong_doc.pk, 0.9),
make_similar(weak_doc.pk, 0.1),
]
result = build_taxonomy_candidates(nodes, user=None)
result = build_taxonomy_candidates(similar_documents, user=None)
assert [c["name"] for c in result["tags"]] == ["Strong", "Weak"]
@@ -141,9 +143,9 @@ class TestBuildTaxonomyCandidates:
document = DocumentFactory.create()
for i in range(15):
document.tags.add(TagFactory.create(name=f"Tag{i}"))
nodes = [make_node(document.pk, 0.5)]
similar_documents = [make_similar(document.pk, 0.5)]
result = build_taxonomy_candidates(nodes, user=None)
result = build_taxonomy_candidates(similar_documents, user=None)
assert len(result["tags"]) == 10
@@ -157,12 +159,12 @@ class TestBuildTaxonomyCandidates:
- Only 5 correspondents are returned
"""
correspondents = CorrespondentFactory.create_batch(7)
nodes = [
make_node(DocumentFactory.create(correspondent=c).pk, 0.5)
similar_documents = [
make_similar(DocumentFactory.create(correspondent=c).pk, 0.5)
for c in correspondents
]
result = build_taxonomy_candidates(nodes, user=None)
result = build_taxonomy_candidates(similar_documents, user=None)
assert len(result["correspondents"]) == 5
@@ -177,9 +179,9 @@ class TestBuildTaxonomyCandidates:
"""
document_type = DocumentTypeFactory.create(name="Invoice")
document = DocumentFactory.create(document_type=document_type)
nodes = [make_node(document.pk, 0.5)]
similar_documents = [make_similar(document.pk, 0.5)]
result = build_taxonomy_candidates(nodes, user=None)
result = build_taxonomy_candidates(similar_documents, user=None)
assert len(result["document_types"]) == 1
assert result["document_types"][0]["id"] == document_type.pk
@@ -195,12 +197,12 @@ class TestBuildTaxonomyCandidates:
- Only 5 document_types are returned
"""
document_types = DocumentTypeFactory.create_batch(7)
nodes = [
make_node(DocumentFactory.create(document_type=dt).pk, 0.5)
similar_documents = [
make_similar(DocumentFactory.create(document_type=dt).pk, 0.5)
for dt in document_types
]
result = build_taxonomy_candidates(nodes, user=None)
result = build_taxonomy_candidates(similar_documents, user=None)
assert len(result["document_types"]) == 5
@@ -215,9 +217,9 @@ class TestBuildTaxonomyCandidates:
"""
storage_path = StoragePathFactory.create(name="Invoices")
document = DocumentFactory.create(storage_path=storage_path)
nodes = [make_node(document.pk, 0.5)]
similar_documents = [make_similar(document.pk, 0.5)]
result = build_taxonomy_candidates(nodes, user=None)
result = build_taxonomy_candidates(similar_documents, user=None)
assert len(result["storage_paths"]) == 1
assert result["storage_paths"][0]["id"] == storage_path.pk
@@ -233,12 +235,12 @@ class TestBuildTaxonomyCandidates:
- Only 5 storage_paths are returned
"""
storage_paths = StoragePathFactory.create_batch(7)
nodes = [
make_node(DocumentFactory.create(storage_path=sp).pk, 0.5)
similar_documents = [
make_similar(DocumentFactory.create(storage_path=sp).pk, 0.5)
for sp in storage_paths
]
result = build_taxonomy_candidates(nodes, user=None)
result = build_taxonomy_candidates(similar_documents, user=None)
assert len(result["storage_paths"]) == 5
@@ -258,14 +260,14 @@ class TestBuildTaxonomyCandidates:
tag = TagFactory.create(name="Restricted")
document = DocumentFactory.create()
document.tags.add(tag)
nodes = [make_node(document.pk, 0.5)]
similar_documents = [make_similar(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(nodes, user=user)
result = build_taxonomy_candidates(similar_documents, user=user)
assert result["tags"] == []
@@ -295,10 +297,10 @@ class TestBuildTaxonomyCandidates:
tag.save()
document = DocumentFactory.create()
document.tags.add(tag)
nodes = [make_node(document.pk, 0.5)]
similar_documents = [make_similar(document.pk, 0.5)]
spy = mocker.patch("documents.permissions.permitted_object_ids")
result = build_taxonomy_candidates(nodes, user=None)
result = build_taxonomy_candidates(similar_documents, user=None)
assert result["tags"][0]["name"] == "Owned"
spy.assert_not_called()