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
+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",