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/`).
435 lines
14 KiB
Python
435 lines
14 KiB
Python
"""Integration: the onboarding-chips endpoint (phase 103, task 01) —
|
|
the full state matrix of ``GET /api/suggestions``.
|
|
|
|
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
|
|
env-agnostic: the empty-DB contract is "exactly
|
|
``get_settings().suggestions``, whatever the environment makes that".
|
|
|
|
Matrix (task item 2):
|
|
|
|
* empty DB → exactly ``get_settings().suggestions``;
|
|
* 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).
|
|
|
|
The deflection "Maybe try" chips (``app.rag.suggestions.
|
|
derive_suggestions``) are a SEPARATE contract — untouched.
|
|
|
|
Real Postgres (``podman compose up -d db``).
|
|
|
|
Requires: podman compose up -d db
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Iterator
|
|
from datetime import UTC, datetime, timedelta
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
from sqlalchemy import text
|
|
from sqlalchemy.orm import Session
|
|
|
|
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. 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)
|
|
def clean_chats(db: Session) -> Iterator[None]:
|
|
"""``saved_chats`` is global state: reset around every test."""
|
|
db.execute(text("TRUNCATE saved_chats"))
|
|
db.commit()
|
|
yield
|
|
db.execute(text("TRUNCATE saved_chats"))
|
|
db.commit()
|
|
|
|
|
|
def _user(text: str) -> dict[str, Any]:
|
|
return {"who": "user", "text": text}
|
|
|
|
|
|
def _brain(text: str = "You've got this!") -> dict[str, Any]:
|
|
return {"who": "brain", "text": text}
|
|
|
|
|
|
def _add_chat(
|
|
db: Session,
|
|
*,
|
|
title: str,
|
|
messages: list[dict[str, Any]],
|
|
updated_at: datetime,
|
|
) -> None:
|
|
"""One saved-chat row with EXPLICIT ``created_at``/``updated_at``
|
|
stamps (deterministic order — no reliance on ``now()``
|
|
resolution)."""
|
|
db.add(
|
|
SavedChat(
|
|
title=title,
|
|
messages=messages,
|
|
created_at=updated_at,
|
|
updated_at=updated_at,
|
|
)
|
|
)
|
|
db.commit()
|
|
|
|
|
|
def _chips(admin_client: TestClient) -> list[str]:
|
|
r = admin_client.get("/api/suggestions")
|
|
assert r.status_code == 200, r.text
|
|
return r.json()["suggestions"]
|
|
|
|
|
|
# ---------- empty DB: the seed ----------
|
|
|
|
|
|
def test_empty_db_returns_seed(admin_client: TestClient) -> None:
|
|
"""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")
|
|
assert r.status_code == 200
|
|
assert r.json() == {"suggestions": get_settings().suggestions}
|
|
|
|
|
|
# ---------- openers only: follow-ups never surface ----------
|
|
|
|
|
|
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",
|
|
messages=[
|
|
_user(Q_ONE), _brain("a1"),
|
|
_user(Q_TWO), _brain("a2"),
|
|
_user(Q_THREE), _brain("a3"),
|
|
_user(FOLLOW_UP), _brain("a4"),
|
|
],
|
|
updated_at=datetime.now(UTC),
|
|
)
|
|
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 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("…"), # the older chat's LAST question
|
|
],
|
|
updated_at=base,
|
|
)
|
|
_add_chat(
|
|
db,
|
|
title="newer chat",
|
|
messages=[
|
|
_user(Q_ONE), _brain("…"),
|
|
_user(Q_TWO), _brain("…"), # a follow-up — never a chip
|
|
],
|
|
updated_at=base + timedelta(hours=2),
|
|
)
|
|
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 (openers only) ----------
|
|
|
|
|
|
def test_verbatim_reask_counts_once_across_chats(
|
|
admin_client: TestClient, db: Session
|
|
) -> None:
|
|
"""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_ONE), _brain("…"), _user(Q_TWO), _brain("…")],
|
|
updated_at=base,
|
|
)
|
|
_add_chat(
|
|
db,
|
|
title="newer",
|
|
messages=[
|
|
_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),
|
|
)
|
|
chips = _chips(admin_client)
|
|
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 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("…"), # opener
|
|
_user(Q_FOUR), _brain("…"), # follow-up
|
|
],
|
|
updated_at=base,
|
|
)
|
|
_add_chat(
|
|
db,
|
|
title="newer",
|
|
messages=[
|
|
_user(lower_variant), _brain("…"), # differently-cased opener
|
|
_user(Q_ONE), _brain("…"), # follow-up
|
|
],
|
|
updated_at=base + timedelta(hours=2),
|
|
)
|
|
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 ----------
|
|
|
|
|
|
def test_exactly_two_questions_give_exactly_two_chips(
|
|
admin_client: TestClient, db: Session
|
|
) -> None:
|
|
"""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("…"),
|
|
_user(Q_THREE), _brain("…"), # follow-up — never a chip
|
|
],
|
|
updated_at=base,
|
|
)
|
|
_add_chat(
|
|
db,
|
|
title="b",
|
|
messages=[
|
|
_user(Q_ONE), _brain("…"),
|
|
_user(FOLLOW_UP), _brain("…"), # follow-up — never a chip
|
|
],
|
|
updated_at=base + timedelta(hours=1),
|
|
)
|
|
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-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("…"),
|
|
_user(Q_ONE), _brain("…"), # follow-up — never a chip
|
|
],
|
|
updated_at=datetime.now(UTC),
|
|
)
|
|
chips = _chips(admin_client)
|
|
assert chips == [Q_TWO]
|
|
assert Q_ONE not in chips
|
|
|
|
|
|
# ---------- brain-only / blank user texts ----------
|
|
|
|
|
|
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 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 — no non-blank user message
|
|
_brain("more brain"),
|
|
],
|
|
updated_at=base,
|
|
)
|
|
_add_chat(
|
|
db,
|
|
title="real question",
|
|
messages=[_user(Q_FOUR), _brain("…")],
|
|
updated_at=base + timedelta(hours=1),
|
|
)
|
|
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
|
|
openers) → the full seed list."""
|
|
_add_chat(
|
|
db,
|
|
title="all brain",
|
|
messages=[_brain("a"), _user("\t"), _brain("b")],
|
|
updated_at=datetime.now(UTC),
|
|
)
|
|
r = admin_client.get("/api/suggestions")
|
|
assert r.status_code == 200
|
|
assert r.json() == {"suggestions": get_settings().suggestions}
|
|
|
|
|
|
# ---------- auth pin (phase 79) ----------
|
|
|
|
|
|
def test_anonymous_is_401(client: TestClient) -> None:
|
|
"""The phase-79 contract, pinned here too: an unsigned-in caller
|
|
cannot read the chips."""
|
|
r = client.get("/api/suggestions")
|
|
assert r.status_code == 401
|
|
assert r.json() == {"detail": "authentication required"}
|