feat(rag): feed whole matched documents to the LLM — no context truncation (A7 revised)

This commit is contained in:
2026-08-24 23:37:44 -04:00
parent d7a4064616
commit 1e6ae360e0
16 changed files with 923 additions and 60 deletions
@@ -0,0 +1,120 @@
# Phase 23 — Containerfile: Build the Whole App Image Again
**Source:** `TODO.md` L6 — *"Fix Containerfile build not working"*
**Story:** `.agent/user_stories/containerfile-build.md` (created by task 02)
**Context:** `Containerfile` (3 stages: node:22-alpine + esbuild
0.25.5 frontend bundle → uv/python deps → slim runtime serving
`/app/static`); `frontend/` (4 pages: `index.html`, `sources.html`,
`document.html`, `login.html`; assets: `styles.css`, `markdown.js`
(classic script), `header.js`/`app.js`/`sources.js`/`document.js`/
`login.js` (ES modules)); `scripts/entrypoint.sh`.
## Verified diagnosis (2026-08-24, this conversion — not a guess)
1. **Root cause of the build failure:** phase 19 switched the page
scripts to `import … from "/assets/header.js"` (an absolute URL).
esbuild resolves that as the *filesystem* path `/assets/header.js`
and the stage-1 bundle dies:
`✘ [ERROR] Could not resolve "/assets/header.js"`
(reproduced with esbuild **0.25.5**, the exact pinned version, on a
copy of `frontend/`).
2. **Secondary gap (image would be broken even if it built):** stage 1
bundles only `app.js` + `sources.js` and copies only `index.html` +
`sources.html`. Missing from the image: `document.html` +
`login.html` (phases 10/16), `document.js` + `login.js`, and
`markdown.js` (classic script loaded by `index.html` +
`document.html`).
3. **Verified fix:** with relative imports (`from "./header.js"`) all
four page scripts bundle cleanly with esbuild 0.25.5.
4. **Latent double-evaluation trap:** all four HTML pages also load
`<script type="module" src="/assets/header.js">` directly while the
page script imports it. In dev the browser dedupes (same module
URL) — but in the image the bundled page script already contains the
header code, so shipping a raw `header.js` too would evaluate the
module **twice** (duplicate sign-out listener, double init). The
direct tags are redundant: the page script's `import` is hoisted and
guarantees `header.js` evaluates before the page script's body calls
`initSharedHeader()`, in dev and in the bundle alike.
## Objective
`podman build -f Containerfile .` succeeds, and the resulting image
serves the **whole app** — all four pages with their bundled, minified,
local-only assets (No CDN rule) — with `header.js` evaluated exactly
once per page.
## Owner-confirmed (2026-08-24, roadmap A4)
1. **Relative imports** (`./header.js`) over an esbuild alias — simpler,
verified working, dev-server behavior unchanged (files are
side-by-side).
2. **Remove the four redundant direct `header.js` script tags** (the
design above) rather than ship a raw `header.js` into the image —
single module evaluation, no duplicate listeners.
3. The image must cover **all four pages + all local assets** they
reference — the integration test (task 02) enforces this coverage so
the gap cannot silently reappear.
## Dependencies
- `19_shared_header` (complete) — introduced the absolute imports (root
cause) and the direct `header.js` tags.
- `10_story_document_viewer` / `16_admin_auth` (complete) — the pages
missing from the image.
- `08_story_dark_tech_theme` (complete) — No CDN rule the image must
honor.
## Tasks
1. `01_fix_containerfile_build.md` — relative imports, tag removal,
stage-1 asset coverage, green `podman build`, image smoke test.
2. `02_integration_test_commit.md` — `tests/integration/
test_containerfile_assets.py` (hermetic coverage pin), regression
suites, story file, final validation, the single atomic commit,
phase move to `complete/`.
## Locked decisions
- **A11 honored** — vanilla JS, no CDN, static serving from FastAPI.
**A16 honored** — integration test for the new build coverage; story
file + report; no Playwright suite required (this phase is
build/infrastructure — the phase gate is the hermetic integration
test + the real `podman build` + image smoke recorded in the report,
plus the dev-server E2E regressions). No anchor changed.
## Testing & Quality
- **Integration (new `tests/integration/test_containerfile_assets.py`,
hermetic — no podman, no network):** every `frontend/*.html` is
copied into stage 1's `/out`; every local `src`/`href` asset
referenced by the four pages is produced by a stage-1 line (esbuild
`--outfile` or `cp`); the four page module scripts are the exact set
esbuild bundles; `markdown.js` is produced; no HTML references
`/assets/header.js` directly (single-evaluation design pin); the
esbuild version stays pinned.
- **Unit:** none (no `app/` changes).
- **Coverage:** the >90% `app/` gate is unaffected, re-run to prove it.
- **Build gate (manual, recorded in the report):** `podman build
-f Containerfile .` green; image smoke (task 01 step 6) results +
log excerpt in `.agent/reports/23_containerfile_build/`.
- **Dev regressions (E2E, isolated):** `test_smoke.py`,
`test_shared_header.py`, `test_chat_persistence.py` (the HTML tag
removal touches dev page load).
- **Lint/types:** `uv run ruff check . && uv run pyright` clean.
## Completion Criteria
- [ ] Local esbuild 0.25.5 bundles all four page scripts cleanly.
- [ ] `podman build -f Containerfile .` green (log excerpt in the
report).
- [ ] Image smoke: container runs (throwaway Postgres 17 + pgvector);
`GET /`, `/sources.html`, `/document.html`, `/login.html` → 200;
`/assets/app.js` minified and contains the header code;
`/assets/markdown.js` 200; no `http(s)://` asset reference in any
served page (No CDN rule).
- [ ] Dev server unchanged in behavior: the three regression E2E suites
green in isolation.
- [ ] `uv run pytest` green; `uv run pytest --cov=app
--cov-report=term-missing` ≥ today's number.
- [ ] `uv run ruff check . && uv run pyright` clean.
- [ ] `.agent/user_stories/containerfile-build.md` exists.
- [ ] One `--no-gpg-sign` commit (below);
`.agent/phases/todo/23_containerfile_build/` moved to
`.agent/phases/complete/`.
## Commit
```bash
git add -A .agent/ Containerfile frontend/ tests/ && git commit --no-gpg-sign -m "fix(build): Containerfile builds again — relative module imports, all four pages and shared assets in the image"
```
@@ -0,0 +1,70 @@
# Task 02 — Integration coverage test, story file, validation, commit
**Phase:** `23_containerfile_build` · **Source:** `TODO.md` L6
## Objective
Pin the stage-1 asset coverage so it can't silently rot again (a new
page/script/asset without a matching Containerfile line fails CI), plus
regressions, story file, final validation, and the single atomic commit.
## Work
1. `tests/integration/test_containerfile_assets.py` (new — **hermetic**:
parses `Containerfile` + `frontend/` as text, no podman, no network).
Tests:
1. `test_every_html_page_is_copied_into_stage1` — for each
`frontend/*.html` in the repo, a stage-1 line copies it into
`/out` (regex over the `cp` line; the set must be exactly the
four current pages — a new page added to `frontend/` fails this).
2. `test_every_local_asset_reference_is_produced` — collect every
local `src=`/`href=` under `assets/` or `/assets/` from the four
HTML files; each basename must be produced by a stage-1 line
(an `esbuild … --outfile=/out/assets/<name>` or a `cp` of it).
(This is what catches a missing `markdown.js`-style gap.)
3. `test_page_module_scripts_are_bundled` — the set of `type="module"`
page scripts referenced by the HTML (basenames) equals the set of
scripts esbuild bundles in stage 1 (`app.js`, `sources.js`,
`document.js`, `login.js`).
4. `test_header_module_is_imported_not_directly_loaded` — no HTML
file contains a `<script … src="/assets/header.js">` (or
`assets/header.js`) tag (the single-evaluation design pin,
owner-confirmed A4-2); and each of the four page scripts imports
it relatively (`from "./header.js"`).
5. `test_markdown_js_is_a_produced_classic_script` — `markdown.js`
has a stage-1 minify line **without** `--bundle` (it is a classic
global script) and no `import`/`export` statements at its top
level (source pin of that assumption).
6. `test_esbuild_stays_pinned` — the frontend stage pins a concrete
`esbuild@X.Y.Z` version (no floating version).
2. `.agent/user_stories/containerfile-build.md` (new) — story file per
the repo format: goal, the bug report verbatim from `TODO.md` L6, the
verified diagnosis (root cause + missing-asset gap + double-eval
trap), the owner-confirmed A4 decisions, and the test mapping table.
3. Regressions, in isolation, one command each (prereq
`podman compose up -d db`):
- `uv run pytest tests/e2e/test_smoke.py -v --no-cov`
- `uv run pytest tests/e2e/test_shared_header.py -v --no-cov`
- `uv run pytest tests/e2e/test_chat_persistence.py -v --no-cov`
4. Final validation: `uv run pytest` green (includes the new
integration test); `uv run pytest --cov=app --cov-report=term-missing`
≥ today's number (>90% gate); `uv run ruff check . && uv run pyright`
clean.
5. Finish the phase report (`.agent/reports/23_containerfile_build/` —
build log excerpt, smoke results, regression results).
6. Commit (one atomic commit) and move the phase:
```bash
git add -A .agent/ Containerfile frontend/ tests/
git commit --no-gpg-sign -m "fix(build): Containerfile builds again — relative module imports, all four pages and shared assets in the image"
mv .agent/phases/todo/23_containerfile_build .agent/phases/complete/
```
## Testing & Quality
- New integration suite green within `uv run pytest`; the three
regression E2E suites green in isolation; full suite green; `app/`
coverage at or above today's number (>90%); ruff + pyright clean.
## Completion Criteria
- [ ] `test_containerfile_assets.py` 6/6 within the full suite.
- [ ] Regressions (smoke, shared header, chat persistence) green in
isolation.
- [ ] Story file + phase report (build log + smoke evidence) exist.
- [ ] One `--no-gpg-sign` commit; phase directory in `complete/`.
@@ -0,0 +1,138 @@
# Phase 24 — Whole-Document Context: a matched document is never truncated
**Source:** `TODO.md` L3–L4 — *"Documents are truncated for some reason?
This should never happen"* + *"When the LLM matches a chunk it should get
the entire document placed in its context so it can see the whole thing
before answering the question"*
**Story:** `.agent/user_stories/whole-document-context.md` (created by
task 03)
**Context:** `app/rag/retriever.py::select_documents` (the one and only
place document content is cut — the 24k budget), `app/api/chat.py::plan_turn`
(passes the budget on both HIGH and LOW paths), `app/config.py`
(`max_context_chars`), `app/rag/prompts.py` (the shared `[…truncated…]`
marker — still owned by the steering section), `tests/unit/test_retriever.py`
(pins the current cap), `tests/e2e/mock_llm.py` + `tests/e2e/test_chat_rag.py`
(E2E patterns), `.env.example` + `README.md` (document the knob).
## Verified diagnosis (2026-08-24, this conversion — not a guess)
1. **The importer stores the whole file** — `app/rag/importer.py:199–228`
reads each file into `documents.content` in full (sha256 over the whole
content). No truncation at import time.
2. **Chunking never touches the LLM context** — the 2000-char target /
1200-char hard cap only shapes `chunks` rows (retrieval + embeddings);
the chat prompt is built from `documents.content`.
3. **The viewer serves raw content** — `GET /api/documents/content`
(`app/api/docs.py`) returns `doc.content` unchanged; no truncation there
either. So the owner's "truncated for some reason" is *not* a separate
import/viewer bug.
4. **The one and only truncation point is `select_documents()`**
(`app/rag/retriever.py:252–289`): the top-2 documents' combined text is
capped at `BOR_MAX_CONTEXT_CHARS` (default **24 000**) and the
lowest-ranked overflowing document is truncated in place with
`[…truncated…]`. `plan_turn()` (`app/api/chat.py`) passes the budget on
both the HIGH (grounded) and LOW (deflected) paths. The marker the owner
saw in answers comes from here.
5. **The cap is pinned + documented** — `tests/unit/test_retriever.py`
(`test_combined_content_capped_with_truncation_marker`,
`test_single_doc_over_budget_is_truncated_to_budget`,
`test_under_budget_no_truncation`); the knob is in `.env.example`
(`BOR_MAX_CONTEXT_CHARS=24000`) and `README.md:377`.
## Objective
When hybrid retrieval matches a chunk, the LLM sees the **entire** parent
document — the 24k context budget (and `BOR_MAX_CONTEXT_CHARS`) is removed
from the document path, and a dedicated story E2E proves deterministically
that a >24k document — and the *second* document of a >24k pair (the exact
case the old budget cut) — reaches the model whole.
## Owner-confirmed (2026-08-24, roadmap D1–D5)
1. **D1 — no cap at all (revises LOCKED A7):** `select_documents` returns
the full top-N document texts, always. The `max_context_chars` setting
and `BOR_MAX_CONTEXT_CHARS` env var are removed. If a future KB ever
makes the prompt too large for the model, the existing `LLMError` → SSE
`error` path surfaces it loudly — no silent partial context. The
emergency-valve variant (raised cap + warning log) was **explicitly
rejected**.
2. **D2 — `top_n_docs = 2` unchanged** (the TODO is about truncation, not
about how many documents).
3. **D3 — no viewer/import changes** — both already serve full content
(verified diagnosis above).
4. **D4 — E2E evidence via a deterministic mock tail-echo** (repo pattern,
cf. the phase-15 tuning-note echo); the big documents are seeded
directly in the DB inside the E2E test — `tests/fixtures/docs/` must
not grow, because other suites pin `summary.added == 8`.
5. **D5 — no `query_log` schema change** (no new columns, no migration).
## Dependencies
- `03_story_chat_rag` (complete) — the RAG turn + `plan_turn` this phase
modifies.
- `09_story_retrieval_quality` (complete) — hybrid retrieval +
`select_documents` (A7) whose cap this phase revises.
- `15_steering_notes` (complete) — still owns `[…truncated…]` +
`BOR_STEERING_MAX_CHARS` (shared marker; unchanged).
## Tasks
1. `01_remove_context_cap.md` — remove the 24k budget from
`select_documents`, `plan_turn`, `config`, `.env.example`, `README`;
rewrite the unit tests to pin *no* truncation.
2. `02_whole_doc_e2e_suite.md` — mock tail-echo trigger +
`tests/e2e/test_whole_document_context.py` (whole >24k doc, whole second
doc of a >24k pair, small-doc regression).
3. `03_story_docs_plan_commit.md` — story file, PLAN.md A7/§6/§12 revision
(owner permission 2026-08-24), full validation, one `--no-gpg-sign`
commit, phase move.
## Locked decisions
- **A7 revision (owner permission 2026-08-24, D1):** A7's context clause
becomes *"feed the **full text of top-N=2 documents** (deduped)"* — the
"capped at 24k chars" clause is **removed**; matched parent documents are
**never truncated**. The revision note lands in PLAN.md via task 03
(phase-17/19 precedent — recorded, not silently deviated).
- **Steering unchanged** — `BOR_STEERING_MAX_CHARS` (8 000) still caps the
`<tuning>` section with the same `[…truncated…]` marker (phase-15
behavior byte-identical).
- **A16 honored** — dedicated Playwright story suite run in isolation;
unit + integration green; `app/` coverage >90%.
## Testing & Quality
- **Unit:** `tests/unit/test_retriever.py` — `select_documents` returns
byte-identical full content well past the old 24k budget, no marker;
ranking / dedup / n-cap tests unchanged.
- **Integration:** `uv run pytest tests/integration` green — existing
`test_chat_api.py` prompt tests exercise `plan_turn` through the new
signature (no integration test pins the cap — verified).
- **Coverage:** >90% on `app/` held
(`uv run pytest --cov=app --cov-report=term-missing`).
- **E2E (new, isolated):** `tests/e2e/test_whole_document_context.py` —
the tail sentinel of a 30k-char document (and of the *second* document of
a >24k pair) appears in the rendered answer; `[…truncated…]` never
appears; the small-document grounded path is unchanged.
- **Lint/types:** `uv run ruff check . && uv run pyright` clean.
## Completion Criteria
- [ ] `select_documents` has no budget parameter and never truncates;
`TRUNCATION_MARKER` remains for the steering section only.
- [ ] `BOR_MAX_CONTEXT_CHARS` gone from `app/config.py`, `.env.example`,
and `README.md`.
- [ ] `uv run pytest` green; `uv run pytest --cov=app
--cov-report=term-missing` ≥ today's number.
- [ ] `uv run pytest tests/e2e/test_whole_document_context.py -v --no-cov`
green in isolation.
- [ ] `uv run ruff check . && uv run pyright` clean.
- [ ] `.agent/user_stories/whole-document-context.md` exists; PLAN.md
carries the A7 revision + §6 bullet + §12 row 24 (owner permission
2026-08-24).
- [ ] One `--no-gpg-sign` commit (below);
`.agent/phases/todo/24_whole_document_context/` moved to
`.agent/phases/complete/`.
## Commit (task 03 — after the phase dir has moved to `complete/`)
```bash
git add -f .agent/phases/complete/24_whole_document_context/ .agent/user_stories/whole-document-context.md .agent/PLAN.md
git add -A .agent/phases/todo/24_whole_document_context/ app/ README.md .env.example tests/
git commit --no-gpg-sign -m "feat(rag): feed whole matched documents to the LLM — no context truncation (A7 revised)"
```
(The *conversion* commit — this phase dir force-added under `todo/` plus
the cleared `TODO.md` — lands separately when the roadmap is written, per
the phase-20–23 precedent, commit `824914c`.)
@@ -0,0 +1,62 @@
# Task 01 — Remove the 24k document-context budget
**Phase:** `24_whole_document_context` · **Source:** `TODO.md:3–4` — *"Documents are truncated for some reason? This should never happen"* + *"When the LLM matches a chunk it should get the entire document placed in its context so it can see the whole thing before answering the question"*
**Story:** `.agent/user_stories/whole-document-context.md` (created by task 03)
## Objective
`select_documents` returns the full text of the top-N parent documents,
always — the budget parameter, the `max_context_chars` setting, the env var,
and the documentation lines are removed, and the unit tests pin the new
no-truncation contract (owner-confirmed D1: no cap at all — the
emergency-valve variant was explicitly rejected).
## Work
1. `app/rag/retriever.py` — `select_documents(chunks, n=None)`: drop the
`max_chars` parameter and the whole `remaining`/truncation loop; return
the deduped top-N documents with their `content` byte-identical. Update
the module docstring (remove the "capped at ``BOR_MAX_CONTEXT_CHARS``"
sentence; state the A7 revision — whole documents, never truncated,
owner permission 2026-08-24) and the `select_documents` docstring.
`TRUNCATION_MARKER` **stays** exported — `app/rag/prompts.py`
(steering section, phase 15) still imports it.
2. `app/api/chat.py` — `plan_turn`: both `select_documents(…)` call sites
(HIGH and LOW paths) drop the `max_chars=settings.max_context_chars`
argument. No other turn-flow change (deflection, steering, `query_log`,
SSE all unchanged).
3. `app/config.py` — delete `max_context_chars: int = 24_000` and its
comment. (Any leftover `BOR_MAX_CONTEXT_CHARS=…` in an operator's
gitignored `.env` is silently ignored via `extra="ignore"` — no
migration, no startup check needed.)
4. `.env.example` — delete the `BOR_MAX_CONTEXT_CHARS=24000` line.
5. `README.md` — delete the settings-table row
`| BOR_MAX_CONTEXT_CHARS | 24000 | cap on total document text sent to the LLM |`
(line ~377). Leave the steering-notes `[…truncated…]` mention (~line 178)
alone — that budget still exists.
6. `tests/unit/test_retriever.py` — drop the `max_chars=…` argument from
every `select_documents` call; replace the three cap tests
(`test_combined_content_capped_with_truncation_marker`,
`test_single_doc_over_budget_is_truncated_to_budget`,
`test_under_budget_no_truncation`) with
`test_content_never_truncated_even_past_old_budget`: two documents of
20 000 + 15 000 chars (35 000 combined — past the old 24 000 cap) come
back with content **byte-identical** (assert equality against the
originals) and `TRUNCATION_MARKER` absent from both. Keep the module
docstring accurate ("ordering and dedup — no context cap, A7 revised").
## Testing & Quality
- Unit: `uv run pytest tests/unit/test_retriever.py -v` green — ranking,
dedup, n-cap, and the new no-truncation test.
- Integration: `uv run pytest tests/integration` green — the existing
`test_chat_api.py` prompt tests exercise `plan_turn` through the new
signature (no integration test pins the cap — verified during conversion).
- Coverage: `uv run pytest --cov=app --cov-report=term-missing` ≥ today's
number (the >90% gate).
## Completion Criteria
- [ ] `rg "max_context" app/ tests/ .env.example README.md` → **no matches**
(`steering_max_chars` is a different setting and must remain).
- [ ] `uv run pytest tests/unit/test_retriever.py -v` green.
- [ ] `uv run pytest` green; `uv run pytest --cov=app
--cov-report=term-missing` ≥ today's number.
- [ ] No behavior change outside the context budget — deflection, steering,
viewer, and import behave byte-identically to before.
@@ -0,0 +1,84 @@
# Task 02 — Story E2E: whole documents reach the LLM (mock tail-echo)
**Phase:** `24_whole_document_context` · **Source:** `TODO.md:3–4` — *"Documents are truncated for some reason? This should never happen"* + *"When the LLM matches a chunk it should get the entire document placed in its context so it can see the whole thing before answering the question"* (this task makes the no-truncation contract provable end-to-end)
**Story:** `.agent/user_stories/whole-document-context.md` (created by task 03)
## Objective
`tests/e2e/test_whole_document_context.py` proves, with the deterministic
mock, that a matched document **larger than the old 24k cap** — and the
**second** document of a pair whose combined size exceeds it (the exact case
the old budget cut) — reach the LLM whole.
## Work
1. `tests/e2e/mock_llm.py` — add one user-message trigger, same pattern as
`LONG_ANSWER_TRIGGER` and the phase-15 tuning-note echo:
- `END_OF_NOTES_TRIGGER = "show the end of your notes"` — verified
2026-08-24: no existing E2E question or fixture file contains the
phrase, so every other suite is unaffected.
- In `compose_answer`, after the `DEFLECT_MODE` check and before the
generic branch: when the trigger is present in the user message, the
answer quotes the **tail of the context** — e.g.
`f"…and the very end of my notes reads: “{_context(body)[-160:]}” (Deterministic mock answer for E2E.)"`.
Byte-stable across runs, so a sentinel placed at the *end* of a
document appears in the rendered answer **iff the whole document was
in the prompt**. (The tail includes the closing `</documents>` —
harmless for `to_contain_text` sentinel assertions.)
- Keep the mock docstring's trigger list updated.
2. `tests/e2e/test_whole_document_context.py` — new story suite. Reuse the
`page`, `app_url`, `mock_llm`, `db_ready` fixtures and the
TRUNCATE-then-seed pattern from `tests/e2e/test_chat_rag.py` /
`tests/integration/test_document_content.py`. **Seed the big documents
directly via SQLAlchemy** (a `documents` row + 2–3 `chunks` rows, each
chunk with `embedding = embed_text(chunk_text)` imported from
`tests.e2e.mock_llm` — the same deterministic bag-of-words vector the
mock computes, so the question's live mock embedding genuinely overlaps).
**Do not add fixture files** — `tests/fixtures/docs/` must stay at its
current 8 files; other suites pin `summary.added == 8`.
- Content helper: a ~30 000-char document = repeated
"gitlab install playbook" body (tokens shared with the question →
hybrid hit) whose **last line is a unique sentinel**, e.g.
`WHOLE-DOC-TAIL-GITLAB-<test-unique>`.
- `test_whole_document_over_old_cap_reaches_llm` — seed one 30 000-char
document (past the old 24 000 cap); ask
`"Show the end of your notes about the gitlab install playbook, please."`;
assert the rendered brain bubble contains the tail sentinel **and**
`"Deterministic mock answer for E2E"`, contains **no**
`[…truncated…]`, the document's `.source-chip` renders, and the
`query_log` row has `deflected == False`.
- `test_second_document_of_over_cap_pair_reaches_llm` — seed two
documents of ~16 000 chars each (32 000 combined — under the old
budget the lower-ranked one was truncated in place). Doc 1's first
chunk carries the question's key tokens ("gitlab install playbook");
doc 2's first chunk carries a weaker overlap ("playbook", "notes") so
doc 1 ranks first and doc 2 is the **last block inside
`<documents>`** — i.e. the tail-echo surfaces doc 2's sentinel. Assert
doc 2's tail sentinel in the answer. If the ranking ever flips locally,
strengthen doc 1's token overlap (repeat the question phrase in its
first chunk) until two consecutive isolated runs are stable — do not
weaken the assertion.
- `test_small_document_path_unchanged` — regression: re-import the
standard fixtures via the real importer (the `test_chat_rag.py`
`_import_fixtures` pattern), ask the usual on-topic question
(`"How is my Kubernetes cluster set up?"`) → grounded answer +
`kubernetes.md` chip, no `[…truncated…]` (the under-24k path is
byte-identical to before).
- Header comment: story path + run-in-isolation command
(`uv run pytest tests/e2e/test_whole_document_context.py -v --no-cov`).
## Testing & Quality
- E2E: `uv run pytest tests/e2e/test_whole_document_context.py -v --no-cov`
green **in isolation** (DB up: `podman compose up -d db`).
- E2E regressions: `uv run pytest tests/e2e/test_chat_rag.py
tests/e2e/test_honest_deflection.py -v --no-cov` still green (the mock
change is additive — the trigger phrase appears in no existing question).
- Coverage: the >90% `app/` gate is unaffected (tests + mock only) — re-run
`uv run pytest --cov=app --cov-report=term-missing` to prove it.
- Lint/types: `uv run ruff check . && uv run pyright` clean.
## Completion Criteria
- [ ] `uv run pytest tests/e2e/test_whole_document_context.py -v --no-cov`
— all three tests green in isolation.
- [ ] `uv run pytest tests/e2e/test_chat_rag.py
tests/e2e/test_honest_deflection.py -v --no-cov` green.
- [ ] `uv run ruff check . && uv run pyright` clean.
- [ ] `tests/fixtures/docs/` unchanged (still 8 files).
@@ -0,0 +1,76 @@
# Task 03 — Story file, PLAN.md revision, validation, commit
**Phase:** `24_whole_document_context` · **Source:** `TODO.md:3–4` — *"Documents are truncated for some reason? This should never happen"* + *"When the LLM matches a chunk it should get the entire document placed in its context so it can see the whole thing before answering the question"*
**Story:** `.agent/user_stories/whole-document-context.md` (created by this task)
## Objective
Record the user story and the A7 revision (owner permission 2026-08-24),
run the full validation gate, land one atomic commit, and move the phase
to `complete/`.
## Work
1. `.agent/user_stories/whole-document-context.md` — story file in house
style (cf. `.agent/user_stories/sources-midstream.md`):
- Header: `**Phase:** 24_whole_document_context · **E2E:**
tests/e2e/test_whole_document_context.py`.
- Bug report section: the two TODO.md items (L3–L4), verbatim.
- Narrative: *Given* a question whose chunk matches a document,
*when* the turn assembles context, *then* the LLM receives the
**entire** parent document — never a `[…truncated…]`-cut version —
so the answer is grounded in the whole note.
- Owner-confirmed (2026-08-24, roadmap D1–D5) — the five decisions from
the phase overview, verbatim.
- Acceptance criteria: no budget parameter in `select_documents`;
`BOR_MAX_CONTEXT_CHARS` gone from settings/env/README; the story E2E's
three tests green in isolation; steering-note truncation (phase 15)
unchanged; unit+integration green, coverage >90%, one
`--no-gpg-sign` commit.
- Playwright Mapping Rule: the three tests of task 02, one line each.
2. `.agent/PLAN.md` revisions (phase-17/19 precedent — record the owner
permission, never deviate silently):
- §2, A7 row: replace *"feed the **full text of top-N=2 documents**
(deduped, capped at 24k chars)"* with *"feed the **full text of
top-N=2 documents** (deduped)"* and append the rationale addendum:
"A7 revised 2026-08-24 — matched documents never truncated (owner:
'this should never happen'; emergency-valve variant rejected)".
- §2, revision-note block under the anchors table: add
**A7 revision (phase 24, owner permission 2026-08-24):** the
`[…truncated…]` cap on document context is removed —
`select_documents` always returns the full top-N texts;
`BOR_MAX_CONTEXT_CHARS` is gone. The steering section
(`BOR_STEERING_MAX_CHARS`, phase 15) keeps its budget and the shared
marker.
- §6, retrieval bullet: *"top 2 → full content, concatenated, truncated
to `BOR_MAX_CONTEXT_CHARS` (24k) with a `[…truncated…]` marker"* →
*"top 2 → full content, concatenated — **never truncated** (A7
revised, phase 24, owner permission 2026-08-24)"*.
- §12 roadmap table: add row 24 —
`| 24 | 24_whole_document_context.md | whole-document-context.md | tests/e2e/test_whole_document_context.py |`
plus a footnote: "Row 24 added 2026-08-24 with owner permission —
A7's 24k context cap removed (documents are never truncated)".
3. Final validation (AGENTS.md §9 — all must be green before the commit):
- `uv run pytest` (unit + integration)
- `uv run pytest --cov=app --cov-report=term-missing` (>90% gate)
- `uv run pytest tests/e2e/test_whole_document_context.py -v --no-cov`
(in isolation)
- `uv run ruff check . && uv run pyright`
4. Commit + phase move (AGENTS.md §8; use the commit block in
`00_phase.md` — it assumes the move happens first):
`mv .agent/phases/todo/24_whole_document_context
.agent/phases/complete/`, then the two `git add` lines + one
`--no-gpg-sign` commit.
## Testing & Quality
- All AGENTS.md §9 gates: unit + integration green, `app/` coverage >90%,
story E2E green in isolation, lint + types clean.
## Completion Criteria
- [ ] `.agent/user_stories/whole-document-context.md` exists (story,
D1–D5, acceptance criteria, Playwright mapping).
- [ ] `.agent/PLAN.md` carries the A7 row + revision note (§2), the §6
bullet, and the §12 row 24 + footnote — every one marked
"owner permission 2026-08-24".
- [ ] All validation commands green (work step 3).
- [ ] One `--no-gpg-sign` commit;
`.agent/phases/complete/24_whole_document_context/` exists and
`.agent/phases/todo/24_whole_document_context/` is gone.