mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-08-10 21:03:18 +00:00
Docs: make chat unbounded-scan plan self-contained
Inline the bug diagnosis into a Background section and drop references to the now-deleted CHAT_UNBOUNDED_DOCUMENT_SCAN.md and the throwaway worktree, so the plan can be executed on its own. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
d27be0839c
commit
3f37f49dd0
@@ -2,48 +2,77 @@
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Revision note (2026-08-08):** This plan was originally written 2026-07-26 against an older
|
||||
version of `views.py`/`chat.py`. Both files have since changed (the `permitted_document_ids`
|
||||
perf refactor landed, and `stream_chat_with_documents` grew an `output_language` parameter).
|
||||
The bug itself is unchanged and still present; this revision updates line numbers, signatures,
|
||||
and code snippets to match the code as of commit `fc242bb57` (current `dev` tip). Re-verify
|
||||
line numbers again before implementing if more commits have landed on `chat.py` or `views.py`
|
||||
in the meantime.
|
||||
|
||||
**Existing partial work (found 2026-08-09):** a worktree already exists at
|
||||
`.claude/worktrees/chat-unbounded-scan` on branch `worktree-chat-unbounded-scan`, with one
|
||||
commit: `085040f18 Test: rewrite chat tests to use QuerySets, add bounded-lookup regression
|
||||
test` -- this is Task 1 below, already done, and its diff matches this plan's Task 1 almost
|
||||
exactly. However that worktree branched off `dev` **before** the `permitted_document_ids` perf
|
||||
refactor landed, so its copy of `views.py` (and possibly other files) predates the current
|
||||
`dev` tip and will conflict if merged as-is. Before starting Task 1, check whether to
|
||||
`git rebase dev` that worktree branch (or cherry-pick just `085040f18`'s `test_chat.py` change
|
||||
onto a fresh branch off current `dev`) rather than redoing the work from scratch -- do not
|
||||
blindly re-implement Task 1 without first inspecting whether the existing commit can be
|
||||
reused.
|
||||
|
||||
**Benchmark/profiling status:** none exists yet for this specific bug. The original
|
||||
`CHAT_UNBOUNDED_DOCUMENT_SCAN.md` explicitly called for benchmarking (query count + wall time,
|
||||
scaling with library size) before designing the fix, matching the methodology used for the
|
||||
sibling `perf/13314-*` branches -- this was missing from the task breakdown below until this
|
||||
revision. See new **Task 0** and the addition to **Task 4**.
|
||||
|
||||
**Goal:** Stop `ChatStreamingView`'s "chat with my whole archive" path from materializing every accessible `Document` into Python memory on every chat message; bound the cost to the vector-store `IN`-filter id list plus at most `CHAT_RETRIEVER_TOP_K` (5) documents for the reference/permission lookup.
|
||||
|
||||
**Architecture:** Change `documents` from a materialized `list[Document]` to a lazy `QuerySet[Document]` threaded through `ChatStreamingView.post` -> `stream_chat_with_documents` -> `_stream_chat_with_documents` -> `_get_document_references`. Build the vector-store `IN` filter from `documents.values_list("pk", flat=True)` (ids only, no row hydration) instead of iterating full `Document` instances. Reorder `_get_document_references` to run `retriever.retrieve()` first, then permission-check/hydrate only the (≤5) documents that `top_nodes` actually reference via `documents.filter(pk__in=candidate_ids)`, instead of hydrating every accessible document up front.
|
||||
|
||||
**Tech Stack:** Django ORM (QuerySet), llama-index (`MetadataFilters`, `VectorIndexRetriever`), pytest + pytest-django.
|
||||
|
||||
## Background
|
||||
|
||||
`ChatStreamingView.post` (`src/documents/views.py`), when the request has no `document_id`
|
||||
(i.e. "chat with my whole archive" rather than "chat with this one document"), builds a
|
||||
`QuerySet` of every `Document` the requesting user is permitted to view and passes it straight
|
||||
into `stream_chat_with_documents(query_str, documents)`
|
||||
(`src/paperless_ai/chat.py`), which calls into `_stream_chat_with_documents`. Two places there
|
||||
force-materialize the entire queryset into Python objects, on **every single chat message**:
|
||||
|
||||
1. `_document_id_filters(str(doc.pk) for doc in documents)` -- iterates every accessible
|
||||
document just to build a `MetadataFilter(key="document_id", operator=IN,
|
||||
value=sorted(doc_ids))` for the vector-store query.
|
||||
2. `_get_document_references`'s `allowed_documents = {doc.pk: doc for doc in documents}` --
|
||||
hydrates every accessible `Document` row into a dict, just to look up at most
|
||||
`MAX_CHAT_REFERENCES = 3` of them later.
|
||||
|
||||
Meanwhile the actual retrieval only ever wants `CHAT_RETRIEVER_TOP_K = 5` nodes, and shows at
|
||||
most 3 references. So the cost of _every_ chat message -- not a background job, an interactive
|
||||
request a user is staring at a spinner for -- scales with total accessible-document count, not
|
||||
with the ~5 documents that actually matter to the answer. This is worse than an equivalent
|
||||
scan in a background Celery task: a user is waiting on it in real time, on every message, and
|
||||
the cost grows as the library grows regardless of how good or bad the actual answer needs to
|
||||
be.
|
||||
|
||||
**What this plan fixes (and what it deliberately doesn't):**
|
||||
|
||||
1. Stop materializing full `Document` rows for the filter step -- `_document_id_filters` only
|
||||
needs a list of ids, not hydrated rows (Task 2, Step 3).
|
||||
2. Stop permission-checking/hydrating the whole accessible set before knowing which documents
|
||||
were even retrieved -- flip the order so retrieval happens first (bounded by
|
||||
`CHAT_RETRIEVER_TOP_K = 5`), then permission-check only those results (Task 2, Step 4). The
|
||||
permission check itself is unchanged in substance -- a document is only surfaced if it's in
|
||||
the caller's permission-scoped queryset -- only its timing and the amount of data it touches
|
||||
change.
|
||||
3. **Out of scope:** the vector-store-side `IN (...)` filter still needs the full list of
|
||||
accessible document ids to constrain the KNN search to permitted documents -- that's
|
||||
inherent to "chat with my whole (permitted) archive" and can't be avoided by filtering after
|
||||
the fact (doing so would leak un-permitted document content into the LLM context). Whether
|
||||
that `IN`-list itself is a performance problem for the vector store at very large scale is a
|
||||
separate, unimplemented investigation and is explicitly not addressed by this plan.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Backend lint/format: ruff, line length 88, double quotes, single-line isort imports (from `CLAUDE.md`).
|
||||
- Type checking: mypy + pyrefly; do not introduce new violations beyond the frozen baseline (`.mypy-baseline.txt`, `.pyrefly-baseline.json`).
|
||||
- Tests: pytest/pytest-django; match the style of the file being edited (`src/paperless_ai/tests/test_chat.py` is already idiomatic pytest with fixtures).
|
||||
- The existing permission check semantics MUST be preserved exactly: a document referenced by a retrieved node is only surfaced/cited if it is in the caller's permission-scoped `documents` queryset. No behavior change to what a user is allowed to see, only to when/how much is loaded to check it.
|
||||
- Preserve `output_language` threading through `stream_chat_with_documents` / `_stream_chat_with_documents` unchanged -- it is unrelated to this fix but must not be dropped by a careless signature rewrite.
|
||||
- Do not touch the vector-store-side `IN (...)` filter question (see Background, point 3) -- out of scope for this plan.
|
||||
|
||||
**Suggested delegation (Claude Code `Agent` tool `subagent_type` + model tier):**
|
||||
|
||||
- Task 0 (benchmark baseline -- open-ended: choosing a harness, interpreting numbers, deciding what "proves the bug" means): `python-pro` or `django-developer` at **Sonnet** tier. Not mechanical enough for Haiku -- it requires judgment about what to measure and whether the resulting numbers actually support the claimed scaling behavior, and it's the evidence the rest of the plan's justification rests on.
|
||||
- Task 1 (test rewrite -- mechanical: swap list literals for querysets/MagicMocks per the exact snippets already written out in this plan): `django-developer` at **Haiku** tier. The transformations are fully specified here (copy-paste-adjacent), so a fast/cheap model is sufficient; escalate to Sonnet only if the agent reports the current file has drifted from what this plan quotes. Before dispatching, check whether `.claude/worktrees/chat-unbounded-scan` commit `085040f18` can be rebased/cherry-picked instead of redone (see "Existing partial work" note above).
|
||||
- Task 1 (test rewrite -- mechanical: swap list literals for querysets/MagicMocks per the exact snippets already written out in this plan): `django-developer` at **Haiku** tier. The transformations are fully specified here (copy-paste-adjacent), so a fast/cheap model is sufficient; escalate to Sonnet only if the agent reports the current file has drifted from what this plan quotes.
|
||||
- Task 2 (`chat.py` rework -- the actual bug fix, changes runtime permission-check ordering): `django-developer` at **Sonnet** tier (or whatever the session's default is). This is the correctness-sensitive core of the change -- worth the stronger model even though the code is also fully specified, because a subtle mistake here (e.g. querying `documents` before `.filter(pk__in=...)` narrows it) reintroduces the exact bug being fixed.
|
||||
- Task 3 (`views.py` one-line change + locating/running the right view tests): `django-developer` at **Haiku** tier for the one-line edit; if the test-discovery grep in Step 2 turns up ambiguity (unlike Task 1, this step isn't fully pre-solved -- see the verification note below naming `test_permission_filtering_security.py`), let it escalate or hand off rather than guessing.
|
||||
- Task 4 (full verification, lint/type baselines, doc status update, before/after benchmark comparison): a `code-reviewer` subagent (or the `code-review` skill) at **Sonnet** tier or above for the correctness/permission-scoping review, paired with whichever agent ran Task 0 (same one, if possible, so it can compare against numbers it already understands) for the benchmark re-run in Step 0. Not a good candidate for Haiku -- both the permission-scoping check and the benchmark interpretation require judgment.
|
||||
- Task 3 (`views.py` one-line change + locating/running the right view tests): `django-developer` at **Haiku** tier for the one-line edit; if the test-discovery grep in Step 2 turns up ambiguity, let it escalate or hand off rather than guessing.
|
||||
- Task 4 (full verification, lint/type baselines, before/after benchmark comparison): a `code-reviewer` subagent (or the `code-review` skill) at **Sonnet** tier or above for the correctness/permission-scoping review, paired with whichever agent ran Task 0 (same one, if possible, so it can compare against numbers it already understands) for the benchmark re-run in Step 0. Not a good candidate for Haiku -- both the permission-scoping check and the benchmark interpretation require judgment.
|
||||
- Use `superpowers:subagent-driven-development` to run Tasks 0-3 as independent-but-ordered subagent dispatches with review checkpoints between them, per this plan's header.
|
||||
|
||||
## Current code (as of `fc242bb57`, for reference while implementing)
|
||||
---
|
||||
|
||||
## Current code (as of `dev` commit `fc242bb57`, for reference while implementing)
|
||||
|
||||
Re-verify these line numbers against the live files before editing -- they will drift as other
|
||||
work lands on `dev`.
|
||||
|
||||
`src/documents/views.py:2245-2286` (`ChatStreamingView.post`):
|
||||
|
||||
@@ -93,12 +122,14 @@ class ChatStreamingView(GenericAPIView[Any]):
|
||||
```
|
||||
|
||||
Note: the whole-library `else` branch already returns a `QuerySet` (`permitted_document_ids`
|
||||
returns a lazy `QuerySet[int]` per `documents/permissions.py:223-237`) — the bug is entirely
|
||||
returns a lazy `QuerySet[int]`, see `src/documents/permissions.py`) -- the bug is entirely
|
||||
inside `chat.py`, which force-materializes it. Only the single-document `if` branch needs to
|
||||
change (`[document]` -> a one-row `QuerySet`), purely so both branches share the same type.
|
||||
|
||||
`src/paperless_ai/chat.py:84-176` (`_get_document_references`, `stream_chat_with_documents`,
|
||||
`_stream_chat_with_documents`):
|
||||
`src/paperless_ai/chat.py` (`_get_document_references`, `stream_chat_with_documents`,
|
||||
`_stream_chat_with_documents` -- abridged excerpt, elisions and inline comments below are
|
||||
annotations for this plan, not literal source; re-read the live file rather than treating this
|
||||
as a copy-paste-ready contiguous block):
|
||||
|
||||
```python
|
||||
def _get_document_references(
|
||||
@@ -139,25 +170,8 @@ def _stream_chat_with_documents(
|
||||
references = _get_document_references(documents, top_nodes)
|
||||
```
|
||||
|
||||
All three signatures need to carry `output_language: str | None = None` through unchanged —
|
||||
this parameter did not exist when the plan was first drafted; do not drop it.
|
||||
|
||||
(Note: the excerpt above is abridged for readability — elisions (`...`) and inline
|
||||
`# <-- full materialization` comments are annotations added for this plan, not literal source.
|
||||
`_stream_chat_with_documents` actually continues to line 209 in the real file. Each quoted line
|
||||
does match the source verbatim; re-read the live file rather than treating this block as a
|
||||
copy-paste-ready contiguous excerpt.)
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Backend lint/format: ruff, line length 88, double quotes, single-line isort imports (from `CLAUDE.md`).
|
||||
- Type checking: mypy + pyrefly; do not introduce new violations beyond the frozen baseline (`.mypy-baseline.txt`, `.pyrefly-baseline.json`).
|
||||
- Tests: pytest/pytest-django; match the style of the file being edited (`src/paperless_ai/tests/test_chat.py` is already idiomatic pytest with fixtures).
|
||||
- The existing permission check semantics MUST be preserved exactly: a document referenced by a retrieved node is only surfaced/cited if it is in the caller's permission-scoped `documents` queryset. No behavior change to what a user is allowed to see, only to when/how much is loaded to check it.
|
||||
- Preserve `output_language` threading through all three functions — it is unrelated to this fix but must not be dropped by a careless signature rewrite.
|
||||
- Do not touch the vector-store-side `IN (...)` filter question (whether a large id list is itself a KNN scaling concern) -- that is flagged as a separate, unimplemented follow-up in `CHAT_UNBOUNDED_DOCUMENT_SCAN.md` and is out of scope for this plan.
|
||||
|
||||
---
|
||||
All three signatures need to carry `output_language: str | None = None` through unchanged --
|
||||
this parameter is unrelated to the fix but must not be dropped.
|
||||
|
||||
## File Structure
|
||||
|
||||
@@ -184,7 +198,7 @@ copy-paste-ready contiguous excerpt.)
|
||||
|
||||
Seed libraries at a few sizes (e.g. 10, 100, 1000 documents) via
|
||||
`DocumentFactory.create_batch(n)` (see `src/documents/tests/factories.py`), matching the
|
||||
pattern already used in this file's own `test_get_document_references_only_queries_referenced_documents`
|
||||
pattern already used in this plan's own `test_get_document_references_only_queries_referenced_documents`
|
||||
test (Task 1, Step 3) which seeds 200. Wrap the call path in Django's
|
||||
`django.test.utils.CaptureQueriesContext` (or the `django_assert_num_queries` fixture for a
|
||||
fixed expected count, but here you want the _actual_ count at each size, not just an
|
||||
@@ -192,13 +206,13 @@ assertion) plus `time.perf_counter()` for wall time. `src/profiling.py`'s `profi
|
||||
context manager already bundles both (query count/time + memory) if you'd rather reuse it
|
||||
than hand-roll `CaptureQueriesContext`.
|
||||
|
||||
- [ ] **Step 2: Run the benchmark against the two hot spots identified in `CHAT_UNBOUNDED_DOCUMENT_SCAN.md`**
|
||||
- [ ] **Step 2: Run the benchmark against the two hot spots described in Background**
|
||||
|
||||
Specifically measure, at each library size:
|
||||
|
||||
1. `_document_id_filters(str(doc.pk) for doc in documents)` (`chat.py:151`) -- the filter-list
|
||||
1. `_document_id_filters(str(doc.pk) for doc in documents)` (`chat.py`) -- the filter-list
|
||||
build.
|
||||
2. `_get_document_references(documents, top_nodes)` (`chat.py:84-110`) -- the reference
|
||||
2. `_get_document_references(documents, top_nodes)` (`chat.py`) -- the reference
|
||||
lookup, with `top_nodes` fixed at a small constant (e.g. 1-3 nodes) regardless of library
|
||||
size, to isolate the effect of accessible-library size on this specific function (this is
|
||||
the function the fix changes the most).
|
||||
@@ -240,9 +254,9 @@ just keep the recorded numbers from Step 3.
|
||||
|
||||
- [ ] **Step 1: Replace list-based `documents` fixtures with `QuerySet`-shaped values**
|
||||
|
||||
In `src/paperless_ai/tests/test_chat.py`, the `mock_document` fixture (line 39-46) is a
|
||||
In `src/paperless_ai/tests/test_chat.py`, the `mock_document` fixture (around line 39-46) is a
|
||||
`MagicMock`, not a real row, so it cannot be used with a real `QuerySet.filter(pk=...)`
|
||||
lookup. Replace its use in `test_stream_chat_with_one_document_retrieval` (line 108) with a
|
||||
lookup. Replace its use in `test_stream_chat_with_one_document_retrieval` with a
|
||||
real `DocumentFactory.create()` instance and pass `Document.objects.filter(pk=document.pk)`:
|
||||
|
||||
```python
|
||||
@@ -267,12 +281,13 @@ def test_stream_chat_with_one_document_retrieval(patch_embed_nodes) -> None:
|
||||
mock_client_cls.return_value = mock_client
|
||||
mock_client.llm = MagicMock()
|
||||
|
||||
mock_node = TextNode(
|
||||
text="This is node content.",
|
||||
metadata={"document_id": str(document.pk), "title": "Test Document"},
|
||||
)
|
||||
mock_index = MagicMock()
|
||||
mock_index.vector_store.get_nodes.return_value = [mock_node]
|
||||
mock_index.vector_store.get_nodes.return_value = [
|
||||
TextNode(
|
||||
text="This is node content.",
|
||||
metadata={"document_id": str(document.pk), "title": "Test Document"},
|
||||
),
|
||||
]
|
||||
mock_load_index.return_value = mock_index
|
||||
|
||||
mock_retriever_instance = MagicMock()
|
||||
@@ -313,16 +328,16 @@ def test_stream_chat_with_one_document_retrieval(patch_embed_nodes) -> None:
|
||||
Remove the `mock_document` fixture only if nothing else in the file still uses it (check with
|
||||
`rg -n "mock_document" src/paperless_ai/tests/test_chat.py` after this step).
|
||||
|
||||
Apply the equivalent change to `test_stream_chat_with_multiple_documents_retrieval` (line 174):
|
||||
Apply the equivalent change to `test_stream_chat_with_multiple_documents_retrieval`:
|
||||
replace `doc1 = MagicMock(pk=1, ...)` / `doc2 = MagicMock(pk=2, ...)` with two
|
||||
`DocumentFactory.create(...)` instances, and pass
|
||||
`documents = Document.objects.filter(pk__in=[doc1.pk, doc2.pk])` to
|
||||
`stream_chat_with_documents`. Update the node/reference metadata to use the real created pks
|
||||
instead of hardcoded `"1"`/`"2"`.
|
||||
|
||||
For the three non-DB tests (`test_stream_chat_empty_document_list` line 234,
|
||||
`test_stream_chat_no_matching_nodes` line 241,
|
||||
`test_stream_chat_unexpected_failure_returns_generic_error` line 261), replace the list
|
||||
For the three non-DB tests (`test_stream_chat_empty_document_list`,
|
||||
`test_stream_chat_no_matching_nodes`,
|
||||
`test_stream_chat_unexpected_failure_returns_generic_error`), replace the list
|
||||
arguments with values that behave like an (unevaluated) `QuerySet` without touching the
|
||||
database:
|
||||
|
||||
@@ -358,9 +373,11 @@ Add this helper near the top of the file (after `assert_chat_output`) and use
|
||||
|
||||
Add the necessary import: `from documents.models import Document` at the top of the file.
|
||||
|
||||
- [ ] **Step 2: Rewrite `test_no_nodes_yields_no_content_message` and `test_chat_filter_contains_only_requested_document_ids` (in `TestStreamChatRetrieval`, line 292) to pass a QuerySet**
|
||||
- [ ] **Step 2: Rewrite the two `TestStreamChatRetrieval` tests to pass a QuerySet**
|
||||
|
||||
Both already use real `DocumentFactory` documents and `django_db`. Change the calls:
|
||||
Both `test_no_nodes_yields_no_content_message` and
|
||||
`test_chat_filter_contains_only_requested_document_ids` (in class `TestStreamChatRetrieval`)
|
||||
already use real `DocumentFactory` documents and `django_db`. Change the calls:
|
||||
|
||||
```python
|
||||
out = list(chat.stream_chat_with_documents("question?", Document.objects.filter(pk=doc.pk)))
|
||||
@@ -374,7 +391,7 @@ list(chat.stream_chat_with_documents("question?", Document.objects.filter(pk=inc
|
||||
|
||||
Add a new test proving `_get_document_references` only touches documents that `top_nodes`
|
||||
actually reference, not every document in the passed queryset. This is the direct regression
|
||||
test for the bug described in `CHAT_UNBOUNDED_DOCUMENT_SCAN.md`:
|
||||
test for the bug described in this plan's Background section:
|
||||
|
||||
```python
|
||||
@pytest.mark.django_db
|
||||
@@ -469,8 +486,8 @@ short-circuits without hitting the database at all.)
|
||||
```
|
||||
|
||||
This still touches every accessible document's id (inherent to scoping the vector-store `IN`
|
||||
filter to the permitted set -- see `CHAT_UNBOUNDED_DOCUMENT_SCAN.md`'s point 3, which remains
|
||||
out of scope), but no longer loads full `Document` rows -- just a flat list of integers.
|
||||
filter to the permitted set -- see Background, point 3, which remains out of scope), but no
|
||||
longer loads full `Document` rows -- just a flat list of integers.
|
||||
|
||||
- [ ] **Step 4: Rework `_get_document_references` to hydrate only referenced documents**
|
||||
|
||||
@@ -541,7 +558,7 @@ git commit -m "Fix: bound chat document reference lookup to retrieved nodes inst
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/documents/views.py` (`ChatStreamingView.post`, currently lines 2245-2286 -- re-check with `rg -n "class ChatStreamingView" src/documents/views.py` before editing, in case other changes shifted it)
|
||||
- Modify: `src/documents/views.py` (`ChatStreamingView.post` -- re-locate with `rg -n "class ChatStreamingView" src/documents/views.py` before editing, in case other changes shifted it)
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
@@ -564,24 +581,23 @@ changes inside `chat.py` are what stop that queryset from being force-materializ
|
||||
|
||||
- [ ] **Step 2: Run the view tests**
|
||||
|
||||
Three test locations cover this view (confirmed by an independent verification pass, see
|
||||
plan revision note at the bottom -- re-check with
|
||||
Three test locations cover this view (re-check with
|
||||
`rg -n "ChatStreamingView|/api/chat|stream_chat_with_documents" src/documents/tests/*.py` if
|
||||
more time has passed):
|
||||
more time has passed since this plan was written):
|
||||
|
||||
1. `src/documents/tests/test_views.py`, class `TestAIChatStreamingView` -- patches
|
||||
`stream_chat_with_documents` entirely, doesn't inspect `documents`' type.
|
||||
2. `src/documents/tests/test_api_chat.py`, class `TestChatStreamingViewInputValidation` --
|
||||
input-validation only, doesn't reach `documents` construction.
|
||||
3. `src/documents/tests/test_permission_filtering_security.py:177`, class
|
||||
3. `src/documents/tests/test_permission_filtering_security.py`, class
|
||||
`TestAiChatAllDocumentsPermissionBoundary`, test
|
||||
`test_chat_all_documents_excludes_unshared_document` (~line 190-215) -- **this is the one
|
||||
that actually matters for this change**: it asserts on `kwargs["documents"]` from the
|
||||
mocked `stream_chat_with_documents` call (`{doc.pk for doc in kwargs["documents"]}`),
|
||||
pinning the permission-scoping behavior this plan touches. Read this test specifically
|
||||
before/after the change, not just via a blind `-k chat` filter -- iterating a `QuerySet`
|
||||
with a set comprehension works the same as iterating a `list`, so it should keep passing
|
||||
unchanged, but confirm rather than assume.
|
||||
`test_chat_all_documents_excludes_unshared_document` -- **this is the one that actually
|
||||
matters for this change**: it asserts on `kwargs["documents"]` from the mocked
|
||||
`stream_chat_with_documents` call (`{doc.pk for doc in kwargs["documents"]}`), pinning the
|
||||
permission-scoping behavior this plan touches. Read this test specifically before/after the
|
||||
change, not just via a blind `-k chat` filter -- iterating a `QuerySet` with a set
|
||||
comprehension works the same as iterating a `list`, so it should keep passing unchanged, but
|
||||
confirm rather than assume.
|
||||
|
||||
Run:
|
||||
|
||||
@@ -615,15 +631,13 @@ that tests pass -- prove the improvement, don't assume it. Expect:
|
||||
`CHAT_RETRIEVER_TOP_K = 5`) instead of scaling with library size.
|
||||
- `_document_id_filters`' cost is unchanged in shape (Task 2 only avoids hydrating full
|
||||
`Document` rows there, via `.values_list("pk", flat=True)`; it still touches every accessible
|
||||
id -- see `CHAT_UNBOUNDED_DOCUMENT_SCAN.md` point 3, still out of scope) but should show
|
||||
reduced wall time/memory from not loading full rows.
|
||||
id -- see Background, point 3, still out of scope) but should show reduced wall time/memory
|
||||
from not loading full rows.
|
||||
|
||||
Record the before/after comparison (e.g. as a small table: library size, before query
|
||||
count/time, after query count/time) either back into Task 0's section of this plan or into
|
||||
`CHAT_UNBOUNDED_DOCUMENT_SCAN.md`'s status update (Step 4 below) -- whichever reads better as a
|
||||
permanent record for a future reader. If the numbers do NOT show the expected improvement,
|
||||
stop and treat that as a signal the fix is incomplete or wrong before proceeding to the rest of
|
||||
this task's steps.
|
||||
count/time, after query count/time) back into Task 0's section of this plan. If the numbers do
|
||||
NOT show the expected improvement, stop and treat that as a signal the fix is incomplete or
|
||||
wrong before proceeding to the rest of this task's steps.
|
||||
|
||||
- [ ] **Step 1: Run the full `paperless_ai` and relevant `documents` test suites**
|
||||
|
||||
@@ -644,35 +658,19 @@ uv run prek run --all-files
|
||||
|
||||
Expected: clean, and no new violations beyond `.mypy-baseline.txt` / `.pyrefly-baseline.json`.
|
||||
|
||||
- [ ] **Step 3: Re-read `CHAT_UNBOUNDED_DOCUMENT_SCAN.md`'s "What a fix probably looks like" section and confirm points 1 and 2 are addressed**
|
||||
- [ ] **Step 3: Confirm both in-scope fixes from Background are addressed**
|
||||
|
||||
Point 1 (don't materialize full `Document` rows for the filter step) -- addressed by Task 2 Step 3.
|
||||
Point 2 (permission-check only `top_nodes`, bounded by `CHAT_RETRIEVER_TOP_K`) -- addressed by Task 2 Step 4.
|
||||
Point 3 (whether the vector-store `IN (...)` filter itself is a KNN scaling concern) -- explicitly out of scope for this plan; leave `CHAT_UNBOUNDED_DOCUMENT_SCAN.md` in place (do not delete it) and update its "Status" line to reflect that points 1-2 are fixed and point 3 remains open, so a future session picking up the sqlite-vec investigation has accurate context.
|
||||
|
||||
- [ ] **Step 4: Update `CHAT_UNBOUNDED_DOCUMENT_SCAN.md` status**
|
||||
|
||||
Change the header:
|
||||
|
||||
```markdown
|
||||
**Status:** points 1-2 fixed (see git log for this file's directory); point 3
|
||||
(large `IN (...)` clause inside the vector-store KNN query) remains open and
|
||||
unimplemented -- see "What a fix probably looks like", item 3.
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit the doc update**
|
||||
|
||||
```bash
|
||||
git add CHAT_UNBOUNDED_DOCUMENT_SCAN.md
|
||||
git commit -m "Docs: update chat unbounded scan status after partial fix"
|
||||
```
|
||||
Point 3 (whether the vector-store `IN (...)` filter itself is a KNN scaling concern) remains
|
||||
explicitly out of scope for this plan -- if it needs tracking as future work, open a fresh
|
||||
issue/note for it rather than reviving old diagnosis documents.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review Notes
|
||||
|
||||
- **Spec coverage:** `CHAT_UNBOUNDED_DOCUMENT_SCAN.md` items 1 and 2 under "What a fix probably looks like" are both implemented (Task 2). Item 3 (vector-store `IN` filter scaling) is explicitly called out as needing separate source-level investigation before any code change, and is left open per the doc's own guidance not to guess -- tracked via the Task 4 doc update rather than silently dropped.
|
||||
- **Spec coverage:** both in-scope points from Background ("don't materialize full `Document` rows for the filter step" and "permission-check only `top_nodes`, bounded by `CHAT_RETRIEVER_TOP_K`") are implemented in Task 2. The vector-store `IN` filter scaling question is explicitly out of scope and not silently dropped -- it's called out in Background, Global Constraints, and Task 4 Step 3.
|
||||
- **Placeholder scan:** no TBD/TODO markers; every step has literal code.
|
||||
- **Type consistency:** `documents: QuerySet[Document]` is consistent across `stream_chat_with_documents`, `_stream_chat_with_documents`, `_get_document_references`, and both call sites in `views.py`. `_build_document_reference`'s signature is unchanged (still takes a hydrated `Document`). `output_language` threading is preserved unchanged throughout.
|
||||
- **2026-08-08 revision:** original plan referenced a `get_objects_for_user_owner_aware`-based whole-library branch and an `output_language`-less signature, neither of which match current `dev`. This revision was cross-checked line-by-line against `src/paperless_ai/chat.py` and `src/documents/views.py` at commit `fc242bb57` and against the current `src/paperless_ai/tests/test_chat.py`. The underlying bug (full materialization in `_document_id_filters` call and `_get_document_references`) is unchanged and still present. A follow-up `django-developer` agent independently re-verified this revision against the live repo: all line numbers in `test_chat.py` and the `chat.py`/`views.py` snippets matched verbatim; it additionally surfaced that `src/documents/tests/test_permission_filtering_security.py::TestAiChatAllDocumentsPermissionBoundary` (not originally named in the plan) is the test that actually pins the permission-scoping behavior this change touches -- folded into Task 3 Step 2 above.
|
||||
</content>
|
||||
- **Self-contained:** this plan does not depend on any other document, branch, or worktree existing -- all context needed to execute it (bug diagnosis, current code, fix design) is inlined above.
|
||||
|
||||
Reference in New Issue
Block a user