0a2c5924f9
Wires T93's `search_all_memories` service into a small read-only HTML
surface so users can find a memory across every chat in the database.
* `chat/web/search.py` (new): GET `/search?q=...` runs the FTS service
with k=50, hydrates each row with bot name + scene timestamp, and
renders `search.html`. Empty `q` short-circuits to no results so the
top-bar form can submit even with an empty input.
* `chat/templates/search.html` (new): empty-state placeholder, results
list with chat-level "Open chat" links (`/chats/{chat_id}` — memories
don't carry an event_id today, so no per-turn anchor).
* `chat/templates/layout.html`: append a small `<form>` to the rail
nav, additive only.
* `chat/app.py`: register `search_router` (additive import + include).
* `tests/test_search_ux.py`: 3 tests — multi-chat results, empty-query
placeholder, chat link.
136 lines
5.0 KiB
Python
136 lines
5.0 KiB
Python
"""T100 (Phase 4): cross-chat search UX (top-bar + results page).
|
|
|
|
Verifies the FastAPI ``/search`` route that wraps T93's
|
|
``search_all_memories`` service:
|
|
|
|
* ``/search?q=...`` returns 200 + an HTML page that lists matches drawn
|
|
from MULTIPLE chats (not just the current one) and links each result
|
|
back to ``/chats/{chat_id}``.
|
|
* ``/search`` with no query renders the page in its empty state with a
|
|
"enter a query" placeholder and no result rows (avoids hitting the
|
|
FTS index with an invalid empty MATCH).
|
|
* Result links navigate to the originating chat so users can pick up
|
|
the thread where the memory came from.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from chat.app import app
|
|
from chat.db.connection import open_db
|
|
from chat.eventlog.log import append_event
|
|
from chat.eventlog.projector import project
|
|
import chat.state.memory # noqa: F401 (registers memory_written handler)
|
|
|
|
|
|
@pytest.fixture
|
|
def client(tmp_path, monkeypatch):
|
|
config_path = tmp_path / "config.toml"
|
|
config_path.write_text('featherless_api_key = "test"\n')
|
|
monkeypatch.setenv("CHAT_CONFIG_PATH", str(config_path))
|
|
monkeypatch.setenv("CHAT_DB_PATH", str(tmp_path / "test.db"))
|
|
with TestClient(app) as c:
|
|
yield c
|
|
|
|
|
|
def _seed_two_chats_with_memories(db_path: Path) -> None:
|
|
"""Seed: a ``you_entity``, two bots, two chats, and one ``rabbit``
|
|
memory per chat. Two-chat seeding lets the cross-chat assertion
|
|
actually distinguish "both chats appear" from "only the current
|
|
one does"."""
|
|
with open_db(db_path) as conn:
|
|
append_event(
|
|
conn,
|
|
kind="you_authored",
|
|
payload={"name": "Me", "pronouns": "", "persona": ""},
|
|
)
|
|
for bot_id, chat_id in (("bot_a", "chat_a"), ("bot_b", "chat_b")):
|
|
append_event(
|
|
conn,
|
|
kind="bot_authored",
|
|
payload={
|
|
"id": bot_id,
|
|
"name": bot_id.upper(),
|
|
"persona": "thoughtful",
|
|
"voice_samples": [],
|
|
"traits": [],
|
|
"backstory": "",
|
|
"initial_relationship_to_you": "friend",
|
|
"kickoff_prose": "kickoff",
|
|
},
|
|
)
|
|
append_event(
|
|
conn,
|
|
kind="chat_created",
|
|
payload={
|
|
"id": chat_id,
|
|
"host_bot_id": bot_id,
|
|
"initial_time": "2026-04-26T20:00:00+00:00",
|
|
"narrative_anchor": "Day 1",
|
|
"weather": "",
|
|
},
|
|
)
|
|
append_event(
|
|
conn,
|
|
kind="memory_written",
|
|
payload={
|
|
"owner_id": bot_id,
|
|
"chat_id": chat_id,
|
|
"pov_summary": f"the rabbit darted across {chat_id}",
|
|
"witness_you": 1,
|
|
"witness_host": 1,
|
|
"witness_guest": 0,
|
|
"source": "direct",
|
|
"reliability": 1.0,
|
|
"significance": 1,
|
|
"pinned": 0,
|
|
"auto_pinned": 0,
|
|
},
|
|
)
|
|
project(conn)
|
|
|
|
|
|
def test_search_returns_results_from_multiple_chats(client, tmp_path):
|
|
"""A single ``/search?q=rabbit`` must surface matches from BOTH
|
|
chats — the whole point of the cross-chat search box is that it
|
|
isn't owner-scoped."""
|
|
_seed_two_chats_with_memories(tmp_path / "test.db")
|
|
resp = client.get("/search?q=rabbit")
|
|
assert resp.status_code == 200
|
|
body = resp.text
|
|
# Both chats' memory snippets must appear in the rendered page.
|
|
assert "chat_a" in body
|
|
assert "chat_b" in body
|
|
assert "rabbit" in body.lower()
|
|
|
|
|
|
def test_empty_query_renders_placeholder_not_results(client, tmp_path):
|
|
"""``/search`` with no query renders the page in its empty state.
|
|
|
|
The placeholder copy is a contract with the user — they should see
|
|
"enter a query" rather than an empty result list that looks like a
|
|
no-match. Also: the FTS short-circuit means there are no result
|
|
rows to leak into the body."""
|
|
_seed_two_chats_with_memories(tmp_path / "test.db")
|
|
resp = client.get("/search")
|
|
assert resp.status_code == 200
|
|
body = resp.text.lower()
|
|
assert "enter a query" in body
|
|
# Seeded "rabbit" memories must NOT appear: empty query => no results.
|
|
assert "the rabbit darted" not in resp.text
|
|
|
|
|
|
def test_result_links_navigate_to_chat(client, tmp_path):
|
|
"""Each result links back to its originating chat so the user can
|
|
reopen the thread where the memory was first witnessed."""
|
|
_seed_two_chats_with_memories(tmp_path / "test.db")
|
|
resp = client.get("/search?q=rabbit")
|
|
assert resp.status_code == 200
|
|
# The link target is chat-level (memories don't carry an event_id
|
|
# column today, so we don't deep-link to a specific turn).
|
|
assert 'href="/chats/chat_a"' in resp.text
|