feat(docs): save the whole chat session as a doc
Phase 75 (TODO.md L4): "Save as doc" now drafts a document from the
ENTIRE chat session — every question and answer up to the click, in
order — instead of only the clicked bubble's answer; the existing
doc-edit screen's free-form body editing is how the user edits out
anything they don't want to keep from previous replies (no new UI
surface).
Task 01 (frontend):
- app.js buildSessionTranscript(): walks the bor.chat.v1 conversation
record in order — a numbered section per user turn ("## N.
<question, raw>" + blank line + the raw answer text; more answers
join under the same heading), sections blank-line separated, all
trailing whitespace collapsed to one final newline. Only the raw
persisted text travels (m.who + m.text — no thinking blocks, no
source chips, no tune metadata); a brain record before the first
user record is skipped; a heading-only section marks a user turn
whose answer never landed (A6, owner-confirmed 2026-09-08).
- saveAsDoc(btn): the draft body is buildSessionTranscript(); the
dead single-bubble markdown parameter is dropped (the button's
appendSaveAsDocButton signature is unchanged — one button per
bubble). Title/path/double-click guard/hand-off are unchanged
(defaultDocTitle: the last question, whitespace-collapsed,
<=120 chars; docs/<slug>.md).
- Unit: the app.js source pins move to the transcript shape (whole
session, no thinking, no dead parameter).
Task 02 (E2E):
- tests/e2e/test_save_doc_session.py (bare-repo fixture, the
phase-59 convention — git as source of truth): three DISTINCT
on-topic turns in one session (turn 1 carries the phase-17
"think out loud" trigger so its record has a thinking block the
transcript must exclude) -> save on the LAST bubble -> the
prefilled body is ## 1./## 2./## 3. in order, byte-exact against
the deterministic mock, thinking-free -> edit the whole
section-2 block out of the body -> push -> git show
bor-docs:<path> equals the EDITED body byte-for-byte (section 2's
question and answer provably absent; sections 1 and 3 byte-exact;
the UI's sha prefix is git rev-parse bor-docs). Second test:
the button on the FIRST bubble still drafts the whole session
(A6 — the transcript is the session at click time, title stays
the last question); canceling leaves the branch tip untouched.
- tests/e2e/test_response_to_docs.py: the phase-59 single-turn body
expectation moves to the transcript shape ("## 1. <question>" +
the answer's markdown) — the rest of the suite unchanged.
Also lands the phase-74 file moves (00_phase.md /
03_mock_marker_e2e.md -> complete/) and the phase reports — the
house convention of committing .agents/ with the phase.
This commit is contained in:
@@ -0,0 +1,11 @@
|
|||||||
|
**Phase 74 final verification pass — all green** (work was already implemented & committed; this pass verified every criterion)
|
||||||
|
|
||||||
|
- Verified: `ChatRequest.history` + `history_to_messages` mapper (turn/char budgets, drop-whole, `reasoning_content` on brain turns only when non-empty); wired through both deflected and grounded branches; `runTurn` sends `conversation.slice(0,-1)` with `thinking`; mock `echo my history` marker; per-turn log gains `history_msgs=N` (seen live in e2e app logs).
|
||||||
|
- `uv run pytest --cov=app` → all pass, TOTAL coverage **99%** (>90% criterion).
|
||||||
|
- `uv run ruff check . && uv run pyright` → clean (0 errors).
|
||||||
|
- Story E2E `uv run pytest tests/e2e/test_llm_history.py -v --no-cov` → **3 passed** (grounded follow-up, cold-start no-history, deflected follow-up — prior Q/A + prior thinking proven byte-exact via mock echo).
|
||||||
|
- Regression suites in isolation (AGENTS.md rule 9): test_chat_rag, test_chat_history, test_agent_document_tools, test_harness_aligned_tools, test_stop_generation, test_retry_answer, test_response_to_docs → **all pass** (exit 0, no failures).
|
||||||
|
- Commit `055c0b5 feat(rag): pass chat history with prior thinking to the LLM`: atomic, covers all phase code, **no gpg signature** (`--no-gpg-sign` honored); no uncommitted app code; `echo my history` collides with no existing phrase/fixture.
|
||||||
|
- Completion criteria: 1) both branches carry history + reasoning ✅ 2) no-history byte-identical (integration pins + green regressions) ✅ 3) log field + suite + coverage + lint ✅ 4) commit + phase dir in `complete/` ✅.
|
||||||
|
- Notable: running multiple e2e suites in ONE pytest process causes a port-8123 bind clash (several suites by design boot their own app on `APP_PORT`, assuming per-suite isolation). Pre-existing infra property, not a phase-74 defect — the mandated isolation gate passes everywhere.
|
||||||
|
- Next pending phase: **75_save_doc_full_session**.
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
........................................................................ [ 4%]
|
||||||
|
........................................................................ [ 9%]
|
||||||
|
........................................................................ [ 13%]
|
||||||
|
........................................................................ [ 18%]
|
||||||
|
........................................................................ [ 23%]
|
||||||
|
........................................................................ [ 27%]
|
||||||
|
........................................................................ [ 32%]
|
||||||
|
........................................................................ [ 37%]
|
||||||
|
........................................................................ [ 41%]
|
||||||
|
........................................................................ [ 46%]
|
||||||
|
........................................................................ [ 51%]
|
||||||
|
........................................................................ [ 55%]
|
||||||
|
........................................................................ [ 60%]
|
||||||
|
........................................................................ [ 65%]
|
||||||
|
........................................................................ [ 69%]
|
||||||
|
........................................................................ [ 74%]
|
||||||
|
........................................................................ [ 79%]
|
||||||
|
........................................................................ [ 83%]
|
||||||
|
........................................................................ [ 88%]
|
||||||
|
........................................................................ [ 93%]
|
||||||
|
........................................................................ [ 97%]
|
||||||
|
................................... [100%]
|
||||||
|
=============================== warnings summary ===============================
|
||||||
|
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||||
|
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||||
|
from starlette.testclient import TestClient as TestClient # noqa
|
||||||
|
|
||||||
|
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||||
|
================================ tests coverage ================================
|
||||||
|
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||||
|
|
||||||
|
Name Stmts Miss Cover
|
||||||
|
-----------------------------------------------
|
||||||
|
app/__init__.py 1 0 100%
|
||||||
|
app/api/__init__.py 0 0 100%
|
||||||
|
app/api/auth.py 22 0 100%
|
||||||
|
app/api/chat.py 177 0 100%
|
||||||
|
app/api/chats.py 110 0 100%
|
||||||
|
app/api/config.py 7 0 100%
|
||||||
|
app/api/doc_drafts.py 93 0 100%
|
||||||
|
app/api/docs.py 50 0 100%
|
||||||
|
app/api/git_sources.py 212 0 100%
|
||||||
|
app/api/health.py 10 0 100%
|
||||||
|
app/api/steering.py 42 0 100%
|
||||||
|
app/api/suggestions.py 8 0 100%
|
||||||
|
app/api/sync.py 101 0 100%
|
||||||
|
app/config.py 140 0 100%
|
||||||
|
app/core/__init__.py 0 0 100%
|
||||||
|
app/core/auth.py 20 0 100%
|
||||||
|
app/core/caching.py 108 0 100%
|
||||||
|
app/core/debugging.py 29 2 93%
|
||||||
|
app/core/docs_push.py 39 0 100%
|
||||||
|
app/core/logging.py 13 0 100%
|
||||||
|
app/db.py 21 0 100%
|
||||||
|
app/main.py 52 0 100%
|
||||||
|
app/models.py 86 0 100%
|
||||||
|
app/rag/__init__.py 0 0 100%
|
||||||
|
app/rag/agent.py 222 0 100%
|
||||||
|
app/rag/archive_upload.py 128 0 100%
|
||||||
|
app/rag/chunker.py 206 4 98%
|
||||||
|
app/rag/git_sources.py 14 0 100%
|
||||||
|
app/rag/importer.py 180 3 98%
|
||||||
|
app/rag/llm.py 216 0 100%
|
||||||
|
app/rag/overview.py 71 0 100%
|
||||||
|
app/rag/prompts.py 88 0 100%
|
||||||
|
app/rag/retriever.py 150 3 98%
|
||||||
|
app/rag/scaffolding.py 55 0 100%
|
||||||
|
app/rag/source_removal.py 41 0 100%
|
||||||
|
app/rag/sources_meta.py 16 0 100%
|
||||||
|
app/rag/suggestions.py 27 0 100%
|
||||||
|
app/rag/summarizer.py 24 0 100%
|
||||||
|
app/schemas.py 210 0 100%
|
||||||
|
-----------------------------------------------
|
||||||
|
TOTAL 2989 12 99%
|
||||||
|
coverage gate: app/ 99% (>90%) OK
|
||||||
|
All checks passed!
|
||||||
|
0 errors, 0 warnings, 0 informations
|
||||||
|
validation OK
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
All done. Final report:
|
||||||
|
|
||||||
|
**Phase 74 task 03 — complete**
|
||||||
|
|
||||||
|
- Implemented: mock `HISTORY_TRIGGER` (`echo my history`) with byte-stable stateless echo (`history: N prior messages; last answer tail: <24 chars>; thinking: yes|no`), checked before the `DEFLECT_MODE` branch; module docstring entry incl. the user/assistant-only history invariant; new E2E suite `tests/e2e/test_llm_history.py` (3 tests: grounded follow-up, cold-start, deflected follow-up — all byte-exact vs the persisted `bor.chat.v1` record).
|
||||||
|
- Gate questions verified against the app's real retrieval path (HIGH: 0.365/0.385 ≥ 0.30; deflected: 0.089, fts=0).
|
||||||
|
- E2E: `uv run pytest tests/e2e/test_llm_history.py -v --no-cov` → 3 passed (ran twice).
|
||||||
|
- Regressions in isolation, all green: chat_rag (3), chat_history (5), agent_document_tools (4), harness_aligned_tools (3), stop_generation (3), retry_answer (4), response_to_docs (4).
|
||||||
|
- `uv run pytest --cov=app --cov-report=term-missing` → 1547 passed, **99%** coverage (app/).
|
||||||
|
- `uv run ruff check .` → clean; `uv run pyright` → 0 errors.
|
||||||
|
- Committed `055c0b5 feat(rag): pass chat history with prior thinking to the LLM` (`--no-gpg-sign`, 29 files; records the harness moves of 73/74 task files to `complete/`).
|
||||||
|
- Deviations: (1) per harness rules I left `todo/74_llm_chat_history/` (00_phase + 03) in place — the harness moves it on success (same pattern as phase 73); (2) Playwright 1.62 sync API has no `expect.poll` — used `page.wait_for_function` for the record wait.
|
||||||
|
- Next pending task: `.agents/phases/todo/75_save_doc_full_session/01_session_transcript.md`.
|
||||||
+78
@@ -0,0 +1,78 @@
|
|||||||
|
........................................................................ [ 4%]
|
||||||
|
........................................................................ [ 9%]
|
||||||
|
........................................................................ [ 13%]
|
||||||
|
........................................................................ [ 18%]
|
||||||
|
........................................................................ [ 23%]
|
||||||
|
........................................................................ [ 27%]
|
||||||
|
........................................................................ [ 32%]
|
||||||
|
........................................................................ [ 37%]
|
||||||
|
........................................................................ [ 41%]
|
||||||
|
........................................................................ [ 46%]
|
||||||
|
........................................................................ [ 51%]
|
||||||
|
........................................................................ [ 55%]
|
||||||
|
........................................................................ [ 60%]
|
||||||
|
........................................................................ [ 65%]
|
||||||
|
........................................................................ [ 69%]
|
||||||
|
........................................................................ [ 74%]
|
||||||
|
........................................................................ [ 79%]
|
||||||
|
........................................................................ [ 83%]
|
||||||
|
........................................................................ [ 88%]
|
||||||
|
........................................................................ [ 93%]
|
||||||
|
........................................................................ [ 97%]
|
||||||
|
................................... [100%]
|
||||||
|
=============================== warnings summary ===============================
|
||||||
|
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||||
|
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||||
|
from starlette.testclient import TestClient as TestClient # noqa
|
||||||
|
|
||||||
|
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||||
|
================================ tests coverage ================================
|
||||||
|
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||||
|
|
||||||
|
Name Stmts Miss Cover
|
||||||
|
-----------------------------------------------
|
||||||
|
app/__init__.py 1 0 100%
|
||||||
|
app/api/__init__.py 0 0 100%
|
||||||
|
app/api/auth.py 22 0 100%
|
||||||
|
app/api/chat.py 177 0 100%
|
||||||
|
app/api/chats.py 110 0 100%
|
||||||
|
app/api/config.py 7 0 100%
|
||||||
|
app/api/doc_drafts.py 93 0 100%
|
||||||
|
app/api/docs.py 50 0 100%
|
||||||
|
app/api/git_sources.py 212 0 100%
|
||||||
|
app/api/health.py 10 0 100%
|
||||||
|
app/api/steering.py 42 0 100%
|
||||||
|
app/api/suggestions.py 8 0 100%
|
||||||
|
app/api/sync.py 101 0 100%
|
||||||
|
app/config.py 140 0 100%
|
||||||
|
app/core/__init__.py 0 0 100%
|
||||||
|
app/core/auth.py 20 0 100%
|
||||||
|
app/core/caching.py 108 0 100%
|
||||||
|
app/core/debugging.py 29 2 93%
|
||||||
|
app/core/docs_push.py 39 0 100%
|
||||||
|
app/core/logging.py 13 0 100%
|
||||||
|
app/db.py 21 0 100%
|
||||||
|
app/main.py 52 0 100%
|
||||||
|
app/models.py 86 0 100%
|
||||||
|
app/rag/__init__.py 0 0 100%
|
||||||
|
app/rag/agent.py 222 0 100%
|
||||||
|
app/rag/archive_upload.py 128 0 100%
|
||||||
|
app/rag/chunker.py 206 4 98%
|
||||||
|
app/rag/git_sources.py 14 0 100%
|
||||||
|
app/rag/importer.py 180 3 98%
|
||||||
|
app/rag/llm.py 216 0 100%
|
||||||
|
app/rag/overview.py 71 0 100%
|
||||||
|
app/rag/prompts.py 88 0 100%
|
||||||
|
app/rag/retriever.py 150 3 98%
|
||||||
|
app/rag/scaffolding.py 55 0 100%
|
||||||
|
app/rag/source_removal.py 41 0 100%
|
||||||
|
app/rag/sources_meta.py 16 0 100%
|
||||||
|
app/rag/suggestions.py 27 0 100%
|
||||||
|
app/rag/summarizer.py 24 0 100%
|
||||||
|
app/schemas.py 210 0 100%
|
||||||
|
-----------------------------------------------
|
||||||
|
TOTAL 2989 12 99%
|
||||||
|
coverage gate: app/ 99% (>90%) OK
|
||||||
|
All checks passed!
|
||||||
|
0 errors, 0 warnings, 0 informations
|
||||||
|
validation OK
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
All green. Final report:
|
||||||
|
|
||||||
|
**Phase 75 · Task 01 — `saveAsDoc` posts the full-session transcript**
|
||||||
|
|
||||||
|
- **`frontend/assets/app.js`**: added `buildSessionTranscript()` (next to `defaultDocTitle()`/`saveAsDoc()`) — one numbered section per user turn (`## N. <raw question>` + blank line + raw answer text), sections blank-line separated, trailing whitespace collapsed to a single final newline; only `m.who`/`m.text` travel (no thinking/sources/tune); orphan-brain and answerless-user edges handled. `saveAsDoc(btn)` now posts `body: buildSessionTranscript()`; dead `markdown` param dropped, single call site updated, `appendSaveAsDocButton(wrap, markdown)` signature kept; double-click guard + 201→`/doc-edit.html?draft=<token>` hand-off unchanged. Title/path rules untouched (A6).
|
||||||
|
- **Tests**: new unit pin `test_app_js_transcript_covers_the_whole_session` + updated `test_app_js_post_payload_and_navigation` in `tests/unit/test_save_as_doc_button.py`; phase-59 E2E body expectations moved to the single-turn transcript (new `transcript()` port helper) in `tests/e2e/test_response_to_docs.py` — push→git verification, guest/unconfigured pins unchanged; no backend change.
|
||||||
|
- **Builder byte-verified** via node harness against the real extracted function (single, multi, trailing-collapse, orphan, answerless cases).
|
||||||
|
- **Results**: `uv run pytest tests/e2e/test_response_to_docs.py -v --no-cov` → 4 passed; `uv run pytest` → 1548 passed; `uv run pytest --cov=app` → TOTAL 99% (>90%); `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors.
|
||||||
|
- **Decisions**: E2E expects the server-stored (`.strip()`-ed) transcript — the builder's final `\n` is stripped by the draft API, so the assertion helper omits it; no commit (phase 75's atomic commit belongs to task 02 per the phase overview).
|
||||||
|
- **Next pending task**: `.agents/phases/todo/75_save_doc_full_session/02_e2e_full_session_save.md`
|
||||||
+78
@@ -0,0 +1,78 @@
|
|||||||
|
........................................................................ [ 4%]
|
||||||
|
........................................................................ [ 9%]
|
||||||
|
........................................................................ [ 13%]
|
||||||
|
........................................................................ [ 18%]
|
||||||
|
........................................................................ [ 23%]
|
||||||
|
........................................................................ [ 27%]
|
||||||
|
........................................................................ [ 32%]
|
||||||
|
........................................................................ [ 37%]
|
||||||
|
........................................................................ [ 41%]
|
||||||
|
........................................................................ [ 46%]
|
||||||
|
........................................................................ [ 51%]
|
||||||
|
........................................................................ [ 55%]
|
||||||
|
........................................................................ [ 60%]
|
||||||
|
........................................................................ [ 65%]
|
||||||
|
........................................................................ [ 69%]
|
||||||
|
........................................................................ [ 74%]
|
||||||
|
........................................................................ [ 79%]
|
||||||
|
........................................................................ [ 83%]
|
||||||
|
........................................................................ [ 88%]
|
||||||
|
........................................................................ [ 93%]
|
||||||
|
........................................................................ [ 97%]
|
||||||
|
.................................... [100%]
|
||||||
|
=============================== warnings summary ===============================
|
||||||
|
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||||
|
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||||
|
from starlette.testclient import TestClient as TestClient # noqa
|
||||||
|
|
||||||
|
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||||
|
================================ tests coverage ================================
|
||||||
|
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||||
|
|
||||||
|
Name Stmts Miss Cover
|
||||||
|
-----------------------------------------------
|
||||||
|
app/__init__.py 1 0 100%
|
||||||
|
app/api/__init__.py 0 0 100%
|
||||||
|
app/api/auth.py 22 0 100%
|
||||||
|
app/api/chat.py 177 0 100%
|
||||||
|
app/api/chats.py 110 0 100%
|
||||||
|
app/api/config.py 7 0 100%
|
||||||
|
app/api/doc_drafts.py 93 0 100%
|
||||||
|
app/api/docs.py 50 0 100%
|
||||||
|
app/api/git_sources.py 212 0 100%
|
||||||
|
app/api/health.py 10 0 100%
|
||||||
|
app/api/steering.py 42 0 100%
|
||||||
|
app/api/suggestions.py 8 0 100%
|
||||||
|
app/api/sync.py 101 0 100%
|
||||||
|
app/config.py 140 0 100%
|
||||||
|
app/core/__init__.py 0 0 100%
|
||||||
|
app/core/auth.py 20 0 100%
|
||||||
|
app/core/caching.py 108 0 100%
|
||||||
|
app/core/debugging.py 29 2 93%
|
||||||
|
app/core/docs_push.py 39 0 100%
|
||||||
|
app/core/logging.py 13 0 100%
|
||||||
|
app/db.py 21 0 100%
|
||||||
|
app/main.py 52 0 100%
|
||||||
|
app/models.py 86 0 100%
|
||||||
|
app/rag/__init__.py 0 0 100%
|
||||||
|
app/rag/agent.py 222 0 100%
|
||||||
|
app/rag/archive_upload.py 128 0 100%
|
||||||
|
app/rag/chunker.py 206 4 98%
|
||||||
|
app/rag/git_sources.py 14 0 100%
|
||||||
|
app/rag/importer.py 180 3 98%
|
||||||
|
app/rag/llm.py 216 0 100%
|
||||||
|
app/rag/overview.py 71 0 100%
|
||||||
|
app/rag/prompts.py 88 0 100%
|
||||||
|
app/rag/retriever.py 150 3 98%
|
||||||
|
app/rag/scaffolding.py 55 0 100%
|
||||||
|
app/rag/source_removal.py 41 0 100%
|
||||||
|
app/rag/sources_meta.py 16 0 100%
|
||||||
|
app/rag/suggestions.py 27 0 100%
|
||||||
|
app/rag/summarizer.py 24 0 100%
|
||||||
|
app/schemas.py 210 0 100%
|
||||||
|
-----------------------------------------------
|
||||||
|
TOTAL 2989 12 99%
|
||||||
|
coverage gate: app/ 99% (>90%) OK
|
||||||
|
All checks passed!
|
||||||
|
0 errors, 0 warnings, 0 informations
|
||||||
|
validation OK
|
||||||
+68
-15
@@ -552,19 +552,24 @@ function markLastRetryable() {
|
|||||||
* answer, so m.stopped records never get it — the restore call site
|
* answer, so m.stopped records never get it — the restore call site
|
||||||
* gates on it). Gate: admin (the whoami gate Tune uses) AND a
|
* gates on it). Gate: admin (the whoami gate Tune uses) AND a
|
||||||
* configured docs repo (docsRepoConfigured — /api/config, settled in
|
* configured docs repo (docsRepoConfigured — /api/config, settled in
|
||||||
* the boot IIFE before any bubble renders). `markdown` is the RAW
|
* the boot IIFE before any bubble renders). `markdown` — the RAW
|
||||||
* persisted answer text — m.text on the restore path, the
|
* persisted answer text, m.text on the restore path, the
|
||||||
* done/fallback raw text on the live path — NEVER the rendered HTML.
|
* done/fallback raw text on the live path, NEVER the rendered HTML —
|
||||||
|
* stays in the signature (the three call sites are unchanged) but no
|
||||||
|
* longer travels: phase 75 (TODO L4, A6) drafts the WHOLE conversation
|
||||||
|
* from the `conversation` record at click time (buildSessionTranscript).
|
||||||
* The .save-as-doc-btn's margin-inline-start: auto pushes it to the
|
* The .save-as-doc-btn's margin-inline-start: auto pushes it to the
|
||||||
* row's right edge (the TODO's "bottom right"); markLastRetryable
|
* row's right edge (the TODO's "bottom right"); markLastRetryable
|
||||||
* keeps it rightmost when the last bubble also carries the Retry
|
* keeps it rightmost when the last bubble also carries the Retry
|
||||||
* button.
|
* button.
|
||||||
*
|
*
|
||||||
* Click: default title (the LAST user question, whitespace-collapsed,
|
* Click: default title (UNCHANGED by phase 75 — the LAST user
|
||||||
* ≤120 chars — the phase-50 auto-title convention) + default in-repo
|
* question, whitespace-collapsed, ≤120 chars, the phase-50 auto-title
|
||||||
* path (docs/<slug>.md) → POST /api/doc-drafts {title, path, body} →
|
* convention) + default in-repo path (docs/<slug>.md) + the
|
||||||
* 201 → /doc-edit.html?draft=<token> (the edit screen, task 06, owns
|
* FULL-SESSION transcript as the body (phase 75 A6) → POST
|
||||||
* the rest). Failure → the neutral one-line banner (phase-55
|
* /api/doc-drafts {title, path, body} → 201 →
|
||||||
|
* /doc-edit.html?draft=<token> (the edit screen, task 06, owns the
|
||||||
|
* rest). Failure → the neutral one-line banner (phase-55
|
||||||
* convention), the conversation unblocked, no navigation. */
|
* convention), the conversation unblocked, no navigation. */
|
||||||
const SAVE_AS_DOC_ICON =
|
const SAVE_AS_DOC_ICON =
|
||||||
'<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M14 3H6a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V8z"/><path d="M14 3v5h5"/><path d="M9 13h6M9 16h4"/></svg>';
|
'<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M14 3H6a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V8z"/><path d="M14 3v5h5"/><path d="M9 13h6M9 16h4"/></svg>';
|
||||||
@@ -603,6 +608,52 @@ function docSlug(title) {
|
|||||||
return slug || "note";
|
return slug || "note";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Phase 75 (TODO L4; A6, owner-confirmed 2026-09-08): the draft BODY
|
||||||
|
* is the WHOLE conversation — every user question and the brain
|
||||||
|
* answers that followed it, in order, as the RAW persisted text. The
|
||||||
|
* user edits out unwanted turns in the doc-edit body (A7 — the
|
||||||
|
* existing free-form body field; no new UI surface). The shape: a
|
||||||
|
* numbered section per USER turn —
|
||||||
|
*
|
||||||
|
* ## 1. <user question, raw text>
|
||||||
|
*
|
||||||
|
* <brain answer, raw text>
|
||||||
|
*
|
||||||
|
* ## 2. <user question, raw text>
|
||||||
|
*
|
||||||
|
* <brain answer, raw text>
|
||||||
|
*
|
||||||
|
* — 1-based per user turn (normally one answer per section; more
|
||||||
|
* answers join under the same heading), sections blank-line
|
||||||
|
* separated, ALL trailing whitespace collapsed to a single final
|
||||||
|
* newline. ONLY the raw text travels (m.who + m.text — nothing else
|
||||||
|
* off the record); a stopped/partial brain turn appears as-is (its
|
||||||
|
* text is what the user saw — A7); a brain record before the first
|
||||||
|
* user record (unproducible from the UI) is skipped, and a user turn
|
||||||
|
* whose brain record never landed is a heading-only section. */
|
||||||
|
function buildSessionTranscript() {
|
||||||
|
const sections = []; // { heading: "## N. <q>", answers: [raw text] }
|
||||||
|
let open = null; // answers of the current user section (null while
|
||||||
|
// no user record has opened one yet)
|
||||||
|
for (const m of conversation) {
|
||||||
|
if (m.who === "user") {
|
||||||
|
open = [];
|
||||||
|
sections.push({
|
||||||
|
heading: `## ${sections.length + 1}. ${m.text}`,
|
||||||
|
answers: open,
|
||||||
|
});
|
||||||
|
} else if (open) {
|
||||||
|
open.push(m.text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const body = sections
|
||||||
|
.map((s) =>
|
||||||
|
s.answers.length ? `${s.heading}\n\n${s.answers.join("\n\n")}` : s.heading
|
||||||
|
)
|
||||||
|
.join("\n\n");
|
||||||
|
return body.replace(/\s+$/, "") + "\n";
|
||||||
|
}
|
||||||
|
|
||||||
/* The bottom-right "Save as doc" button — the appendTuneButton
|
/* The bottom-right "Save as doc" button — the appendTuneButton
|
||||||
* pattern: reuses the .msg-meta row when it exists (role=list → the
|
* pattern: reuses the .msg-meta row when it exists (role=list → the
|
||||||
* button joins as a listitem so ARIA stays valid), otherwise creates
|
* button joins as a listitem so ARIA stays valid), otherwise creates
|
||||||
@@ -623,15 +674,17 @@ function appendSaveAsDocButton(wrap, markdown) {
|
|||||||
btn.className = "save-as-doc-btn"; // margin-inline-start: auto → bottom-right
|
btn.className = "save-as-doc-btn"; // margin-inline-start: auto → bottom-right
|
||||||
if (meta.getAttribute("role") === "list") btn.setAttribute("role", "listitem");
|
if (meta.getAttribute("role") === "list") btn.setAttribute("role", "listitem");
|
||||||
btn.innerHTML = SAVE_AS_DOC_ICON + "<span>Save as doc</span>";
|
btn.innerHTML = SAVE_AS_DOC_ICON + "<span>Save as doc</span>";
|
||||||
btn.addEventListener("click", () => saveAsDoc(btn, markdown));
|
btn.addEventListener("click", () => saveAsDoc(btn));
|
||||||
meta.appendChild(btn);
|
meta.appendChild(btn);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Create the draft from the bubble's RAW markdown and hand off to the
|
/* Create the draft from the FULL-SESSION transcript (phase 75 A6 —
|
||||||
* edit screen. Double-click guard: one save at a time (the button is
|
* every Q/A up to the click, in order — replacing the phase-59
|
||||||
* disabled until the outcome — released in the finally, never stale,
|
* single-bubble body; the title and path rules are unchanged) and
|
||||||
* PLAN §7.4). */
|
* hand off to the edit screen. Double-click guard: one save at a time
|
||||||
async function saveAsDoc(btn, markdown) {
|
* (the button is disabled until the outcome — released in the
|
||||||
|
* finally, never stale, PLAN §7.4). */
|
||||||
|
async function saveAsDoc(btn) {
|
||||||
if (btn.disabled) return; // one save at a time (double-click guard)
|
if (btn.disabled) return; // one save at a time (double-click guard)
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
try {
|
try {
|
||||||
@@ -640,7 +693,7 @@ async function saveAsDoc(btn, markdown) {
|
|||||||
const res = await fetch("/api/doc-drafts", {
|
const res = await fetch("/api/doc-drafts", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ title, path, body: markdown }),
|
body: JSON.stringify({ title, path, body: buildSessionTranscript() }),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
// Neutral one-line copy (phase-55 convention) — the detail may
|
// Neutral one-line copy (phase-55 convention) — the detail may
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ on PATH — the suite skips without it):
|
|||||||
The loop under test: a completed brain bubble carries a bottom-right
|
The loop under test: a completed brain bubble carries a bottom-right
|
||||||
"Save as doc" action (admin + a configured ``BOR_DOCS_REPO``) → it
|
"Save as doc" action (admin + a configured ``BOR_DOCS_REPO``) → it
|
||||||
opens ``/doc-edit.html?draft=<token>`` prefilled (auto-title from the
|
opens ``/doc-edit.html?draft=<token>`` prefilled (auto-title from the
|
||||||
last question, path ``docs/<slug>.md``, body = the answer's MARKDOWN
|
last question, path ``docs/<slug>.md``, body = the FULL-SESSION
|
||||||
SOURCE — never the rendered HTML) → Push commits + pushes to the
|
TRANSCRIPT — phase 75 A6; every session in this suite is single-turn,
|
||||||
|
so it is ``## 1. <the question>`` + the answer's markdown source —
|
||||||
|
never the rendered HTML) → Push commits + pushes to the
|
||||||
``.env``-configured branch of the ``.env``-configured repo. Every
|
``.env``-configured branch of the ``.env``-configured repo. Every
|
||||||
success assertion reads the **bare repo itself** (``git show
|
success assertion reads the **bare repo itself** (``git show
|
||||||
<branch>:<path>``, ``git rev-list``, ``git rev-parse``) — the UI text
|
<branch>:<path>``, ``git rev-list``, ``git rev-parse``) — the UI text
|
||||||
@@ -34,9 +36,9 @@ App boots (the conftest pattern, module-scoped — as in
|
|||||||
|
|
||||||
The mock LLM keeps every answer byte-deterministic: the suite replays
|
The mock LLM keeps every answer byte-deterministic: the suite replays
|
||||||
the same question through ``POST /api/chat`` (raw SSE, the
|
the same question through ``POST /api/chat`` (raw SSE, the
|
||||||
``test_chat_rag.py`` pattern) to recover the exact markdown source the
|
``test_chat_rag.py`` pattern) to recover the exact answer bytes the
|
||||||
draft must carry — so "body == the answer's markdown source" is an
|
draft must carry — so "body == the single-turn transcript of that
|
||||||
exact-byte assertion, not a contains check.
|
answer" is an exact-byte assertion, not a contains check.
|
||||||
|
|
||||||
Test → story mapping (Playwright Mapping Rule):
|
Test → story mapping (Playwright Mapping Rule):
|
||||||
1. ``test_save_edit_push``
|
1. ``test_save_edit_push``
|
||||||
@@ -142,6 +144,16 @@ def doc_slug(title: str) -> str:
|
|||||||
return slug or "note"
|
return slug or "note"
|
||||||
|
|
||||||
|
|
||||||
|
def transcript(question: str, answer: str) -> str:
|
||||||
|
"""The phase-75 draft body (app.js ``buildSessionTranscript``,
|
||||||
|
A6) for the SINGLE-turn sessions this suite drives, as stored:
|
||||||
|
``## 1. <question>`` + blank line + the answer's raw markdown.
|
||||||
|
The builder's single trailing newline is stripped by the draft
|
||||||
|
API's ``.strip()`` (and the edit screen's push trims again), so
|
||||||
|
the stored — and pushed — bytes end at the answer."""
|
||||||
|
return f"## 1. {question}\n\n{answer}"
|
||||||
|
|
||||||
|
|
||||||
def _admin_cookies(page: Page) -> dict[str, str]:
|
def _admin_cookies(page: Page) -> dict[str, str]:
|
||||||
"""The signed session cookies the browser holds after a form login
|
"""The signed session cookies the browser holds after a form login
|
||||||
— used to call the admin API with plain httpx (the
|
— used to call the admin API with plain httpx (the
|
||||||
@@ -440,18 +452,21 @@ def test_save_edit_push(
|
|||||||
expect(page.locator("#draft-path")).to_have_value(
|
expect(page.locator("#draft-path")).to_have_value(
|
||||||
f"docs/{doc_slug(QUESTION_1)}.md"
|
f"docs/{doc_slug(QUESTION_1)}.md"
|
||||||
)
|
)
|
||||||
# Body == the rendered answer's MARKDOWN SOURCE: the SSE replay
|
# Body == the FULL-SESSION transcript (phase 75 A6): this
|
||||||
# recovers the exact bytes the UI accumulated (the mock is
|
# session is single-turn, so it is "## 1. <the question>" + blank
|
||||||
|
# line + the answer's raw markdown. The SSE replay recovers the
|
||||||
|
# exact answer bytes the UI accumulated (the mock is
|
||||||
# byte-deterministic on the same KB + question) — and they are
|
# byte-deterministic on the same KB + question) — and they are
|
||||||
# plain markdown, not rendered HTML.
|
# plain markdown, not rendered HTML.
|
||||||
raw = _stream_chat_answer(app_url, QUESTION_1)
|
raw = _stream_chat_answer(app_url, QUESTION_1)
|
||||||
assert MOCK_ANSWER_MARKER in raw and QUESTION_1 in raw
|
assert MOCK_ANSWER_MARKER in raw and QUESTION_1 in raw
|
||||||
assert "<" not in raw and ">" not in raw, "the draft body must be markdown, not HTML"
|
assert "<" not in raw and ">" not in raw, "the draft body must be markdown, not HTML"
|
||||||
expect(page.locator("#draft-body")).to_have_value(raw)
|
body = transcript(QUESTION_1, raw)
|
||||||
|
expect(page.locator("#draft-body")).to_have_value(body)
|
||||||
|
|
||||||
# Modify the doc (the story's "modify before [pushing]"): a
|
# Modify the doc (the story's "modify before [pushing]"): a
|
||||||
# distinctive marker line the bare repo must show after the push.
|
# distinctive marker line the bare repo must show after the push.
|
||||||
edited = f"{raw}\n\n{E2E_MARKER}"
|
edited = f"{body}\n\n{E2E_MARKER}"
|
||||||
page.fill("#draft-body", edited)
|
page.fill("#draft-body", edited)
|
||||||
|
|
||||||
# Push → the live region reports the branch + a 7-char commit sha…
|
# Push → the live region reports the branch + a 7-char commit sha…
|
||||||
@@ -491,7 +506,9 @@ def test_second_push_fast_forwards(
|
|||||||
_login_admin(page, app_url)
|
_login_admin(page, app_url)
|
||||||
_ask(page, app_url, QUESTION_2)
|
_ask(page, app_url, QUESTION_2)
|
||||||
|
|
||||||
# Save the second answer (different question → different slug)…
|
# Save the second answer (different question → different slug);
|
||||||
|
# its session is single-turn too, so the body is the single-turn
|
||||||
|
# transcript of that answer…
|
||||||
expect(page.locator(".msg.brain .save-as-doc-btn")).to_have_count(1)
|
expect(page.locator(".msg.brain .save-as-doc-btn")).to_have_count(1)
|
||||||
_open_edit_screen(page)
|
_open_edit_screen(page)
|
||||||
expect(page.locator("#draft-title")).to_have_value(QUESTION_2)
|
expect(page.locator("#draft-title")).to_have_value(QUESTION_2)
|
||||||
@@ -499,7 +516,7 @@ def test_second_push_fast_forwards(
|
|||||||
f"docs/{doc_slug(QUESTION_2)}.md"
|
f"docs/{doc_slug(QUESTION_2)}.md"
|
||||||
)
|
)
|
||||||
raw2 = _stream_chat_answer(app_url, QUESTION_2)
|
raw2 = _stream_chat_answer(app_url, QUESTION_2)
|
||||||
expect(page.locator("#draft-body")).to_have_value(raw2)
|
expect(page.locator("#draft-body")).to_have_value(transcript(QUESTION_2, raw2))
|
||||||
|
|
||||||
# …and push WITHOUT editing — a new commit on the same branch.
|
# …and push WITHOUT editing — a new commit on the same branch.
|
||||||
_push_and_read_sha(page)
|
_push_and_read_sha(page)
|
||||||
@@ -511,13 +528,21 @@ def test_second_push_fast_forwards(
|
|||||||
.strip()
|
.strip()
|
||||||
== "2"
|
== "2"
|
||||||
)
|
)
|
||||||
# …file 2 landed with its unedited body…
|
# …file 2 landed with its unedited body (the single-turn
|
||||||
|
# transcript)…
|
||||||
path2 = f"docs/{doc_slug(QUESTION_2)}.md"
|
path2 = f"docs/{doc_slug(QUESTION_2)}.md"
|
||||||
assert _git(["-C", str(docs_repo.bare), "show", f"{BRANCH}:{path2}"]) == raw2
|
assert (
|
||||||
|
_git(["-C", str(docs_repo.bare), "show", f"{BRANCH}:{path2}"])
|
||||||
|
== transcript(QUESTION_2, raw2)
|
||||||
|
)
|
||||||
# …and file 1 from test 1 is still at its path, byte-for-byte
|
# …and file 1 from test 1 is still at its path, byte-for-byte
|
||||||
# (deterministic reconstruction: the mock answer + the marker line).
|
# (deterministic reconstruction: the single-turn transcript + the
|
||||||
|
# marker line).
|
||||||
path1 = f"docs/{doc_slug(QUESTION_1)}.md"
|
path1 = f"docs/{doc_slug(QUESTION_1)}.md"
|
||||||
expected_first = f"{_stream_chat_answer(app_url, QUESTION_1)}\n\n{E2E_MARKER}"
|
expected_first = (
|
||||||
|
f"{transcript(QUESTION_1, _stream_chat_answer(app_url, QUESTION_1))}"
|
||||||
|
f"\n\n{E2E_MARKER}"
|
||||||
|
)
|
||||||
assert _git(["-C", str(docs_repo.bare), "show", f"{BRANCH}:{path1}"]) == expected_first
|
assert _git(["-C", str(docs_repo.bare), "show", f"{BRANCH}:{path1}"]) == expected_first
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,549 @@
|
|||||||
|
"""Phase 75 story E2E (Playwright): "Save as doc" captures the WHOLE
|
||||||
|
chat session.
|
||||||
|
|
||||||
|
TODO.md L4 (owner 2026-09-05): "Then, update the 'save as doc'
|
||||||
|
process to include the output from the entire chat session rather than
|
||||||
|
the last response. The user can edit out anything they don't want to
|
||||||
|
keep from previous replies."
|
||||||
|
|
||||||
|
Run in isolation (DB must be up: ``podman compose up -d db``; ``git``
|
||||||
|
on PATH — the suite skips without it):
|
||||||
|
|
||||||
|
uv run pytest tests/e2e/test_save_doc_session.py -v --no-cov
|
||||||
|
|
||||||
|
The loop under test: a MULTI-turn session (three DISTINCT on-topic
|
||||||
|
questions in one chat page — the mock's default composed answer embeds
|
||||||
|
each question's first 80 chars, so the three answers are byte-distinct
|
||||||
|
and assertable) → "Save as doc" on any completed brain bubble drafts
|
||||||
|
the FULL-SESSION transcript (phase 75 A6: ``## N. <question>`` + the
|
||||||
|
answer's raw markdown, every turn up to the click, in order — NOT just
|
||||||
|
the clicked bubble) → the edit screen shows it prefilled → the user
|
||||||
|
edits an unwanted previous reply OUT of the body (A7: the existing
|
||||||
|
free-form body field) → Push commits + pushes to the ``.env``-
|
||||||
|
configured branch of the ``.env``-configured repo. Every success
|
||||||
|
assertion reads the **bare repo itself** (``git show <branch>:<path>``
|
||||||
|
== the EDITED body byte-for-byte; ``git rev-parse`` for the sha the UI
|
||||||
|
reported) — the UI text is only the entry point (the phase-59
|
||||||
|
convention, D3: no PR is ever created or attempted).
|
||||||
|
|
||||||
|
Turn 1 is asked with the phase-17 ``think out loud`` trigger, so its
|
||||||
|
brain record carries a ``thinking`` block (the deterministic
|
||||||
|
scratchpad) — the transcript must EXCLUDE it (A6: only the raw
|
||||||
|
``m.text`` travels), and both the draft body and the pushed file are
|
||||||
|
asserted free of the scratchpad text.
|
||||||
|
|
||||||
|
App boots (the conftest pattern, module-scoped — as in
|
||||||
|
``test_response_to_docs.py``):
|
||||||
|
|
||||||
|
* the module app boots with ``BOR_DOCS_REPO=<tmp>/docs.git`` (a local
|
||||||
|
BARE repo seeded with one commit on ``main``), ``BOR_DOCS_BRANCH=
|
||||||
|
bor-docs``, ``BOR_DOCS_BASE_BRANCH=main``, ``BOR_DOCS_WORK_DIR=
|
||||||
|
<tmp>/docs-work``;
|
||||||
|
* the KB is the ``tests/fixtures/docs/`` set (the
|
||||||
|
``test_chat_rag.py`` fixture) — the three questions gate HIGH, so
|
||||||
|
every turn is a grounded answer with the deterministic marker.
|
||||||
|
|
||||||
|
Test → story mapping (Playwright Mapping Rule):
|
||||||
|
1. ``test_full_session_save_and_edit_out``
|
||||||
|
2. ``test_earlier_bubble_button_saves_whole_session``
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import parse_qs, urlsplit
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
from playwright.sync_api import Locator, Page, expect
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from app.config import Settings
|
||||||
|
from app.db import SessionLocal
|
||||||
|
from app.rag.importer import ImportSummary, import_sources
|
||||||
|
from app.rag.llm import LLMClient
|
||||||
|
from e2e.auth_helpers import login
|
||||||
|
from e2e.conftest import (
|
||||||
|
ADMIN_PASSWORD,
|
||||||
|
APP_PORT,
|
||||||
|
SESSION_SECRET,
|
||||||
|
USE_REAL_LLM,
|
||||||
|
_wait_http,
|
||||||
|
)
|
||||||
|
|
||||||
|
REPO = Path(__file__).resolve().parents[2]
|
||||||
|
FIXTURES = REPO / "tests" / "fixtures" / "docs"
|
||||||
|
|
||||||
|
APP_URL = f"http://127.0.0.1:{APP_PORT}"
|
||||||
|
|
||||||
|
BRANCH = "bor-docs"
|
||||||
|
BASE_BRANCH = "main"
|
||||||
|
|
||||||
|
#: Three DISTINCT on-topic questions in ONE session — the house
|
||||||
|
#: phrasings proven HIGH-gate in other suites, so every turn renders a
|
||||||
|
#: grounded answer with the deterministic marker (never a deflection).
|
||||||
|
#: Turn 1 carries the phase-17 thinking trigger (its brain record then
|
||||||
|
#: carries a ``thinking`` block the transcript must exclude); the
|
||||||
|
#: phase-74 suite pins the exact grounded behavior of this phrasing.
|
||||||
|
Q1 = "think out loud — how is my Kubernetes cluster set up?"
|
||||||
|
Q2 = "What is in the new-service deployment?"
|
||||||
|
Q3 = "How did I install gitlab?"
|
||||||
|
|
||||||
|
MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E"
|
||||||
|
|
||||||
|
#: Fixed lines of the mock's deterministic scratchpad
|
||||||
|
#: (``mock_llm.compose_thinking``) — present in turn 1's persisted
|
||||||
|
#: ``thinking`` block, and ABSENT from every composed answer and from
|
||||||
|
#: the questions themselves, so their absence from the draft body and
|
||||||
|
#: the pushed file proves the thinking block never reached the doc.
|
||||||
|
THINKING_LINES = (
|
||||||
|
"Step 1: Read the question carefully",
|
||||||
|
"Scratch 3: versions and ports are the facts",
|
||||||
|
)
|
||||||
|
|
||||||
|
#: The edit screen's URL shape (the save action navigates with the
|
||||||
|
#: uuid4 token).
|
||||||
|
DRAFT_URL_RE = re.compile(r"/doc-edit\.html\?draft=[0-9a-f-]{36}")
|
||||||
|
#: The success line (doc-edit.js): `Pushed to <branch> — commit <sha7>.`
|
||||||
|
SUCCESS_SHA_RE = re.compile(r"commit ([0-9a-f]{7})\.$")
|
||||||
|
|
||||||
|
|
||||||
|
def _git_available() -> bool:
|
||||||
|
try:
|
||||||
|
return subprocess.run(
|
||||||
|
["git", "--version"], capture_output=True, timeout=10
|
||||||
|
).returncode == 0
|
||||||
|
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.skipif(
|
||||||
|
not _git_available(), reason="git is not on PATH (the docs push is real git)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _git(args: list[str], cwd: Path | None = None) -> str:
|
||||||
|
"""One git command (the bare repo is the source of truth); fail loud."""
|
||||||
|
proc = subprocess.run(
|
||||||
|
["git", *args], cwd=cwd, capture_output=True, text=True, timeout=60
|
||||||
|
)
|
||||||
|
assert proc.returncode == 0, f"git {' '.join(args)} failed: {proc.stderr}"
|
||||||
|
return proc.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def _branch_tip(bare: Path) -> str | None:
|
||||||
|
"""The branch's tip sha, or ``None`` while the branch does not
|
||||||
|
exist yet (a cancel-only test may run before any push created it)."""
|
||||||
|
proc = subprocess.run(
|
||||||
|
["git", "-C", str(bare), "rev-parse", BRANCH],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
return proc.stdout.strip() if proc.returncode == 0 else None
|
||||||
|
|
||||||
|
|
||||||
|
def doc_slug(title: str) -> str:
|
||||||
|
"""The app.js slug rule (phase 59 locked assumption), ported:
|
||||||
|
lowercase, runs of non-alphanumerics → ``-``, trimmed, ≤60 chars,
|
||||||
|
empty → ``note`` (the trailing trim survives a mid-dash 60-cut)."""
|
||||||
|
slug = (
|
||||||
|
re.sub(r"[^a-z0-9]+", "-", title.lower())
|
||||||
|
.strip("-")[:60]
|
||||||
|
.rstrip("-")
|
||||||
|
)
|
||||||
|
return slug or "note"
|
||||||
|
|
||||||
|
|
||||||
|
def session_transcript(turns: list[tuple[str, str]]) -> str:
|
||||||
|
"""The phase-75 draft body (app.js ``buildSessionTranscript``, A6)
|
||||||
|
for an N-turn session, as STORED: a numbered section per user turn
|
||||||
|
(``## N. <question>`` + blank line + the answer's raw markdown),
|
||||||
|
sections blank-line separated. The builder's single trailing
|
||||||
|
newline is stripped by the draft API's ``.strip()`` (and the edit
|
||||||
|
screen's push trims again), so the stored — and pushed — bytes end
|
||||||
|
at the last answer's last char (``rstrip`` mirrors both)."""
|
||||||
|
sections = [
|
||||||
|
f"## {i}. {question}\n\n{answer}"
|
||||||
|
for i, (question, answer) in enumerate(turns, start=1)
|
||||||
|
]
|
||||||
|
return "\n\n".join(sections).rstrip()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def docs_repo(tmp_path_factory: pytest.TempPathFactory) -> SimpleNamespace:
|
||||||
|
"""The local BARE docs repo (the .env remote, D3-generic): one
|
||||||
|
seed commit (``README.md``) pushed as ``main``. ``work`` is where
|
||||||
|
the app's ``BOR_DOCS_WORK_DIR`` checkout lands (it persists for the
|
||||||
|
whole module — the push exercises the existing-checkout path)."""
|
||||||
|
base = tmp_path_factory.mktemp("docs-git")
|
||||||
|
bare = base / "docs.git"
|
||||||
|
_git(["init", "--bare", str(bare)])
|
||||||
|
seed = base / "seed"
|
||||||
|
_git(["init", "-b", "main", str(seed)])
|
||||||
|
(seed / "README.md").write_text("# e2e docs repo\n", encoding="utf-8")
|
||||||
|
_git(["add", "--", "README.md"], cwd=seed)
|
||||||
|
# -c identity + no GPG signing: the machine's global git config
|
||||||
|
# (gpgsign=true here) must not leak into the fixture.
|
||||||
|
_git(
|
||||||
|
[
|
||||||
|
"-c", "user.name=E2E Seeder",
|
||||||
|
"-c", "user.email=e2e@local",
|
||||||
|
"-c", "commit.gpgsign=false",
|
||||||
|
"commit", "-m", "seed: README",
|
||||||
|
],
|
||||||
|
cwd=seed,
|
||||||
|
)
|
||||||
|
_git(["remote", "add", "origin", str(bare)], cwd=seed)
|
||||||
|
_git(["push", "origin", "main"], cwd=seed)
|
||||||
|
return SimpleNamespace(bare=bare, work=base / "docs-work")
|
||||||
|
|
||||||
|
|
||||||
|
def _spawn_app(port: int, mock_port: int, docs_env: dict[str, str]) -> subprocess.Popen:
|
||||||
|
"""One uvicorn boot (the conftest app_server env shape, the
|
||||||
|
``test_response_to_docs.py`` pattern)."""
|
||||||
|
env = dict(os.environ)
|
||||||
|
env.pop("DEBUGPY", None)
|
||||||
|
env["BOR_ENVIRONMENT"] = "e2e"
|
||||||
|
env["BOR_STATIC_DIR"] = str(REPO / "frontend")
|
||||||
|
env["BOR_LLM_BASE_URL"] = (
|
||||||
|
"https://aipi.reeseapps.com/v1" if USE_REAL_LLM else f"http://127.0.0.1:{mock_port}/v1"
|
||||||
|
)
|
||||||
|
# Mock-calibrated threshold (conftest pattern): the fixture questions
|
||||||
|
# gate HIGH, so every turn is a grounded answer with the marker.
|
||||||
|
env["BOR_RELEVANCE_THRESHOLD"] = "0.30"
|
||||||
|
env.setdefault(
|
||||||
|
"BOR_DATABASE_URL",
|
||||||
|
"postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese",
|
||||||
|
)
|
||||||
|
# Phase 16: admin auth must be set or create_app() refuses to boot.
|
||||||
|
env["BOR_ADMIN_PASSWORD"] = ADMIN_PASSWORD
|
||||||
|
env["BOR_SESSION_SECRET"] = SESSION_SECRET
|
||||||
|
env.update(docs_env)
|
||||||
|
return subprocess.Popen(
|
||||||
|
[sys.executable, "-m", "uvicorn", "app.main:app",
|
||||||
|
"--host", "127.0.0.1", "--port", str(port), "--log-level", "warning"],
|
||||||
|
cwd=REPO,
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _stop(proc: subprocess.Popen) -> None:
|
||||||
|
proc.terminate()
|
||||||
|
try:
|
||||||
|
proc.wait(timeout=10)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
proc.kill()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def app_server(mock_llm: int, docs_repo: SimpleNamespace) -> Iterator[str]:
|
||||||
|
"""The configured app under test (module scope — shadows the
|
||||||
|
conftest session app; an isolated run never starts two)."""
|
||||||
|
proc = _spawn_app(
|
||||||
|
APP_PORT,
|
||||||
|
mock_llm,
|
||||||
|
{
|
||||||
|
"BOR_DOCS_REPO": str(docs_repo.bare),
|
||||||
|
"BOR_DOCS_BRANCH": BRANCH,
|
||||||
|
"BOR_DOCS_BASE_BRANCH": BASE_BRANCH,
|
||||||
|
"BOR_DOCS_WORK_DIR": str(docs_repo.work),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
_wait_http(f"{APP_URL}/api/health")
|
||||||
|
yield APP_URL
|
||||||
|
finally:
|
||||||
|
_stop(proc)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def app_url(app_server: str) -> str:
|
||||||
|
return app_server
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# KB + table hygiene (the E2E isolation pattern — this suite owns the
|
||||||
|
# KB tables and doc_drafts; both are reset around every test)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def _import_fixtures(mock_port: int) -> ImportSummary:
|
||||||
|
kwargs: dict[str, Any] = {
|
||||||
|
"_env_file": None,
|
||||||
|
"llm_base_url": f"http://127.0.0.1:{mock_port}/v1",
|
||||||
|
}
|
||||||
|
settings = Settings(**kwargs) # pyright: ignore[reportCallIssue]
|
||||||
|
return await import_sources([FIXTURES], LLMClient(settings))
|
||||||
|
|
||||||
|
|
||||||
|
def _run_in_thread(coro: Any) -> Any:
|
||||||
|
"""Run a coroutine on a worker thread (the Playwright sync API keeps
|
||||||
|
an asyncio loop on the test thread — the test_chat_rag.py helper)."""
|
||||||
|
import threading
|
||||||
|
|
||||||
|
box: dict[str, Any] = {}
|
||||||
|
|
||||||
|
def runner() -> None:
|
||||||
|
try:
|
||||||
|
box["value"] = asyncio.run(coro)
|
||||||
|
except BaseException as e: # noqa: BLE001 — re-raised on the test thread
|
||||||
|
box["error"] = e
|
||||||
|
|
||||||
|
t = threading.Thread(target=runner)
|
||||||
|
t.start()
|
||||||
|
t.join()
|
||||||
|
if "error" in box:
|
||||||
|
raise box["error"]
|
||||||
|
return box["value"]
|
||||||
|
|
||||||
|
|
||||||
|
def _reset_db(mock_port: int, seed: bool) -> None:
|
||||||
|
with SessionLocal() as db:
|
||||||
|
db.execute(text("TRUNCATE chunks, documents, query_log, doc_drafts"))
|
||||||
|
db.commit()
|
||||||
|
if seed:
|
||||||
|
summary = _run_in_thread(_import_fixtures(mock_port))
|
||||||
|
assert summary.added == 13 # the A9 fixture set (test_chat_rag.py)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _kb_and_clean_drafts(mock_llm: int, db_ready: None) -> Iterator[None]:
|
||||||
|
"""Fresh KB (the deterministic mock embeddings — the grounded
|
||||||
|
questions gate HIGH) + an empty ``doc_drafts`` table per test."""
|
||||||
|
_reset_db(mock_llm, seed=True)
|
||||||
|
yield
|
||||||
|
_reset_db(mock_llm, seed=False)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Story helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _stream_chat_answer(app_url: str, message: str) -> str:
|
||||||
|
"""Replay one turn through the raw SSE endpoint (the
|
||||||
|
``test_chat_rag.py`` transport pattern) and return the EXACT answer
|
||||||
|
text — the markdown source the UI accumulates into ``m.text``,
|
||||||
|
byte-identical for the deterministic mock. The mock's composed
|
||||||
|
answer is a pure function of the LAST user message + the document
|
||||||
|
context (both identical whether or not the browser's phase-74
|
||||||
|
history rode along), so a bare replay recovers the same bytes the
|
||||||
|
multi-turn browser session rendered."""
|
||||||
|
frames: list[dict[str, Any]] = []
|
||||||
|
with httpx.stream(
|
||||||
|
"POST", f"{app_url}/api/chat", json={"message": message}, timeout=120.0
|
||||||
|
) as r:
|
||||||
|
assert r.status_code == 200
|
||||||
|
buf = ""
|
||||||
|
for part in r.iter_text():
|
||||||
|
buf += part
|
||||||
|
while "\n\n" in buf:
|
||||||
|
frame, buf = buf.split("\n\n", 1)
|
||||||
|
if frame.strip().startswith("data:"):
|
||||||
|
frames.append(
|
||||||
|
json.loads(frame.strip().removeprefix("data:").strip())
|
||||||
|
)
|
||||||
|
deltas = [f for f in frames if f.get("type") == "delta"]
|
||||||
|
assert deltas, "the SSE stream must deliver deltas"
|
||||||
|
return "".join(d["text"] for d in deltas)
|
||||||
|
|
||||||
|
|
||||||
|
def _ask(page: Page, question: str) -> None:
|
||||||
|
"""One grounded turn to its DONE state — the phase-74 pattern: the
|
||||||
|
LAST user bubble carries the question, the LAST brain bubble the
|
||||||
|
marker, and the Send button is re-enabled (``done`` settled the
|
||||||
|
turn; the meta-row buttons have landed)."""
|
||||||
|
page.fill("#message-input", question)
|
||||||
|
page.click("#send-btn")
|
||||||
|
expect(page.locator(".msg.user .bubble").last).to_contain_text(question)
|
||||||
|
expect(page.locator(".msg.brain .bubble").last).to_contain_text(
|
||||||
|
MOCK_ANSWER_MARKER, timeout=60_000
|
||||||
|
)
|
||||||
|
expect(page.locator("#send-label")).to_have_text("Send")
|
||||||
|
|
||||||
|
|
||||||
|
def _session(page: Page, app_url: str) -> list[tuple[str, str]]:
|
||||||
|
"""Drive the three-turn session and return each turn's EXACT
|
||||||
|
answer bytes (distinct: the composed answer embeds the question's
|
||||||
|
first 80 chars, and the three questions differ)."""
|
||||||
|
_ask(page, Q1)
|
||||||
|
_ask(page, Q2)
|
||||||
|
_ask(page, Q3)
|
||||||
|
answers = [
|
||||||
|
_stream_chat_answer(app_url, q) for q in (Q1, Q2, Q3)
|
||||||
|
]
|
||||||
|
for q, a in zip((Q1, Q2, Q3), answers, strict=True):
|
||||||
|
assert q in a, f"the composed answer must quote its question: {a!r}"
|
||||||
|
assert MOCK_ANSWER_MARKER in a
|
||||||
|
assert len(set(answers)) == 3, "the three answers must be byte-distinct"
|
||||||
|
return list(zip((Q1, Q2, Q3), answers, strict=True))
|
||||||
|
|
||||||
|
|
||||||
|
def _login_admin(page: Page, app_url: str) -> None:
|
||||||
|
"""Real form login landing on the chat (admin settled)."""
|
||||||
|
login(page, app_url, next="/")
|
||||||
|
expect(page).to_have_url(app_url + "/", timeout=30_000)
|
||||||
|
expect(page.locator("#sign-out-btn")).to_be_visible(timeout=30_000)
|
||||||
|
|
||||||
|
|
||||||
|
def _open_edit_screen(page: Page, btn: Locator) -> str:
|
||||||
|
"""Click ONE bubble's save action, wait for the navigation, return
|
||||||
|
the draft token from the URL (the uuid4 credential)."""
|
||||||
|
btn.click()
|
||||||
|
page.wait_for_url(DRAFT_URL_RE, timeout=30_000)
|
||||||
|
token = parse_qs(urlsplit(page.url).query).get("draft", [""])[0]
|
||||||
|
assert re.fullmatch(r"[0-9a-f-]{36}", token), f"no draft token in {page.url}"
|
||||||
|
expect(page.locator("#doc-edit-gate")).to_be_hidden(timeout=30_000)
|
||||||
|
expect(page.locator("#doc-edit-content")).to_be_visible(timeout=30_000)
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def _push_and_read_sha(page: Page) -> tuple[str, str]:
|
||||||
|
"""Submit the edit screen's push; wait for the success line and
|
||||||
|
return (branch, sha7) exactly as the live region reported them."""
|
||||||
|
page.click("#push-doc-btn")
|
||||||
|
status = page.locator("#push-status")
|
||||||
|
expect(status).to_contain_text(f"Pushed to {BRANCH}", timeout=60_000)
|
||||||
|
line = status.inner_text().strip()
|
||||||
|
m = SUCCESS_SHA_RE.search(line)
|
||||||
|
assert m, f"the success line carries no commit sha: {line!r}"
|
||||||
|
return BRANCH, m.group(1)
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_no_thinking_leak(body: str) -> None:
|
||||||
|
"""A6: the transcript carries ONLY the raw m.text of each record —
|
||||||
|
turn 1's persisted ``thinking`` block (the deterministic
|
||||||
|
scratchpad) must never reach the document."""
|
||||||
|
for line in THINKING_LINES:
|
||||||
|
assert line not in body, f"thinking scratchpad text leaked into the doc: {line!r}"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 1. The whole loop: 3 turns → save → all turns in order → edit a
|
||||||
|
# previous reply out → push → the bare repo agrees byte-for-byte
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_full_session_save_and_edit_out(
|
||||||
|
page: Page,
|
||||||
|
app_url: str,
|
||||||
|
mock_llm: int,
|
||||||
|
db_ready: None,
|
||||||
|
docs_repo: SimpleNamespace,
|
||||||
|
) -> None:
|
||||||
|
page.set_default_timeout(30_000)
|
||||||
|
_login_admin(page, app_url)
|
||||||
|
turns = _session(page, app_url)
|
||||||
|
(q1, a1), (q2, a2), (q3, a3) = turns
|
||||||
|
|
||||||
|
# Every completed brain bubble carries the bottom-right action…
|
||||||
|
expect(page.locator(".msg.brain .save-as-doc-btn")).to_have_count(3)
|
||||||
|
# …and the one on the LAST bubble opens the edit screen…
|
||||||
|
_open_edit_screen(page, page.locator(".msg.brain .save-as-doc-btn").last)
|
||||||
|
|
||||||
|
# Title / path: UNCHANGED by phase 75 — the last question (the
|
||||||
|
# phase-50 auto-title convention; Q3 is whitespace-free and
|
||||||
|
# ≤120 chars, so it arrives verbatim) + docs/<slug>.md.
|
||||||
|
expect(page.locator("#draft-title")).to_have_value(q3)
|
||||||
|
expect(page.locator("#draft-path")).to_have_value(f"docs/{doc_slug(q3)}.md")
|
||||||
|
|
||||||
|
# Body: the WHOLE session (A6) — ## 1./## 2./## 3. IN ORDER, each
|
||||||
|
# followed by that turn's answer byte-exact against the mock
|
||||||
|
# (never the clicked bubble alone, never rendered HTML).
|
||||||
|
expected = session_transcript(turns)
|
||||||
|
expect(page.locator("#draft-body")).to_have_value(expected)
|
||||||
|
body = page.input_value("#draft-body")
|
||||||
|
i1, i2, i3 = (
|
||||||
|
body.index(f"## {i}. {q}") for i, q in ((1, q1), (2, q2), (3, q3))
|
||||||
|
)
|
||||||
|
assert i1 < i2 < i3, "the sections must appear in session order"
|
||||||
|
for _q, a in turns:
|
||||||
|
assert a in body, f"an answer is missing from the transcript: {a[:60]!r}…"
|
||||||
|
_assert_no_thinking_leak(body)
|
||||||
|
assert "<" not in body and ">" not in body, (
|
||||||
|
"the draft body must be markdown, not HTML"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Edit out a PREVIOUS reply (A7: the free-form body field is the
|
||||||
|
# user's means) — delete the entire section-2 block (heading +
|
||||||
|
# answer) and push.
|
||||||
|
edited = f"## 1. {q1}\n\n{a1}\n\n## 3. {q3}\n\n{a3}".rstrip()
|
||||||
|
page.fill("#draft-body", edited)
|
||||||
|
branch, sha7 = _push_and_read_sha(page)
|
||||||
|
assert branch == BRANCH
|
||||||
|
|
||||||
|
# GIT-VERIFY: the bare repo's file is EXACTLY the edited body…
|
||||||
|
path = f"docs/{doc_slug(q3)}.md"
|
||||||
|
shown = _git(["-C", str(docs_repo.bare), "show", f"{BRANCH}:{path}"])
|
||||||
|
assert shown == edited
|
||||||
|
# …section 2 is provably gone (both question and answer)…
|
||||||
|
assert q2 not in shown, "section 2's question survived the edit-out"
|
||||||
|
assert a2 not in shown, "section 2's answer survived the edit-out"
|
||||||
|
# …sections 1 and 3 are byte-exact and in order…
|
||||||
|
assert shown.index(f"## 1. {q1}") < shown.index(f"## 3. {q3}")
|
||||||
|
assert a1 in shown and a3 in shown
|
||||||
|
# …and the UI's sha prefix is the branch's real tip.
|
||||||
|
tip = _git(["-C", str(docs_repo.bare), "rev-parse", BRANCH]).strip()
|
||||||
|
assert tip.startswith(sha7), f"UI sha {sha7} != bare repo tip {tip}"
|
||||||
|
_assert_no_thinking_leak(shown)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 2. The button on an EARLIER bubble still drafts the whole session
|
||||||
|
# (A6: the transcript is the session at click time, not the bubble),
|
||||||
|
# and canceling leaves the repo untouched
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_earlier_bubble_button_saves_whole_session(
|
||||||
|
page: Page,
|
||||||
|
app_url: str,
|
||||||
|
mock_llm: int,
|
||||||
|
db_ready: None,
|
||||||
|
docs_repo: SimpleNamespace,
|
||||||
|
) -> None:
|
||||||
|
page.set_default_timeout(30_000)
|
||||||
|
_login_admin(page, app_url)
|
||||||
|
turns = _session(page, app_url)
|
||||||
|
(q1, _a1), (_q2, _a2), (q3, _a3) = turns
|
||||||
|
|
||||||
|
expect(page.locator(".msg.brain .save-as-doc-btn")).to_have_count(3)
|
||||||
|
|
||||||
|
# The branch tip BEFORE the attempt (None while no push has ever
|
||||||
|
# created it — the test must pass in file order AND alone).
|
||||||
|
tip_before = _branch_tip(docs_repo.bare)
|
||||||
|
|
||||||
|
# Click the save action on the FIRST brain bubble — the draft must
|
||||||
|
# still carry the ENTIRE session (all three sections, byte-exact)…
|
||||||
|
_open_edit_screen(
|
||||||
|
page, page.locator(".msg.brain .save-as-doc-btn").first
|
||||||
|
)
|
||||||
|
# …with the UNCHANGED title (the last question, not the first).
|
||||||
|
expect(page.locator("#draft-title")).to_have_value(q3)
|
||||||
|
expect(page.locator("#draft-path")).to_have_value(f"docs/{doc_slug(q3)}.md")
|
||||||
|
body = session_transcript(turns)
|
||||||
|
expect(page.locator("#draft-body")).to_have_value(body)
|
||||||
|
_assert_no_thinking_leak(body)
|
||||||
|
|
||||||
|
# …then cancel out (Back to chat — NO push): the branch is
|
||||||
|
# untouched — same tip as before (or still absent).
|
||||||
|
page.click("#doc-edit-content a.doc-edit-back")
|
||||||
|
page.wait_for_url(APP_URL + "/", timeout=30_000)
|
||||||
|
tip_after = _branch_tip(docs_repo.bare)
|
||||||
|
assert tip_after == tip_before, (
|
||||||
|
f"canceling moved the docs branch: {tip_before} -> {tip_after}"
|
||||||
|
)
|
||||||
@@ -240,19 +240,64 @@ def test_app_js_slug_rule() -> None:
|
|||||||
assert "docs/${docSlug(title)}.md" in js
|
assert "docs/${docSlug(title)}.md" in js
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_js_transcript_covers_the_whole_session() -> None:
|
||||||
|
"""Phase 75 (TODO L4, A6): buildSessionTranscript() — the draft
|
||||||
|
body — is the WHOLE conversation: a numbered section per USER turn
|
||||||
|
("## N. <raw question>" + blank line + the raw answer text; more
|
||||||
|
answers join under the same heading), sections blank-line
|
||||||
|
separated, ALL trailing whitespace collapsed to ONE final newline.
|
||||||
|
Only the raw persisted text travels (m.who + m.text — no thinking
|
||||||
|
blocks, no source chips, no tune metadata); a brain record before
|
||||||
|
the first user record is skipped; a user turn whose brain record
|
||||||
|
never landed is a heading-only section. saveAsDoc POSTS the
|
||||||
|
transcript (the phase-59 single-bubble body no longer travels)."""
|
||||||
|
js = _text(APP_JS)
|
||||||
|
fn_idx = js.find("function buildSessionTranscript() {")
|
||||||
|
assert fn_idx != -1, "buildSessionTranscript missing"
|
||||||
|
fn_end = js.find("function appendSaveAsDocButton", fn_idx)
|
||||||
|
assert fn_end > fn_idx
|
||||||
|
fn_body = js[fn_idx:fn_end]
|
||||||
|
# One section per USER turn, numbered 1-based in record order…
|
||||||
|
assert 'm.who === "user"' in fn_body
|
||||||
|
assert "sections.length + 1" in fn_body
|
||||||
|
# …"## N. <question, RAW text>" — the record's text verbatim
|
||||||
|
# (no title transform, no HTML).
|
||||||
|
assert "`## ${sections.length + 1}. ${m.text}`" in fn_body
|
||||||
|
# …the RAW answer text joins under its question; the question and
|
||||||
|
# its answer(s) are blank-line separated, and the sections too.
|
||||||
|
assert "open.push(m.text)" in fn_body
|
||||||
|
assert 's.answers.join("\\n\\n")' in fn_body
|
||||||
|
assert '.join("\\n\\n")' in fn_body
|
||||||
|
# ALL trailing whitespace collapses to a single final newline.
|
||||||
|
assert 'body.replace(/\\s+$/, "") + "\\n"' in fn_body
|
||||||
|
# Only raw text travels: no record field beyond who/text is read.
|
||||||
|
assert "m.thinking" not in fn_body
|
||||||
|
assert "m.sources" not in fn_body
|
||||||
|
assert "m.deflected" not in fn_body
|
||||||
|
assert "m.tools" not in fn_body
|
||||||
|
# saveAsDoc POSTS the transcript as the body (and the dead
|
||||||
|
# single-bubble markdown argument is gone from its signature).
|
||||||
|
save_idx = js.find("async function saveAsDoc(btn) {")
|
||||||
|
assert save_idx > fn_idx, "saveAsDoc missing (or still carries markdown)"
|
||||||
|
save_body = js[save_idx : save_idx + 3000]
|
||||||
|
assert "body: buildSessionTranscript()" in save_body
|
||||||
|
assert "markdown" not in save_body
|
||||||
|
|
||||||
|
|
||||||
def test_app_js_post_payload_and_navigation() -> None:
|
def test_app_js_post_payload_and_navigation() -> None:
|
||||||
"""Click → POST /api/doc-drafts {title, path, body: markdown} (the
|
"""Click → POST /api/doc-drafts {title, path, body: the
|
||||||
raw markdown is the body — never HTML) → 201 →
|
FULL-SESSION transcript} (phase 75 A6 — every Q/A up to the click,
|
||||||
|
in order — never HTML) → 201 →
|
||||||
location.assign("/doc-edit.html?draft=" + token). A double-click
|
location.assign("/doc-edit.html?draft=" + token). A double-click
|
||||||
guard disables the button until the outcome (released in the
|
guard disables the button until the outcome (released in the
|
||||||
finally — never stale); failure shows the neutral one-line banner
|
finally — never stale); failure shows the neutral one-line banner
|
||||||
(phase-55 convention) and never navigates."""
|
(phase-55 convention) and never navigates."""
|
||||||
js = _text(APP_JS)
|
js = _text(APP_JS)
|
||||||
fn_idx = js.find("async function saveAsDoc(btn, markdown) {")
|
fn_idx = js.find("async function saveAsDoc(btn) {")
|
||||||
assert fn_idx != -1, "saveAsDoc missing"
|
assert fn_idx != -1, "saveAsDoc missing"
|
||||||
fn_body = js[fn_idx : fn_idx + 3000]
|
fn_body = js[fn_idx : fn_idx + 3000]
|
||||||
assert 'fetch("/api/doc-drafts"' in fn_body
|
assert 'fetch("/api/doc-drafts"' in fn_body
|
||||||
assert 'JSON.stringify({ title, path, body: markdown })' in fn_body
|
assert "body: buildSessionTranscript()" in fn_body
|
||||||
assert 'location.assign("/doc-edit.html?draft=" + draft.token)' in fn_body
|
assert 'location.assign("/doc-edit.html?draft=" + draft.token)' in fn_body
|
||||||
assert "btn.disabled = true" in fn_body
|
assert "btn.disabled = true" in fn_body
|
||||||
assert "btn.disabled = false" in fn_body
|
assert "btn.disabled = false" in fn_body
|
||||||
|
|||||||
Reference in New Issue
Block a user