Files
ducoterra 1f1c01c9f7
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_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/`).
2026-09-12 16:37:50 -04:00

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"}