Skip to content

feat(chat): server-side conversations, history and query condensing - #9

Draft
VampiricCyborg wants to merge 11 commits into
chore/phase-0-baselinefrom
feat/phase-8-conversations
Draft

VampiricCyborg wants to merge 11 commits into
chore/phase-0-baselinefrom
feat/phase-8-conversations

Conversation

@VampiricCyborg

@VampiricCyborg VampiricCyborg commented Sep 23, 2026 •

Copy link
Copy Markdown
Owner

Phase 8 — server-side conversations

ChatRequest.conversation_id was accepted and ignored. Chats lived only in
localStorage, the LLM never saw prior turns, and retrieval ran on the literal
follow-up text. This makes conversations server state, gives the model history,
and rewrites elliptical follow-ups before they reach the vector search.

Base is chore/phase-0-baseline, not main — the integration tests here build on
the fixtures that phase 0 adds to tests/conftest.py. Review this diff on its own;
GitHub shows only the phase-8 commits against that base.

Prior work

A local-only branch feat/conversation-persistence (c445b69, never pushed) held a
first draft of the two ORM models under the commit message "chore: refresh Railway
deployment". Its column layout is carried forward here. The branch itself is not
merged: it forked before a9f3d6c1b5e8 and still declares EMBEDDING_DIM = 768, so
taking its models.py would revert the vector column against a 384-dim database.
The two classes were ported by hand instead.

Turn-2 retrieval — raw vs. rewritten (SMOKE RUN — NOT A RESULT)

Do not quote these numbers anywhere. This is a smoke run: it shows the
harness works end to end and that the wiring is sane. It is not a measurement
of retrieval quality and must not be cited until the follow-up slice has been
reviewed and the full 27-document corpus is indexed.

Deliberately not copied into README.md, and no BASELINE.md is created from
it. This PR touches no markdown files at all.

Turn 2 of each two-turn item, retrieved three ways against the same index in the
same run. Document-level relevance; reference_rewrite is the slice's
hand-written rewrite and stands as a ceiling.

turn-2 variant R@1 R@3 R@5
raw follow-up (what ships today) 0.400 0.533 0.533
condenser rewrite (this PR) 0.933 1.000 1.000
reference rewrite (ceiling) 0.867 0.933 0.933

Turn-2 Recall@5: 0.533 -> 1.000. Eight of fifteen items flip from miss to hit.
All 15 condensing calls returned a usable rewrite ({'ok': 15} — no timeouts, no
fallbacks).

rewrite latency ms
P50 194
P95 1121

That latency lands in front of retrieval, so it is added to time-to-first-token on
every follow-up. chat_condense_timeout is 6s, well above P95.

What the condenser actually produced:

raw turn 2 rewritten for retrieval
"How does it differ from the policy engine?" "How does a policy enforcement point differ from the policy engine in a zero trust architecture?"
"Which of them gives the customer the least control?" "Which of the three cloud service models defined by NIST gives the customer the least control?"
"What happens in the third one?" "What happens in the third phase of the incident response life cycle?"

Read this table with four caveats

  1. The index is a 15-document subset, not the full 27-document corpus — the
    documents the follow-up slice references. The full ingest was stopped twice by
    the machine running out of memory. Fewer distractors inflate absolute recall for
    every variant, so the three columns are comparable to each other but not to a
    full-corpus run.
  2. n = 15, so one item is worth 6.7 recall points. The gap here is 8 items, which
    is well clear of that; the 0.067 by which model_rewrite exceeds
    reference_rewrite is one item and means nothing.
  3. The slice is a draft. evaluation/sets/manual.json is stamped "DRAFT - not reviewed, not used to produce any reported number.", and its own verification
    block records the follow-up slice as not mechanically verified — unlike the
    unanswerable slice, whose ground truth was checked against the corpus text.
  4. There is no BASELINE.md in the repository to put a before/after column
    against. This comparison is internally A/B — every variant runs over the same
    items against the same index in one run — so it does not need one, but the
    standing rule in CLAUDE.md asks for a baseline and it is worth saying plainly
    that none exists yet.

The turn-1 assistant reply the condenser resolves pronouns against is a proxy: the
top retrieved passage from turn 1, truncated. Generating a real answer would have
put a second non-deterministic LLM call inside the measurement. The result file
records this.

Does a chat survive a different browser and a logout?

Three cookie jars for one account, against a real server (DEBUG=false, a real
AUTH_SECRET, PostgreSQL 16 + pgvector). Nothing is shared between the jars but
the credentials — no localStorage, no in-memory state, separate cookie stores on
disk. Jar A creates the chat; jar B is a second browser that is never told about
it; jar C is jar B after logging out and back in.

If conversations were still client-side, B and C would both be empty. Reproduce
with backend/scripts/persistence_check.sh; transcript also committed at
backend/evaluation/results/persistence_check_phase8.txt.

Full transcript
------------------------------------------------------------
  1. Sign up in browser A
------------------------------------------------------------
{
    "id": "c07204e6-aefc-412d-a41c-6bc1a3774aca",
    "name": "Persistence Check",
    "email": "persistence-1790239473@example.invalid",
    "createdAt": "2026-09-24T08:44:33.596132Z"
}
cookie jar A now holds: 1 session cookie
------------------------------------------------------------
  2. Sign in as the SAME user in browser B (a separate cookie jar)
------------------------------------------------------------
{
    "id": "c07204e6-aefc-412d-a41c-6bc1a3774aca",
    "name": "Persistence Check",
    "email": "persistence-1790239473@example.invalid",
    "createdAt": "2026-09-24T08:44:33.596132Z"
}
jars A and B share no state. Both cookies encode the same user id, so only
the signature at the tail distinguishes them -- they are separate sessions:
  A session cookie: ...iDRDzw1yvXdcAOx4
  B session cookie: ...TLu-I7R934x7RrrE
------------------------------------------------------------
  3. Browser B sees no chats yet
------------------------------------------------------------
{
    "items": [],
    "total": 0,
    "limit": 30,
    "offset": 0,
    "has_more": false
}
------------------------------------------------------------
  4. Browser A sends a message with no conversation_id
------------------------------------------------------------
POST /chat  {"message": "What is a policy enforcement point?", "mode": "llm"}

--- first frames of the SSE stream ---
event: conversation
data: {"id": "b632b811-67ac-4e5a-b980-6d3864cf751a"}

event: token
data: "A"

  ...

server created conversation: b632b811-67ac-4e5a-b980-6d3864cf751a
------------------------------------------------------------
  5. Browser B — never told about that conversation — lists its chats
------------------------------------------------------------
{
    "items": [
        {
            "id": "b632b811-67ac-4e5a-b980-6d3864cf751a",
            "title": "What is a policy enforcement point?",
            "mode": "llm",
            "pinned": false,
            "created_at": "2026-09-24T08:44:34.758230Z",
            "updated_at": "2026-09-24T08:44:37.638317Z"
        }
    ],
    "total": 1,
    "limit": 30,
    "offset": 0,
    "has_more": false
}
------------------------------------------------------------
  6. Browser B opens the conversation and reads the transcript
------------------------------------------------------------
{
  "id": "b632b811-67ac-4e5a-b980-6d3864cf751a",
  "title": "What is a policy enforcement point?",
  "mode": "llm",
  "pinned": false
}
messages:
  [user      complete] What is a policy enforcement point?
  [assistant complete] A **Policy Enforcement Point (PEP)** is a component within a securi...
------------------------------------------------------------
  7. Browser B logs out
------------------------------------------------------------
  POST /auth/logout -> HTTP 204
  GET /conversations with the logged-out jar -> HTTP 401
------------------------------------------------------------
  8. Log back in (jar C) and list again
------------------------------------------------------------
  C session cookie: ...ZJc0_EttnTEtRUec  (a newly minted session)

{
    "items": [
        {
            "id": "b632b811-67ac-4e5a-b980-6d3864cf751a",
            "title": "What is a policy enforcement point?",
            "mode": "llm",
            "pinned": false,
            "created_at": "2026-09-24T08:44:34.758230Z",
            "updated_at": "2026-09-24T08:44:37.638317Z"
        }
    ],
    "total": 1,
    "limit": 30,
    "offset": 0,
    "has_more": false
}
------------------------------------------------------------
  9. And the transcript is still there
------------------------------------------------------------
messages:
  [user      complete] What is a policy enforcement point?
  [assistant complete] A **Policy Enforcement Point (PEP)** is a component within a securi...
------------------------------------------------------------
  10. A different account cannot see it
------------------------------------------------------------
  their GET /conversations         -> total=0
  their GET /conversations/b632b811... -> HTTP 404  (404, not 403)
------------------------------------------------------------

The last step is the ownership check from the other side: a second account sees
total=0 and gets 404, not 403, on the conversation id.

A browser-driven check is still outstanding — the Playwright MCP server failed to
connect this session (CONNECT_TIMEOUT), and I did not substitute a scratch
browser harness for it.

Migration

e7c4b9d2a6f1 adds conversations and messages. Purely additive: two new tables
and their indexes, nothing existing altered. downgrade() drops both, which is the
only possible inverse of creating a table and therefore discards saved
conversations — which is why the upgrade direction was kept additive.

railway.json runs alembic upgrade head on every boot, so merging this PR runs
this migration against production.
Round trip evidence below.

upgrade -> downgrade -> upgrade against pgvector/pgvector:pg16 on port 5434:

step result
upgrade head both tables created; btree (user_id, updated_at DESC) and btree (conversation_id, created_at) present; both FKs ON DELETE CASCADE
cascade check DELETE FROM users removed the conversation and its messages
downgrade -1 both tables dropped; the pre-existing users row survived; alembic_version back to d5e2c8a7f1b3
upgrade head tables recreated empty, all four indexes rebuilt, version e7c4b9d2a6f1

The surviving users row is the point: it makes "additive" a checked fact rather
than a claim in the docstring.

Tests

160 passed locally — 114 unit, 46 integration — against pgvector/pgvector:pg16.

What changed

Backend

  • conversations(id, user_id FK cascade, title, mode, pinned, created_at, updated_at)
    indexed on (user_id, updated_at DESC); messages(...) indexed on
    (conversation_id, created_at).
  • app/api/conversations.py: list (paginated), create, get-with-messages, patch
    (title/pinned/mode), delete, patch message feedback, and a one-time import.
    Ownership lives in the service layer, which folds user_id into every WHERE
    clause. A foreign id returns 404, never 403 — 403 would confirm the id is real
    and someone else's, which is a membership oracle.
  • /chat: creates a conversation when none is given and emits event: conversation
    as the first SSE frame; 404s an unknown or foreign id before streaming starts;
    persists the user message; loads the last CHAT_HISTORY_TURNS (6) exchanges under
    CHAT_HISTORY_MAX_TOKENS — a budget deliberately separate from
    llm_max_context_tokens, so a long conversation cannot crowd out retrieved
    passages; drops whole turns from the oldest end, never half a turn.
  • Query condensing (app/llm/condenser.py, versioned prompt in app/llm/prompts.py),
    behind CHAT_QUERY_REWRITE, default true, with
    CHAT_QUERY_REWRITE_MAX_TOKENS=80 and CHAT_QUERY_REWRITE_TIMEOUT=6s. The rewrite feeds retrieval only; the original
    message goes to the answer prompt. Both are logged. Timeout, provider error, an
    empty reply or a reply long enough to be an answer all fall back to the raw message.
  • Assistant message persisted when the stream ends. On client disconnect the partial
    text is saved with status=partial, from a fresh session opened inside the
    generator's finally — the request-scoped session is closed by then.
  • Title is the first user message, truncated. No extra LLM call.

Providers — none of them could take a message list. Each collapsed the exchange
into a single user turn, so "verify every provider handles a multi-message list"
turned out to mean rewriting all five:

provider before now
groq, openai private _build_messages, one user turn shared build_chat_messages
anthropic one-element messages list history + live turn; system stays top-level
gemini system + context + question glued into one string real contents list, system_instruction, assistant role mapped to "model"
ollama POST /api/generate — no messages parameter at all POST /api/chat

Frontend

  • TanStack Query owns the sidebar list and each conversation's messages. Zustand keeps
    UI state only: sidebar, draft mode, and the in-flight answer. The streamed text is
    not in the query cache — it is not server state until the stream ends, at which
    point the conversation refetches and the buffer is dropped, so what remains is the
    stored row with the real message id that feedback and retry need.
  • The conversation event gives a new chat its server id, and the URL is updated with
    window.history.replaceState. router.replace would move /chat → /chat/[id] —
    a different route, so the page unmounts and the streaming answer vanishes
    mid-sentence. Next.js 16 supports the History API directly and syncs it into
    usePathname; both chat routes now render the same component and read the id from
    the path rather than from route params.
  • Pin, rename, delete and feedback are server mutations, optimistic where the gesture
    is direct manipulation. Retry posts regenerate=true and the server replaces its
    previous answer in place instead of appending a duplicate turn.
  • One-time import: docuquery-chat-store:<userId> is POSTed to
    /conversations/import (max 200, validated with the same schemas), and the local
    key is deleted only after a 2xx — the endpoint is not idempotent, so a key
    removed early would either lose the history or duplicate it on retry.

Behaviour changes worth a second look

  • With no retrieved context the user turn is now the message alone. It was
    "\n\nQuestion: <msg>" in LLM mode, because the context f-string was unconditional.
  • Ollama's endpoint changed. Anyone running LLM_PROVIDER=ollama is now hitting
    /api/chat.
  • "New chat" no longer creates a row — the old store wrote one on every click, which
    is where empty New Chat sidebar entries came from.
  • Mode is a property of the conversation, so changing it on an open chat is a PATCH.
  • An answer the server marked partial renders as real text with a note that it was
    cut short.
  • app/services/__init__.py no longer re-exports conversation_service. It imports
    app.llm.models, which runs app/llm/__init__.py, which imports the retrieval
    pipeline — and app.retrieval reaches app.services through app.ingestion.
    app.main imported in an order that hid the cycle; the eval harness enters through
    app.retrieval and hit it.

Tests

Integration (real PostgreSQL): ownership isolation on every conversation route, with
a foreign DELETE leaving the row intact so the 404 is provably a refusal; history
reaching the provider — asserted on the message list a recording provider receives,
because a test that only checked history was loaded would still pass while a
provider silently dropped it; condenser timeout falling back to the raw query; and a
client disconnect leaving a partial message.

Unit (no services): turn pairing, title truncation, the wire format each of the five
providers builds, and every condenser fallback.

Out of scope

Summarising long histories. Truncation is enough for now.


🤖 Generated with Claude Code

VampiricCyborg and others added 6 commits September 22, 2026 23:36
Chats have lived only in each browser's localStorage, so a thread vanished on
logout and never followed the account to another machine. These two tables move
them server-side.

Both are new: nothing existing is altered, so the `alembic upgrade head` that
railway.json runs on every boot cannot disturb documents, chunks or users.
`downgrade()` drops them, which is the only possible inverse of creating a table
and therefore discards saved conversations -- the reason the upgrade direction was
kept additive in the first place.

The ORM models were drafted on the stale local branch feat/conversation-persistence
and are ported by hand rather than merged: that branch predates a9f3d6c1b5e8 and
still declares EMBEDDING_DIM = 768, so taking its models.py wholesale would revert
the vector column against a 384-dim database.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ChatRequest.conversation_id was ignored and LLMRequest had nowhere to put prior
turns, so the model saw every follow-up as a first message. LLMRequest now carries
a trimmed, strictly-alternating history list and all five providers replay it.

None of them could take a message list before -- each collapsed the exchange into
a single user turn:

  groq, openai      had a private _build_messages; now share build_chat_messages
  anthropic         built a one-element messages list; system stays a top-level arg
  gemini            glued system + context + question into one string; now passes a
                    real contents list with system_instruction, mapping the
                    assistant role to Gemini's "model"
  ollama            posted to /api/generate, which has no messages parameter at
                    all; switched to /api/chat

Also adds the versioned query-condensing prompt and app/llm/condenser.py, which
rewrites an elliptical follow-up into a standalone retrieval query, and
StreamCapture, so a caller can persist what was streamed without parsing its own
SSE frames back into text.

Behaviour change: with no retrieved context the user turn is now the message
alone. It was previously "\n\nQuestion: <message>" in LLM mode, because the
context f-string was unconditional.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Routes: list (paginated), create, get-with-messages, patch title/pinned/mode,
delete, patch message feedback, and a one-time /conversations/import for chats
still sitting in a browser's localStorage.

Ownership is enforced in the service layer, which folds user_id into every WHERE
clause, so a handler cannot forget it. Another user's conversation id answers 404,
never 403: 403 would confirm the id is real and someone else's, which is a
membership oracle an attacker could enumerate.

/chat now:
  - creates a conversation when none is given, and emits `event: conversation`
    with its id as the first SSE frame
  - rejects an unknown or foreign conversation_id with 404 *before* streaming
    starts, because once StreamingResponse is returned the status is already 200
  - persists the user message, then loads the last chat_history_turns exchanges
    under chat_history_max_tokens -- a budget deliberately separate from
    llm_max_context_tokens, so a long conversation cannot crowd out retrieved
    passages
  - condenses an elliptical follow-up into a standalone query for RETRIEVAL ONLY;
    the original message still goes to the answer prompt, and both are logged
  - persists the assistant message when the stream ends, and on client disconnect
    saves what was generated with status=partial, using a fresh session from the
    sessionmaker because the request-scoped one is closed by then
  - re-answers in place on `regenerate` instead of appending a duplicate turn

Titles come from the first user message, truncated. No extra LLM call.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…tial saves

Integration tier (real PostgreSQL):
  - every conversation route is scoped to the caller, and a foreign id answers
    404 with the row left intact -- proving the 404 is a refusal rather than a
    delete that reported oddly
  - prior turns reach the provider. Asserted on the message list a recording
    provider is handed, not on the fact that history was loaded: a test of the
    latter would still pass while a provider silently dropped it, which is the
    bug this phase fixes
  - a condenser timeout retrieves on the user's literal words, and a successful
    rewrite reaches retrieval while the answer prompt still sees the original
  - a client that walks away mid-stream leaves a `partial` assistant message

Unit tier (no services): turn pairing, title truncation, the wire format each of
the five providers builds from one history list, and the condenser's fallbacks
for timeout, provider error, an empty reply and a reply long enough to be an
answer rather than a query.

tests/test_health.py's mocked chat test now stubs the persistence seams. It runs
in the unit tier where there is no database, and /chat genuinely needs one now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`--mode followup` runs the two-turn slice three ways over the same items, so the
columns are comparable:

  raw                the user's literal follow-up -- what shipped before
  model_rewrite      app.llm.condenser's output, the deployed path
  reference_rewrite  the slice's hand-written rewrite, a ceiling for the corpus

Reports Recall@1/3/5 at document level plus rewrite latency percentiles and a
count of condenser outcomes, so a run where most rewrites timed out cannot be
mistaken for one where condensing did not help.

Ground truth in this slice names documents by manifest id, never by database
document_id, which is regenerated on every ingest; the join is through the
filename the corpus was ingested under. All 15 ids resolve against the manifest.

The condenser needs an assistant turn to resolve pronouns against. Generating one
would put a second non-deterministic LLM call inside a measurement, so the top
retrieved passage from turn 1 stands in, and the result file records that it is a
proxy rather than a written answer.

Also drops the eager conversation_service re-export from app/services/__init__.py.
It imports app.llm.models, which runs app/llm/__init__.py, which imports the
retrieval pipeline -- and app.retrieval reaches app.services through
app.ingestion. Importing app.retrieval first, as this harness does, hit the loop
and failed on a partially initialised module.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
TanStack Query now owns the sidebar list and each conversation's messages;
Zustand keeps only UI state -- sidebar open, the mode a not-yet-created chat will
start in, and the answer currently streaming. The streamed text is deliberately
not in the query cache: it is not server state until the stream ends, at which
point the conversation refetches and the buffer is dropped, so what stays on
screen is the row the server stored, with the real message id that feedback and
retry need.

A new chat no longer invents an id. Sending with no conversation_id makes the
server create one and announce it on the stream; the client then rewrites the URL
with window.history.replaceState. router.replace would move from /chat to
/chat/[id] -- a different route, so the page unmounts and the answer being
streamed into it disappears mid-sentence. Next.js 16 supports the History API
directly and syncs it into usePathname, so both chat routes now render the same
component and read the id from the path rather than from route params.

Pin, rename, delete and feedback are mutations against the server, optimistic
where the gesture is direct manipulation. Retry posts regenerate=true, and the
server replaces its previous answer in place instead of the thread growing a
duplicate turn.

Chats saved by the previous version are uploaded once from
docuquery-chat-store:<userId> to /conversations/import, and the local key is
deleted only after a 2xx -- the endpoint is not idempotent, so a key removed
early would either lose the history or duplicate it on retry.

Side effects worth calling out:
  - "New chat" no longer creates a row. The old store wrote one on every click,
    which is where empty "New Chat" entries in the sidebar came from.
  - Mode is a property of the conversation, so changing it on an open chat is a
    PATCH; on a new one it is remembered locally until the first message.
  - An answer the server marked `partial` (the browser went away mid-stream) is
    shown as real text with a note that it was cut short.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
docu-query Ready Ready Preview Sep 24, 2026 8:52am UTC

VampiricCyborg and others added 2 commits September 23, 2026 17:22
…es local

Two defects found reviewing the streaming path.

The assistant message's status started at "error" and was only ever corrected to
"complete" or "partial", so a client that hung up before the first token left an
empty row filed as a generation failure -- a closed tab reported as something
that never worked. Completion, failure and disconnect are now three separate
outcomes, and a disconnect with nothing generated writes no row at all.

The bigger one is in the fixtures. /chat saves the assistant message from the
streaming generator's `finally`, where the request-scoped session is already
closed, so it opens its own from AsyncSessionLocal. Overriding `get_db` does not
touch that sessionmaker: it still points at the application's DATABASE_URL. Every
assistant message an integration test provoked would have gone to the developer's
own database rather than the disposable one -- invisible to the assertions, and
writing into a database this suite was never pointed at. The `client` fixture now
redirects it at the test engine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…uffer

A provider error is recorded server-side as a message with status "error". The
client was keeping its own error bubble as well and then refetching the stored
one, so the same failure appeared twice. The buffer is now cleared on failure
too, and the server row is what stays on screen; where the request never reached
the server there is no row, and the toast is the feedback.

The in-flight buffer also rendered into whatever conversation happened to be open
while its id was still unknown. Navigating into an existing thread mid-stream
therefore showed a different chat's tokens. It now requires both ids to be null,
not just the stream's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four fixes to the tests themselves, none to the product.

The `client` fixture built its sessionmaker from `db_session.get_bind()`, which on
an AsyncSession returns the *sync* Engine proxy that `async_sessionmaker` rejects.
The engine is now its own fixture and both take it from there.

The two condensing tests stubbed retrieval with an empty result, which sends
docuquery mode into NoContextError before the provider is ever called -- so they
were measuring the guard, not the query. The shared stub returns one real chunk.
The recording provider also keeps `generate` and `stream` calls in separate lists:
during a streaming chat `generate` is the condenser and `stream` is the answer,
and one combined list made "what did the answer prompt see?" ambiguous.

The disconnect test could never have passed as written, and worse, it would have
passed for the wrong reason once the timing changed. httpx's ASGITransport buffers
the whole response body before yielding a single line, so under it the application
always runs to completion and there is no mid-answer disconnect to detect -- the
route recorded an ordinary `complete` message. It now runs against uvicorn on a
real ephemeral port, reads one token frame and closes the socket, and asserts the
stored message is `partial` and holds only the first token.

Also points the conftest header at port 5434, matching CLAUDE.md.

160 passed locally (114 unit, 46 integration) against pgvector/pgvector:pg16.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turn-2 Recall@5 at document level, 15 items, same index for every variant:

  raw                0.533
  model_rewrite      1.000
  reference_rewrite  0.933

All 15 condensing calls returned a usable rewrite; latency P50 194ms, P95 1121ms.

Read with the caveats in the PR, not on its own. The index here is the
15-document subset the follow-up slice actually references, not the full
27-document corpus -- the full ingest was stopped twice by the machine running
out of memory. Fewer distractors inflate absolute recall for every variant, so
the columns are comparable to each other but not to a full-corpus run. n=15 also
means one item is worth 6.7 points, and evaluation/sets/manual.json is still
stamped DRAFT with the follow-up slice unverified.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Renames the three query-condensing settings to the name the feature is actually
called by:

  chat_condense_enabled     -> chat_query_rewrite            (default stays true)
  chat_condense_max_tokens  -> chat_query_rewrite_max_tokens
  chat_condense_timeout     -> chat_query_rewrite_timeout

All three are new in this PR and unset in every environment, so nothing depends on
the old names. Renaming only the flag would have left the group half-consistent.

The default stays true. The n=15 smoke run does not settle it either way, but
retrieving on an unresolved pronoun is a known failure mode rather than a
hypothesis, and every fallback path -- timeout, provider error, empty reply, a
reply long enough to be an answer -- has a test.

Also adds scripts/persistence_check.sh and its transcript: three cookie jars for
one account, proving a chat created in one browser is readable in another and
survives a logout/login round trip.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

This branch was successfully deployed

1 active deployment
Preview — 895774ab Deployed Sep 24, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant