phase: 164_chat_error_honesty
Build and Push Containers / build-and-push-app (push) Successful in 2m11s
Build and Push Containers / build-and-push-db (push) Successful in 29s

All gates green. Report:

- **Verified (phase 164, all 4 tasks already in `complete/`)**: `HistoryTurn`/`ChatMessage`/`ChatThinkingSegment` caps → 600 000, `history`/saved-messages lists → 500, new `SavedChatCreate/Update` total-8 000 000-char validator; exported `errorDetailText` at the two previously unguarded chat paths (pre-stream `!res.ok` + SSE `error` frame); `FAILED_REQUEST_TEXT` + `bubbleText` threading + phase-114 banner hint; dedicated E2E `tests/e2e/test_chat_error_honesty.py`.
- **Final-pass additions**: `tests/integration/test_chat_api.py::test_history_rejects_one_char_over_the_text_cap` (endpoint-level >600k → 422 with the pydantic list-of-objects `msg` — the criterion's upper half was only unit-pinned); README env rows for `BOR_APP_VERSION` + `BOR_SESSION_COOKIE`; restored `test_document_viewer.py`'s tracked screenshot artifact dirtied by the E2E run (via read-only `git show`, no index/history touched).
- **Tests/lint (exact commands)**: `uv run pytest --cov=app --cov-report=term-missing` → **3837 passed**, `app/` coverage **99%** (>90%); `uv run pytest tests/e2e/test_chat_error_honesty.py -v --no-cov` → **4 passed in isolation**; per-file loop over **all 148** E2E files (`uv run pytest tests/e2e/<f> -q --no-cov`) → **148 × exit 0**, incl. `test_failed_turn_retry`, `test_chat_save_payload_limits`, `test_edit_and_resend`, `test_llm_history`, `test_smoke`; `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors.
- **Completion criteria**: pre-stream 422 shows the distinct line + verbatim `msg`, `[object Object]` unrepresentable + no `input` in DOM — **met** (unit totality/source pins + E2E asserts 1–2); 525k history accepted (200 settles) / >600k → 422 / saved chat 201 + 8M bound — **met**; transport + SSE-frame settles keep `FAILED_TURN_TEXT`, stop/abort untouched, §7.4 re-enables — **met**; budget trim, `message` 4000, the 7 guarded detail-readers and all SSE shapes byte-identical (diff touches only caps + the two chat paths) — **met**; README/AGENTS full review + proxy deploy note — **met**; commit/`complete/` move = pipeline gate.
- **Docs**: README reviewed in full (env tables cross-checked programmatically: 74 `BOR_*` rows vs `Settings` defaults — no mismatches; every `.env.example` var documented; referenced scripts/pages/endpoints/`query_log` columns exist) — found & fixed the 2 undocumented real knobs above; the phase's payload-limits section, failed-turn copy and `client_max_body_size ≥ 16 MB` deploy note verified accurate. `AGENTS.md` verified consistent (rules, gitignore list, quick-reference commands, referenced paths all correct) — no changes needed.
- **Deviations**: `ChatThinkingSegment.text` raised 32 000 → 600 000 too (not itemized in A1, but its own docstring mirrors `HistoryTurn.thinking`; without it a long-thinking turn 422s its own auto-save — same mirror invariant). No live-infra changes made (the proxy limit lives in the separate deployment repo; README note only).
- **Next pending phase**: none — `164_chat_error_honesty` was the only entry in `todo/`.
This commit is contained in:
2026-10-07 18:00:12 -04:00
parent 6ba46e735a
commit 34d60efbc1
42 changed files with 2601 additions and 84 deletions
+45
View File
@@ -172,6 +172,41 @@ the documents they cite. Shared chats stay open to everyone.
A read-only snapshot of any conversation, shareable via a link. Open to
everyone — no login or token required.
### Conversation payload limits
A conversation has one shape, and it carries the **same static bounds**
whether it rides back to the model as `history` on the next turn or is
written to a saved chat — the two paths mirror each other on purpose, so
saving a conversation can never be stricter than continuing it (phase
164):
| Bound | Value | What it admits |
|-------|-------|----------------|
| One message (`text`, `thinking`) | 600 000 chars | a **full-length answer at the largest answer cap** — `BOR_MAX_OUTPUT_TOKENS` at 131 072 tokens ≈ 524 300 chars — as history and as a saved record, with margin (the old 32 000 ceiling broke even at the 32 768-token default) |
| Turns sent as `history` | 500 | the whole browser conversation (the client sends everything it holds, minus the question being asked) |
| Messages per saved chat | 500 | the same conversation, saved |
| **Total chars per saved chat** | **8 000 000** | ~15 full-length answers; the one bound on the anonymous save surface, which stays bounded on purpose |
| One question (`message`) | 4 000 chars | unchanged; the composer counts it live |
These are sanity ceilings, not the prompt's budget: the real gate is
`BOR_HISTORY_MAX_TURNS` / `BOR_HISTORY_MAX_CHARS`, and a turn that does
not fit the budget is dropped **whole** from later prompts (never cut
mid-answer). So a very long answer stops being context for the next
question — the follow-up still works, just with a trimmed window —
raise `BOR_HISTORY_MAX_CHARS` if you want it kept.
**A failed turn states what actually happened** (phase 164): when the
app *answers* but rejects the send before the model runs (an HTTP
4xx/5xx — a validation rejection, an outage page, a proxy error), the
bubble reads *"I couldn't take that — my API rejected the request. Use
Retry to ask again."* — not the connection line — and the banner adds
that nothing was lost but that send. A genuine transport failure and a
model-side error keep *"My answer didn't make it — the connection
dropped."* Either way the exact reason rides verbatim on the bubble's
**Failed** note (API validation details serialize as their `msg`
text — never `[object Object]`, and rejected request content is never
shown), persists with the record, and lands Retry + Edit.
### Edit doc (`/doc-edit.html`)
Review and adjust an AI-generated answer before committing it to the docs
@@ -922,6 +957,7 @@ to `.env` and adjust.
| Env | Default | Meaning |
|-----|---------|---------|
| `BOR_APP_NAME` | `Brain of Reese` | Display name everywhere (page titles, header brand, status labels) |
| `BOR_APP_VERSION` | `0.1.0` | Version the FastAPI app reports (`GET /api/health`, the OpenAPI doc) |
| `BOR_INPUT_PLACEHOLDER` | `Ask me anything…` | Chat composer placeholder |
| `BOR_FOOTER_TEXT` | `Powered by self-hosted models` | Footer line on every page |
| `BOR_ENVIRONMENT` | `development` | `development` or `production` (the prod compose profile sets `production`) |
@@ -1042,6 +1078,7 @@ to `.env` and adjust.
| `BOR_ADMIN_PASSWORD` | *(required)* | The single admin's password — app refuses to start when empty |
| `BOR_SESSION_SECRET` | *(required)* | Signing key for the `bor_session` cookie |
| `BOR_SESSION_MAX_AGE` | `43200` | Session-cookie lifetime in seconds (12 h, sliding) |
| `BOR_SESSION_COOKIE` | `bor_session` | Name of the signed session cookie |
| `DEBUGPY` | `0` | `1` ⇒ attach-on-demand debugpy on `DEBUGPY_PORT` |
| `DEBUGPY_PORT` | `5678` | debugpy listen port |
@@ -1199,6 +1236,14 @@ Production hardening: app runs as non-root (uid 10001), slim image,
healthcheck on `/api/health`, debugpy off unless `DEBUGPY=1`, all assets
served locally (no CDN), `BOR_ENVIRONMENT=production`.
**Reverse-proxy body size:** a legal chat/save POST is now large — one
conversation can carry a full-length answer (see
[Conversation payload limits](#conversation-payload-limits)) — so an
nginx-style proxy in front of the app needs
`client_max_body_size` **≥ 16 MB** (the app itself sets no body limit);
at the default 1 MB the follow-up of a long answer fails at the proxy
instead of reaching the app.
---
## Troubleshooting