phase: 103_suggestions_session_openers
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:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user