phase: 103_suggestions_session_openers
Build and Push Containers / build-and-push-app (push) Successful in 2m32s
Build and Push Containers / build-and-push-db (push) Successful in 12s

Phase 103 final verification pass — all green.

**Verified (all 3 tasks already in `complete/`; no code changes needed):**
- `opening_questions` in `app/api/suggestions.py` — forward walk, one opener per chat (first non-blank user msg, A3), reads raw `messages` not `title` (A4), phase-80 order/dedup/cap/seed contracts; `last_questions` name gone from `app/`+`tests/`
- Docs updated: `app/config.py` seed docstring, `.env.example` `BOR_SUGGESTIONS`, `README.md` — "session openers" wording
- Diff scope correct: only the 6 expected files + phase-file moves; `app/rag/suggestions.py` and `frontend/` untouched

**Test / lint / coverage results:**
- `uv run pytest tests/integration/test_suggestions_api.py -v` → 12 passed
- `uv run pytest tests/e2e/test_suggestion_chips.py -v --no-cov` → 8 passed in isolation (opener-only core pin included)
- `test_responsive_polish.py` → 7 passed; `test_chat_persistence.py` → 4 passed (both isolated, no edits)
- `uv run pytest --cov=app --cov-report=term-missing` → 2086 passed, TOTAL 99% (>90%); `app/api/suggestions.py` 100%
- `uv run ruff check .` → clean; `uv run pyright` → 0 errors, 0 warnings

**Completion criteria:** all 7 ✅ (follow-ups-never-surface pin; cap-across-chats pin; seed/dedup/case/partial/A3/401 pins; E2E suites isolated; deflection chips unchanged; full suite + lint; commit + dir move left to harness per executor rules).

**Deviations:** none — no defects found; nothing changed in this pass.
**Next pending phase:** `98_sync_summary_visibility` (numeric order in `todo/`).
This commit is contained in:
2026-09-12 16:37:50 -04:00
parent 3b2dea5685
commit 1f1c01c9f7
25 changed files with 55781 additions and 204 deletions
+170 -99
View File
@@ -1,42 +1,56 @@
"""Phase 80 E2E (Playwright): onboarding chips = the last 3 questions asked.
"""Phase 103 E2E (Playwright): onboarding chips = the session openers.
Story: ``.agents/user_stories/suggestion-chips.md`` (phase 05) —
REWRITTEN in place for the phase-80 semantics (the phase-76 precedent:
a semantic change rewrites the story suite in place). Source:
``TODO.md`` L6.
REWRITTEN in place for the phase-103 semantics (the phase-76/80
precedent: a semantic change rewrites the story suite in place).
Source: owner request 2026-09-12 — a suggested question must make
sense on its own, and a follow-up never does.
The new contract (owner decision A6): the empty-state chip row is the
3 most recent user questions across ALL saved chats — chats walked
newest-``updated_at`` first, each chat's messages newest-first,
exact (case-sensitive) de-duplicated, cap 3. A fresh deployment — zero
saved questions — gets the SEED list instead (``BOR_SUGGESTIONS`` /
the built-in default). 1–2 saved questions → exactly those chips (NO
mixing with the seed). The row refetches when the empty state comes
back (New chat), so it is never stale. The deflection "Maybe try"
chips are a separate contract (``derive_suggestions``) — untouched.
The new contract (owner decision A1): the chips are the **session
openers** — each saved chat contributes AT MOST ONE chip: its FIRST
non-blank user message, the question that OPENED the session. Follow-up
questions can NEVER surface: a follow-up like "What about qwen 3.6
35b?" (asked after "What are the correct arguments for qwen 3.8 27b on
llama.cpp?") is meaningless as a conversation starter without the
session behind it. Everything else is the phase-80 contract, unchanged:
chats are walked newest-``updated_at`` first, openers are exact
(case-sensitive) de-duplicated, cap 3 — the cap binds ACROSS chats. A
fresh deployment — zero saved openers — gets the SEED list instead
(``BOR_SUGGESTIONS`` / the built-in default). The row refetches when
the empty state comes back (New chat), so it is never stale. The
deflection "Maybe try" chips are a separate contract
(``derive_suggestions``) — untouched.
The four states pinned here:
The states pinned here:
* **seed** — fresh DB (no saved chats) → the chip texts equal the
built-in default list EXACTLY (the ``SEED`` literal below is the
pin for the exact seed list — ``tests/unit/test_config.py`` pins
only the shape) — rendered as accessible buttons in the role=list
group, exactly as the phase-05 component contract;
* **last-3** — two saved chats with 5 user questions total (the older
one saved FIRST — the API stamps ``updated_at``) → a fresh page
load shows EXACTLY the 3 newest questions, newest-first;
* **partial** — exactly 2 saved questions deployment-wide → exactly
2 chips (no seed top-up — the A6 contract, visible in the UI);
* **refetch** — boot with the seed chips, save a chat whose newest
question is Q via the API, click New chat (``#new-chat-btn``) → the
chips now are Q, and the request log shows a SECOND
``GET /api/suggestions`` (the boot fetch was the first).
* **seed** (unchanged) — fresh DB (no saved chats) → the chip texts
equal the built-in default list EXACTLY (the ``SEED`` literal below
is the pin for the exact seed list — ``tests/unit/test_config.py``
pins only the shape) — rendered as accessible buttons in the
role=list group, exactly as the phase-05 component contract;
* **opener-only** (the NEW core state — the owner's exact scenario) —
ONE saved chat with a 3-turn conversation (the opener Q1, the
follow-up Q2, the follow-up Q3, brain replies between) → a fresh
page load shows EXACTLY ONE chip: Q1 (the opener); Q2/Q3 are absent;
* **three-openers** (replaces the old "last-3" state) — THREE saved
chats, each multi-turn (opener + at least one follow-up), DISTINCT
``updated_at`` (the API stamps them on save — the test saves
oldest→newest) → exactly 3 chips = the three openers, newest
``updated_at`` first; none of the chats' FOLLOW-UPS appears;
* **partial** (kept, re-scoped) — exactly 2 saved (multi-turn) chats →
exactly 2 chips (the two openers — NO seed top-up; the follow-ups in
those chats do not pad the row);
* **refetch** (kept) — boot with the seed chips → save a multi-turn
chat (opener Q + a follow-up) via the API → click New chat
(``#new-chat-btn``) → the chips now are exactly Q, and the request
log shows a SECOND ``GET /api/suggestions`` (the boot fetch was the
first).
Carried-over story behavior (unchanged semantics from the phase-05
suite): one-tap submit (chip click → composer filled → submitted →
the mock-LLM brain bubble), Tab+Enter keyboard reachability of the
chips (the keyboard-walk assertion), and the mobile single
horizontal-scroll row.
Carried-over story behavior (unchanged semantics from the phase-05/80
suites): one-tap submit (chip click → composer filled → submitted →
the mock-LLM brain bubble with the ``MOCK_ANSWER_MARKER``), Tab+Enter
keyboard reachability of the chips (the keyboard-walk assertion), and
the mobile single horizontal-scroll row.
The endpoint is authed (phase 79, ``require_user``), so every test
signs in as admin first (``auth_helpers.login``). ``saved_chats`` is
@@ -55,7 +69,6 @@ import asyncio
import json
import time
from collections.abc import Iterator
from datetime import datetime
from pathlib import Path
from threading import Thread
from typing import Any
@@ -75,12 +88,12 @@ FIXTURES = REPO / "tests" / "fixtures" / "docs"
MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E"
#: The EXACT built-in onboarding SEED (phase 80, TODO.md L6): the chip
#: row of a brand-new deployment, shown only before any question has
#: ever been saved. This literal is the E2E pin for the exact seed
#: list — ``tests/unit/test_config.py`` pins only the SHAPE (>=3
#: non-blank distinct strings), and the e2e app under test is forced
#: to the code default by conftest's leak guard — keep in sync with
#: the ``Settings.suggestions`` default in ``app/config.py``.
#: row of a brand-new deployment, shown only while no saved chat has
#: ever opened with a question. This literal is the E2E pin for the
#: exact seed list — ``tests/unit/test_config.py`` pins only the SHAPE
#: (>=3 non-blank distinct strings), and the e2e app under test is
#: forced to the code default by conftest's leak guard — keep in sync
#: with the ``Settings.suggestions`` default in ``app/config.py``.
SEED: list[str] = [
"What documents are in the knowledge base?",
"Which source does each answer come from?",
@@ -91,10 +104,10 @@ SEED: list[str] = [
@pytest.fixture(autouse=True)
def clean_chats(db_ready: None) -> Iterator[None]:
"""``saved_chats`` is the state the phase-80 contract reads:
"""``saved_chats`` is the state the phase-103 contract reads:
truncate it before and after every test so each state test starts
from (and leaves) an empty deployment. Unlike the KB tables, this
reset is non-optional — the chips ARE these rows, and the
reset is non-optional — the chips ARE these rows' openers, and the
carried-over submit tests auto-save a row per turn, which would
otherwise leak into the later state tests."""
with SessionLocal() as db:
@@ -223,92 +236,142 @@ def test_seed_state_chips_are_the_builtin_default(
assert box is not None and box["height"] >= 44
def test_last_three_questions_state(page: Page, app_url: str, db_ready: None) -> None:
"""5 user questions across two saved chats (the older one saved
FIRST — the API stamps ``updated_at`` at INSERT) → a fresh page
load shows EXACTLY the 3 newest questions, newest-first: the
newer chat is walked first, then the older chat newest-first."""
def test_opener_only_state(page: Page, app_url: str, db_ready: None) -> None:
"""The NEW core state — the owner's exact scenario: ONE saved chat
with a 3-turn conversation (opener Q1, follow-up Q2, follow-up Q3)
→ a fresh page load shows EXACTLY ONE chip: Q1 (the opener). Q2/Q3
are absent — a follow-up like "What about …?" is meaningless as a
conversation starter without the session behind it."""
page.set_default_timeout(30_000)
login(page, app_url, next="/")
old_q = [
"How did I install the GitLab runner on the Proxmox node?",
"Which disk holds the Borg backup archives?",
"How is the nftables firewall rule set ordered?",
]
new_q = [
"What TLS termination does Traefik do for homelab.local?",
"Which provider is the primary DNS for reeseapps.com?",
]
opener = "What are the correct arguments for qwen 3.8 27b on llama.cpp?"
follow_up_1 = "What about qwen 3.6 35b?"
follow_up_2 = "And which of the three needs the most VRAM?"
# The OLDER chat first: the API stamps ``updated_at`` (server
# now()), so save order IS walk order. The short pause keeps the
# two stamps strictly apart (and the assert below pins that the
# order the walk sees is the order the test intended).
older = _save_chat(
# ONE multi-turn chat (three user questions, brain replies between)
# — exactly the owner's session shape.
_save_chat(
page,
app_url,
[
_user(old_q[0]), _brain(),
_user(old_q[1]), _brain(),
_user(old_q[2]), _brain(),
_user(opener), _brain(),
_user(follow_up_1), _brain(),
_user(follow_up_2), _brain(),
],
)
time.sleep(0.05)
newer = _save_chat(
page,
app_url,
[
_user(new_q[0]), _brain(),
_user(new_q[1]), _brain(),
],
)
assert datetime.fromisoformat(newer["updated_at"]) > datetime.fromisoformat(
older["updated_at"]
), "the two API-stamped updated_at values must be strictly ordered"
# A FRESH page load (a new boot fetch, not the pre-save boot):
# the chips are exactly the 3 newest questions, newest first.
# EXACTLY ONE chip — the chat's opener, and nothing else.
page.goto(app_url + "/")
chips = _chip_locator(page)
expect(chips.first).to_be_visible(timeout=30_000)
expected = [new_q[1], new_q[0], old_q[2]]
assert _chip_texts(page) == expected
assert _api_suggestions(page, app_url) == expected
# The two older questions (and everything seed-shaped) are gone.
assert old_q[0] not in _chip_texts(page)
assert old_q[1] not in _chip_texts(page)
assert chips.count() == 1, "a 3-turn chat yields EXACTLY its opener as the single chip"
texts = _chip_texts(page)
assert texts == [opener], "the single chip is the EXACT full opener text"
assert follow_up_1 not in texts, "the 'What about …?' follow-up must never chip"
assert follow_up_2 not in texts
# ...and the endpoint itself holds the same contract (same row).
assert _api_suggestions(page, app_url) == [opener]
def test_partial_state_no_seed_topup(page: Page, app_url: str, db_ready: None) -> None:
"""Exactly 2 saved questions deployment-wide → EXACTLY 2 chips
(newest first) — NO mixing/top-up with the seed (the A6
contract, visible in the UI)."""
def test_three_openers_newest_first(page: Page, app_url: str, db_ready: None) -> None:
"""THREE saved chats, each multi-turn (opener + at least one
follow-up), DISTINCT ``updated_at`` (the API stamps them on save —
the test saves oldest→newest) → a fresh page load shows EXACTLY
the three openers, newest ``updated_at`` first; none of the chats'
FOLLOW-UPS appears anywhere in the row."""
page.set_default_timeout(30_000)
login(page, app_url, next="/")
a = "How do I rotate the WireGuard keys on the VPN node?"
b = "What cron schedule runs the restic prune?"
_save_chat(page, app_url, [_user(a), _brain()])
openers = [
"How did I install the GitLab runner on the Proxmox node?",
"What TLS termination does Traefik do for homelab.local?",
"Which provider is the primary DNS for reeseapps.com?",
]
follow_ups = [
"What about the runners' Docker socket access?",
"And does it terminate mTLS for the internal services?",
"What about the secondary DNS for the LAN?",
]
# Save oldest→newest: the API stamps ``updated_at`` (server
# now()), so save order IS walk order. The short pauses keep the
# three stamps strictly apart (and the assert below pins that the
# order the walk sees is the order the test intended).
stamps: list[str] = []
for opener, follow_up in zip(openers, follow_ups, strict=True):
body = _save_chat(
page,
app_url,
[
_user(opener), _brain(),
_user(follow_up), _brain(),
],
)
stamps.append(body["updated_at"])
time.sleep(0.05)
assert stamps == sorted(stamps) and len(set(stamps)) == 3, (
"the three API-stamped updated_at values must be strictly increasing"
)
# A FRESH page load (a new boot fetch, not the pre-save boot):
# exactly the three openers, newest first — the cap of 3 binds
# ACROSS chats, and every chip is a session's OPENER.
page.goto(app_url + "/")
chips = _chip_locator(page)
expect(chips.first).to_be_visible(timeout=30_000)
expected = list(reversed(openers))
texts = _chip_texts(page)
assert chips.count() == 3
assert texts == expected
assert _api_suggestions(page, app_url) == expected
for follow_up in follow_ups:
assert follow_up not in texts, "a chat's follow-up must never chip"
def test_partial_state_no_seed_topup(page: Page, app_url: str, db_ready: None) -> None:
"""Exactly 2 saved (multi-turn) chats → EXACTLY 2 chips (the two
openers, newest first) — NO mixing/top-up with the seed (the
phase-80 A6 contract, visible in the UI), and the follow-ups in
those chats do not pad the row."""
page.set_default_timeout(30_000)
login(page, app_url, next="/")
a_opener = "How do I rotate the WireGuard keys on the VPN node?"
a_follow_up = "What about the peers' allowed-ips?"
b_opener = "What cron schedule runs the restic prune?"
b_follow_up = "And where do the restic lock files live?"
_save_chat(
page,
app_url,
[_user(a_opener), _brain(), _user(a_follow_up), _brain()],
)
time.sleep(0.05)
_save_chat(page, app_url, [_user(b), _brain()])
_save_chat(
page,
app_url,
[_user(b_opener), _brain(), _user(b_follow_up), _brain()],
)
page.goto(app_url + "/")
chips = _chip_locator(page)
expect(chips.first).to_be_visible(timeout=30_000)
assert chips.count() == 2, "exactly 2 chips — the row is never padded toward 3"
texts = _chip_texts(page)
assert texts == [b, a]
assert texts == [b_opener, a_opener]
assert a_follow_up not in texts and b_follow_up not in texts
assert not (set(texts) & set(SEED)), "no seed text may appear once a question is saved"
def test_new_chat_refetches_the_chips(page: Page, app_url: str, db_ready: None) -> None:
"""The row is never stale: boot with the seed chips → save a chat
whose newest question is Q via the API → click New chat
(``#new-chat-btn``) → the empty state comes back with the
"""The row is never stale: boot with the seed chips → save a
multi-turn chat (opener Q + a follow-up) via the API → click New
chat (``#new-chat-btn``) → the empty state comes back with the
REFETCHED row (exactly Q — the deployment now has one saved
question), and the request log shows a SECOND
``GET /api/suggestions`` (the boot fetch was the first)."""
OPENER; the chat's follow-up is never a chip), and the request log
shows a SECOND ``GET /api/suggestions`` (the boot fetch was the
first)."""
page.set_default_timeout(30_000)
sugg_gets: list[float] = []
@@ -325,13 +388,21 @@ def test_new_chat_refetches_the_chips(page: Page, app_url: str, db_ready: None)
assert len(sugg_gets) == 1, "exactly one GET /api/suggestions at boot"
q = "Which service fronts the Pi-hole DNS on the network?"
_save_chat(page, app_url, [_user(q), _brain()])
_save_chat(
page,
app_url,
[
_user(q), _brain(),
_user("What about the Pi-hole's DNSSEC settings?"), _brain(),
],
)
clicked_at = time.monotonic()
page.click("#new-chat-btn")
# The refetch re-renders #suggestions in place: the 4 seed chips
# are replaced by exactly Q (the partial state, live).
# are replaced by exactly Q (the partial state, live — the chat's
# follow-up does not pad the row).
expect(chips).to_have_count(1, timeout=15_000)
expect(chips.first).to_have_text(q, timeout=15_000)
assert len(sugg_gets) == 2, "New chat triggered the refetch"
+186 -70
View File
@@ -1,12 +1,16 @@
"""Integration: the onboarding-chips endpoint (phase 80, task 01) —
"""Integration: the onboarding-chips endpoint (phase 103, task 01) —
the full state matrix of ``GET /api/suggestions``.
The chips are the **last 3 questions asked** — the three most recent
user questions across ALL saved chats: chats are walked newest-
``updated_at`` first (``created_at`` tiebreak), each chat's
``bor.chat.v1`` message list is walked newest-first, exact-
(case-sensitive) de-duplicated, capped at 3. A fresh deployment —
zero saved questions — gets the SEED list instead
The chips are the **session openers** — each saved chat contributes
AT MOST ONE chip: its first non-blank user message (the question that
opened the session). Chats are walked newest-``updated_at`` first
(``created_at`` tiebreak), each chat's ``bor.chat.v1`` message list is
walked FORWARD (oldest→newest, the record's conversational order),
openers are exact-(case-sensitive) de-duplicated, capped at 3 — the
cap binds ACROSS chats. Follow-up questions ("What about …?") can
NEVER surface: they are unanswerable without the session behind them
(owner 2026-09-12). Everything else is the phase-80 contract: a fresh
deployment — zero saved openers — gets the SEED list instead
(``get_settings().suggestions``: the ``BOR_SUGGESTIONS`` override or
the built-in default). The override's JSON parsing is pinned at unit
level (``tests/unit/test_config.py``), so this suite stays
@@ -16,16 +20,27 @@ env-agnostic: the empty-DB contract is "exactly
Matrix (task item 2):
* empty DB → exactly ``get_settings().suggestions``;
* cap + order: 4 questions in ONE chat → the 3 newest, newest first;
* chat order: two chats with DISTINCT ``updated_at`` (stamped
explicitly) → the newer chat's questions outrank the older chat's
newest-LOOKING question;
* dedup: the same text asked in two chats → exactly once; a
differently-cased variant is KEPT (exact dedup);
* partial: 1–2 saved questions deployment-wide → exactly those chips
(NO seed top-up — the A6 contract);
* brain-only: all-``brain`` (or blank user texts) contribute nothing;
an all-brain deployment → the seed;
* one chat with 4 user questions → EXACTLY its opener (the first
question); none of the 3 follow-ups appears;
* cap ACROSS chats: 4 multi-turn chats (distinct ``updated_at``) →
exactly the 3 NEWEST chats' openers, newest first; the oldest
chat's opener is dropped by the cap; none of the chats' FOLLOW-UPS
appears anywhere;
* chat order: two multi-turn chats with DISTINCT ``updated_at``
(stamped explicitly) → [newer chat's opener, older chat's opener];
the older chat's LAST (newest-looking) question is NOT in the
chips;
* dedup: the SAME opener text as the first question of two chats →
exactly once (a verbatim re-ask as a FOLLOW-UP in the newer chat is
deduped too); a differently-cased OPENER variant → both kept (exact
dedup);
* partial: 2 multi-turn chats → 2 openers; 1 chat → 1 chip — the
follow-ups in those chats do NOT pad the row (NO seed top-up — the
phase-80 A6 contract);
* A3: a LEADING blank user entry does NOT disqualify the chat — the
first NON-BLANK user message is the opener;
* brain-only: all-``brain`` (or blank-user-only) chats contribute
nothing; an all-brain deployment → the seed;
* anonymous → 401 ``authentication required`` (the phase-79 contract,
pinned here too).
@@ -51,13 +66,15 @@ from app.config import get_settings
from app.models import SavedChat
#: Fixed question texts — the matrix asserts EXACT chip lists, so the
#: texts are distinct per purpose.
#: texts are distinct per purpose. The Q_* are opener-flavored;
#: FOLLOW_UP is the follow-up-flavored text (the owner's qwen example).
Q_ONE = "How did I install gitlab?"
Q_TWO = "Which node runs my Borg backups?"
Q_THREE = "How do I prune deleted docs?"
Q_FOUR = "What proxy fronts reeseapps.com?"
Q_FIVE = "How is my K3S cluster set up?"
Q_SIX = "How do I deploy a service?"
FOLLOW_UP = "What about qwen 3.6 35b?"
@pytest.fixture(autouse=True)
@@ -109,7 +126,7 @@ def _chips(admin_client: TestClient) -> list[str]:
def test_empty_db_returns_seed(admin_client: TestClient) -> None:
"""Zero saved questions → exactly the seed list — env-agnostic:
"""Zero saved openers → exactly the seed list — env-agnostic:
``get_settings().suggestions`` (the ``BOR_SUGGESTIONS`` override or
the built-in default, whatever the environment makes it)."""
r = admin_client.get("/api/suggestions")
@@ -117,14 +134,15 @@ def test_empty_db_returns_seed(admin_client: TestClient) -> None:
assert r.json() == {"suggestions": get_settings().suggestions}
# ---------- cap + order within one chat ----------
# ---------- openers only: follow-ups never surface ----------
def test_cap_three_and_newest_first_within_a_chat(
admin_client: TestClient, db: Session
) -> None:
"""4 user questions (brain replies between them) in ONE chat →
exactly the 3 NEWEST, newest first."""
def test_a_chats_follow_ups_never_surface(admin_client: TestClient, db: Session) -> None:
"""The phase-103 core pin: ONE chat with 4 user questions (brain
replies between them, the 4th follow-up-flavored) → the chips hold
EXACTLY the chat's OPENER (its first question); none of the 3
follow-ups appears (a follow-up like "What about …?" is
meaningless as a conversation starter without the session)."""
_add_chat(
db,
title="one long chat",
@@ -132,97 +150,158 @@ def test_cap_three_and_newest_first_within_a_chat(
_user(Q_ONE), _brain("a1"),
_user(Q_TWO), _brain("a2"),
_user(Q_THREE), _brain("a3"),
_user(Q_FOUR), _brain("a4"),
_user(FOLLOW_UP), _brain("a4"),
],
updated_at=datetime.now(UTC),
)
assert _chips(admin_client) == [Q_FOUR, Q_THREE, Q_TWO]
chips = _chips(admin_client)
assert chips == [Q_ONE]
for follow_up in (Q_TWO, Q_THREE, FOLLOW_UP):
assert follow_up not in chips
# ---------- the cap binds ACROSS chats ----------
def test_cap_three_across_chats(admin_client: TestClient, db: Session) -> None:
"""FOUR multi-turn chats (opener + at least one follow-up each)
with DISTINCT explicit ``updated_at`` stamps → the chips are
EXACTLY the 3 NEWEST chats' openers, newest first; the oldest
chat's opener is dropped (the cap of 3 now binds ACROSS chats, not
within one chat); none of the four chats' FOLLOW-UPS appears
anywhere."""
base = datetime.now(UTC)
_add_chat(
db,
title="oldest",
messages=[_user(Q_FIVE), _brain("…"), _user(FOLLOW_UP), _brain("…")],
updated_at=base,
)
_add_chat(
db,
title="second",
messages=[_user(Q_THREE), _brain("…"), _user(Q_SIX), _brain("…")],
updated_at=base + timedelta(hours=1),
)
_add_chat(
db,
title="third",
messages=[_user(Q_TWO), _brain("…"), _user(Q_FOUR), _brain("…")],
updated_at=base + timedelta(hours=2),
)
_add_chat(
db,
title="newest",
messages=[_user(Q_ONE), _brain("…"), _user(FOLLOW_UP), _brain("…")],
updated_at=base + timedelta(hours=3),
)
chips = _chips(admin_client)
# Newest chat first: the 3 NEWEST openers; the oldest chat's
# opener (Q_FIVE) is dropped by the cap.
assert chips == [Q_ONE, Q_TWO, Q_THREE]
assert Q_FIVE not in chips
for follow_up in (Q_SIX, Q_FOUR, FOLLOW_UP):
assert follow_up not in chips
# ---------- chat order across chats ----------
def test_newer_chat_walked_first(admin_client: TestClient, db: Session) -> None:
"""Two chats with DISTINCT ``updated_at`` (stamped explicitly):
the newer chat is walked FIRST — its single question outranks the
older chat's newest-LOOKING (last-in-conversation) question."""
"""Two multi-turn chats with DISTINCT ``updated_at`` (stamped
explicitly): the newer chat is walked FIRST — its opener leads the
older chat's opener; the older chat's LAST (newest-looking)
question — a follow-up — is NOT in the chips."""
base = datetime.now(UTC)
_add_chat(
db,
title="older chat",
messages=[_user(Q_FIVE), _brain("…"), _user(Q_SIX), _brain("…")],
messages=[
_user(Q_FIVE), _brain("…"),
_user(Q_SIX), _brain("…"), # the older chat's LAST question
],
updated_at=base,
)
_add_chat(
db,
title="newer chat",
messages=[_user(Q_ONE), _brain("…")],
messages=[
_user(Q_ONE), _brain("…"),
_user(Q_TWO), _brain("…"), # a follow-up — never a chip
],
updated_at=base + timedelta(hours=2),
)
# Newer chat first (Q_ONE), then the older chat newest-first
# (Q_SIX — its LAST question — before Q_FIVE).
assert _chips(admin_client) == [Q_ONE, Q_SIX, Q_FIVE]
assert _chips(admin_client) == [Q_ONE, Q_FIVE]
# The older chat's LAST question and the newer chat's follow-up
# must not surface.
assert Q_SIX not in _chips(admin_client)
assert Q_TWO not in _chips(admin_client)
# ---------- dedup ----------
# ---------- dedup (openers only) ----------
def test_verbatim_reask_counts_once_across_chats(
admin_client: TestClient, db: Session
) -> None:
"""The SAME question text asked in two chats appears EXACTLY ONCE
in the chips."""
"""The SAME opener text as the first question of two chats
appears EXACTLY ONCE in the chips — and the newer chat's verbatim
re-ask AS A FOLLOW-UP stays deduped too (it is the same text as
the already-seen opener)."""
base = datetime.now(UTC)
_add_chat(
db,
title="older",
messages=[_user(Q_THREE), _brain("…")],
messages=[_user(Q_ONE), _brain("…"), _user(Q_TWO), _brain("…")],
updated_at=base,
)
_add_chat(
db,
title="newer",
messages=[
_user(Q_ONE), _brain("…"),
_user(Q_THREE), _brain("…"), # verbatim re-ask (newer chat)
_user(Q_ONE), _brain("…"), # SAME opener as the older chat
_user(Q_TWO), _brain("…"), # verbatim re-ask AS A FOLLOW-UP
],
updated_at=base + timedelta(hours=2),
)
# Newest first: the re-ask (LAST message of the newer chat) leads —
# and it appears exactly once (the older chat's copy is deduped).
chips = _chips(admin_client)
assert chips == [Q_THREE, Q_ONE]
assert chips.count(Q_THREE) == 1
assert chips == [Q_ONE]
assert chips.count(Q_ONE) == 1
assert Q_TWO not in chips
def test_dedup_is_exact_not_case_insensitive(
admin_client: TestClient, db: Session
) -> None:
"""A differently-cased re-ask is a DIFFERENT question (exact,
case-sensitive dedup — case-insensitive would drop it): both
variants show, and the verbatim re-ask in the older chat still
counts once."""
"""A differently-cased OPENER variant is a DIFFERENT question
(exact, case-sensitive dedup — case-insensitive would drop it):
both variants show; the follow-ups in both chats do not."""
base = datetime.now(UTC)
lower_variant = Q_THREE.lower()
_add_chat(
db,
title="older",
messages=[_user(Q_THREE), _brain("…")],
messages=[
_user(Q_THREE), _brain("…"), # opener
_user(Q_FOUR), _brain("…"), # follow-up
],
updated_at=base,
)
_add_chat(
db,
title="newer",
messages=[
_user(lower_variant), _brain("…"),
_user(Q_THREE), _brain("…"),
_user(Q_ONE), _brain("…"),
_user(lower_variant), _brain("…"), # differently-cased opener
_user(Q_ONE), _brain("…"), # follow-up
],
updated_at=base + timedelta(hours=2),
)
# Newer chat walked newest-first: Q_ONE, Q_THREE, lower_variant —
# all three kept (the case variant is NOT a duplicate).
assert _chips(admin_client) == [Q_ONE, Q_THREE, lower_variant]
chips = _chips(admin_client)
# Newer chat first: its opener, then the older chat's opener —
# both kept (the case variant is NOT a duplicate).
assert chips == [lower_variant, Q_THREE]
assert Q_ONE not in chips
assert Q_FOUR not in chips
# ---------- partial: no seed top-up ----------
@@ -231,36 +310,52 @@ def test_dedup_is_exact_not_case_insensitive(
def test_exactly_two_questions_give_exactly_two_chips(
admin_client: TestClient, db: Session
) -> None:
"""1–2 saved questions deployment-wide → EXACTLY those chips — NO
mixing/top-up with the seed (the A6 contract)."""
"""2 multi-turn chats → EXACTLY their 2 openers — NO
mixing/top-up with the seed (the phase-80 A6 contract); the
follow-ups in those chats do not pad the row."""
base = datetime.now(UTC)
_add_chat(
db,
title="a",
messages=[_user(Q_TWO), _brain("…")],
messages=[
_user(Q_TWO), _brain("…"),
_user(Q_THREE), _brain("…"), # follow-up — never a chip
],
updated_at=base,
)
_add_chat(
db,
title="b",
messages=[_user(Q_ONE), _brain("…")],
messages=[
_user(Q_ONE), _brain("…"),
_user(FOLLOW_UP), _brain("…"), # follow-up — never a chip
],
updated_at=base + timedelta(hours=1),
)
assert _chips(admin_client) == [Q_ONE, Q_TWO]
chips = _chips(admin_client)
assert chips == [Q_ONE, Q_TWO]
assert Q_THREE not in chips
assert FOLLOW_UP not in chips
def test_exactly_one_question_gives_exactly_one_chip(
admin_client: TestClient, db: Session
) -> None:
"""The 1-question boundary of the same contract: exactly one chip,
never padded toward the cap or mixed with the seed."""
"""The 1-chat boundary of the same contract: exactly one chip (the
chat's opener), never padded toward the cap by the chat's own
follow-ups or mixed with the seed."""
_add_chat(
db,
title="a",
messages=[_user(Q_TWO), _brain("…")],
messages=[
_user(Q_TWO), _brain("…"),
_user(Q_ONE), _brain("…"), # follow-up — never a chip
],
updated_at=datetime.now(UTC),
)
assert _chips(admin_client) == [Q_TWO]
chips = _chips(admin_client)
assert chips == [Q_TWO]
assert Q_ONE not in chips
# ---------- brain-only / blank user texts ----------
@@ -270,16 +365,15 @@ def test_brain_and_blank_user_texts_contribute_nothing(
admin_client: TestClient, db: Session
) -> None:
"""A chat whose messages are all ``who == "brain"`` (plus a blank
user text) contributes NOTHING: the chips hold exactly the one
real question from the other chat — no brain text, no blank, no
seed top-up."""
user text) contributes NOTHING: the chips hold exactly the opener
of the other chat — no brain text, no blank, no seed top-up."""
base = datetime.now(UTC)
_add_chat(
db,
title="brain only + blank user",
messages=[
_brain("just brain talking"),
_user(" "), # blank user text — skipped
_user(" "), # blank user text — no non-blank user message
_brain("more brain"),
],
updated_at=base,
@@ -293,9 +387,31 @@ def test_brain_and_blank_user_texts_contribute_nothing(
assert _chips(admin_client) == [Q_FOUR]
def test_leading_blank_user_entry_does_not_disqualify(
admin_client: TestClient, db: Session
) -> None:
"""A3: a LEADING blank user entry (the UI cannot produce one —
``handleSend`` trims and guards empty text) does NOT disqualify
the chat: the first NON-BLANK user message is the opener."""
_add_chat(
db,
title="leading blank",
messages=[
_user(" "), # leading blank user entry — skipped
_user(Q_TWO), # the first NON-BLANK user message = the opener
_brain("…"),
_user(FOLLOW_UP), # a follow-up — never a chip
],
updated_at=datetime.now(UTC),
)
chips = _chips(admin_client)
assert chips == [Q_TWO]
assert FOLLOW_UP not in chips
def test_all_brain_deployment_returns_seed(admin_client: TestClient, db: Session) -> None:
"""A deployment with ONLY brain/blank conversations (zero saved
questions) → the full seed list."""
openers) → the full seed list."""
_add_chat(
db,
title="all brain",