Compare commits

...
4 changed files with 112 additions and 8 deletions

No files matched your search

+11 -1
View File
@@ -31,9 +31,19 @@ body:
value: | value: |
#### 🤖 Use of AI tools #### 🤖 Use of AI tools
If this report was generated, in whole or in part, by an AI tool or agent you **must** clearly say so in the description below. Failing to clearly disclose this is a violation of our [Code of Conduct](https://github.com/paperless-ngx/paperless-ngx/blob/main/CODE_OF_CONDUCT.md). If this report was generated, in whole or in part, by an AI tool or agent you **must** say so using the field below. Failing to clearly disclose this is a violation of our [Code of Conduct](https://github.com/paperless-ngx/paperless-ngx/blob/main/CODE_OF_CONDUCT.md).
Reports written by an AI agent must describe the _observed behavior only_: what happened, how to reproduce it, logs etc. Do **not** include code analysis, speculation about the root cause, or suggest code fixes. Reports written by an AI agent must describe the _observed behavior only_: what happened, how to reproduce it, logs etc. Do **not** include code analysis, speculation about the root cause, or suggest code fixes.
- type: dropdown
id: ai-usage
attributes:
label: AI usage
description: Was any part of this report written by an AI tool or agent?
options:
- No AI tools were used
- Yes, AI tools helped write this report
validations:
required: true
- type: textarea - type: textarea
id: description id: description
attributes: attributes:
+21 -5
View File
@@ -43,6 +43,12 @@ jobs:
const haystack = `${issue.title}\n${issue.body ?? ''}`; const haystack = `${issue.title}\n${issue.body ?? ''}`;
const honeypot = haystack.toUpperCase().includes('ASLOP-PR-VERIFY'); const honeypot = haystack.toUpperCase().includes('ASLOP-PR-VERIFY');
// The form's required "AI usage" dropdown renders as a `### AI usage` section. An agent
// that follows the template trips the honeypot even when it discloses, so only treat
// the honeypot as undisclosed AI use when the reporter did not answer "Yes" here.
const aiUsage = (issue.body ?? '').match(/^###\s+AI usage\s*\n+\s*(.+?)\s*$/m)?.[1] ?? '';
const disclosedAI = aiUsage.toLowerCase().startsWith('yes');
// Issues opened through the form always get the template's default labels. GitHub // 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 // 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. // the webhook payload, and retry before concluding that there are none.
@@ -55,19 +61,29 @@ jobs:
if (labels.some((label) => templateLabels.includes(label))) break; if (labels.some((label) => templateLabels.includes(label))) break;
} }
const bypassedTemplate = !labels.some((label) => templateLabels.includes(label)); const bypassedTemplate = !labels.some((label) => templateLabels.includes(label));
core.info(`Labels: [${labels.join(', ')}], honeypot: ${honeypot}, bypassed template: ${bypassedTemplate}`); core.info(
`Labels: [${labels.join(', ')}], honeypot: ${honeypot}, AI usage: "${aiUsage}", ` +
`bypassed template: ${bypassedTemplate}`
);
if (!honeypot && !bypassedTemplate) { if (!bypassedTemplate && (disclosedAI || !honeypot)) {
core.info('No slop signals found'); if (disclosedAI) {
core.info('AI use disclosed, labeling only');
await github.rest.issues.addLabels({ ...common, labels: ['ai'] });
} else {
core.info('No slop signals found');
}
return; return;
} }
// The honeypot is only ever tripped deliberately, so that message can name the cause. // Reaching here with the honeypot means AI usage was not disclosed in the form, so that
// message can name the cause.
// A missing template label only tells us the issue did not come from the form, which // 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. // is reason enough to close it, but not proof of how it was written.
const body = honeypot const body = honeypot
? 'This issue was automatically closed because it contains a marker that is only visible to ' + ? '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' + 'automated tools, which indicates it was generated by an AI agent, but the "AI usage" field does not ' +
'disclose this.\n\n' +
`Please see our [contributing guidelines](${contributing}) and [Code of Conduct](${codeOfConduct}). ` + `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.' '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. ' + : 'This issue was automatically closed because it was not opened using our bug report form. ' +
@@ -514,8 +514,11 @@ class Command(CryptMixin, PaperlessCommand):
else: else:
base_name = document.get_public_filename(counter=filename_counter) base_name = document.get_public_filename(counter=filename_counter)
if base_name not in self.exported_files: # The thumbnail and archive names are derived from the stem, so
self.exported_files.add(base_name) # uniqueness must hold without the original's extension
unique_key = str(Path(base_name).with_suffix(""))
if unique_key not in self.exported_files:
self.exported_files.add(unique_key)
break break
else: else:
filename_counter += 1 filename_counter += 1
@@ -2,6 +2,7 @@ import hashlib
import json import json
import shutil import shutil
import tempfile import tempfile
from datetime import date
from datetime import timedelta from datetime import timedelta
from io import StringIO from io import StringIO
from pathlib import Path from pathlib import Path
@@ -44,14 +45,18 @@ from documents.models import Workflow
from documents.models import WorkflowAction from documents.models import WorkflowAction
from documents.models import WorkflowTrigger from documents.models import WorkflowTrigger
from documents.sanity_checker import check_sanity from documents.sanity_checker import check_sanity
from documents.settings import EXPORTER_ARCHIVE_NAME
from documents.settings import EXPORTER_FILE_NAME from documents.settings import EXPORTER_FILE_NAME
from documents.settings import EXPORTER_SHARE_LINK_BUNDLE_NAME from documents.settings import EXPORTER_SHARE_LINK_BUNDLE_NAME
from documents.settings import EXPORTER_THUMBNAIL_NAME
from documents.tests.utils import SampleDirMixin from documents.tests.utils import SampleDirMixin
from paperless_mail.models import MailAccount from paperless_mail.models import MailAccount
from paperless_testing.assertions import FileSystemAssertsMixin from paperless_testing.assertions import FileSystemAssertsMixin
from paperless_testing.dirs import DirectoriesMixin from paperless_testing.dirs import DirectoriesMixin
from paperless_testing.dirs import PaperlessDirs
from paperless_testing.dirs import paperless_environment from paperless_testing.dirs import paperless_environment
from paperless_testing.factories import DocumentBarcodeFactory from paperless_testing.factories import DocumentBarcodeFactory
from paperless_testing.factories import DocumentFactory
from paperless_testing.permissions import grant_object from paperless_testing.permissions import grant_object
@@ -1462,3 +1467,73 @@ class TestCryptExportImport(
("No passphrase was given, sensitive fields will be in plaintext"), ("No passphrase was given, sensitive fields will be in plaintext"),
stdout.read(), stdout.read(),
) )
@pytest.mark.management
@pytest.mark.django_db
class TestExportDerivedFileNameCollisions:
def test_same_stem_different_extension_get_distinct_derived_files(
self,
paperless_dirs: PaperlessDirs,
tmp_path: Path,
) -> None:
"""
GIVEN:
- Two documents with the same title and created date
- One original is a PDF, the other a PNG, both with archive and thumbnail
WHEN:
- The exporter runs
THEN:
- Each document is exported under a distinct original, thumbnail and archive name
- Each exported file holds its own document's content
"""
for pk, (filename, mime_type) in enumerate(
[("collide.pdf", "application/pdf"), ("collide.png", "image/png")],
start=1,
):
(paperless_dirs.originals_dir / filename).write_bytes(
f"original {pk}".encode(),
)
(paperless_dirs.archive_dir / f"{pk:07}.pdf").write_bytes(
f"archive {pk}".encode(),
)
(paperless_dirs.thumbnail_dir / f"{pk:07}.webp").write_bytes(
f"thumbnail {pk}".encode(),
)
DocumentFactory(
pk=pk,
title="collide",
created=date(2026, 10, 7),
filename=filename,
archive_filename=f"{pk:07}.pdf",
mime_type=mime_type,
)
target = tmp_path / "export"
target.mkdir()
call_command(
"document_exporter",
str(target),
"--no-progress-bar",
skip_checks=True,
)
manifest = json.loads((target / "manifest.json").read_text())
records = {r["pk"]: r for r in manifest if r["model"] == "documents.document"}
for key in (
EXPORTER_FILE_NAME,
EXPORTER_THUMBNAIL_NAME,
EXPORTER_ARCHIVE_NAME,
):
assert records[1][key] != records[2][key]
for pk in (1, 2):
record = records[pk]
assert (target / record[EXPORTER_FILE_NAME]).read_text() == f"original {pk}"
assert (
target / record[EXPORTER_THUMBNAIL_NAME]
).read_text() == f"thumbnail {pk}"
assert (
target / record[EXPORTER_ARCHIVE_NAME]
).read_text() == f"archive {pk}"