diff --git a/chat/app.py b/chat/app.py index 9e2c74b..a5d66dc 100644 --- a/chat/app.py +++ b/chat/app.py @@ -32,6 +32,7 @@ from chat.web.drawer import router as drawer_router from chat.web.kickoff import router as kickoff_router from chat.web.middleware import FirstRunRedirectMiddleware from chat.web.nav import router as nav_router +from chat.web.search import router as search_router from chat.web.settings import router as settings_router from chat.web.sse import router as sse_router from chat.web.turns import router as turns_router @@ -140,6 +141,7 @@ app.include_router(settings_router) app.include_router(nav_router) app.include_router(chat_router) app.include_router(drawer_router) +app.include_router(search_router) app.include_router(sse_router) app.include_router(turns_router) diff --git a/chat/templates/layout.html b/chat/templates/layout.html index 7b1954b..5ccb7c9 100644 --- a/chat/templates/layout.html +++ b/chat/templates/layout.html @@ -7,6 +7,13 @@
  • Bots
  • Settings
  • + {# T100: cross-chat search box. GET /search so the URL is shareable + and back-button friendly; the results page itself re-renders this + form with the query pre-filled. #} +
    {% block content %}{% endblock %} diff --git a/chat/templates/search.html b/chat/templates/search.html new file mode 100644 index 0000000..ee61c24 --- /dev/null +++ b/chat/templates/search.html @@ -0,0 +1,37 @@ +{% extends "layout.html" %} +{% block title %}Search - chat{% endblock %} +{% block content %} + + +
    + + +
    + +{% if not query %} + {# Empty-state placeholder: the top-bar form submits to /search even + with no input, so this page must render cleanly with no query. #} +

    Enter a query to search memories across all chats.

    +{% elif not results %} +

    No matches for “{{ query }}”.

    +{% else %} + +{% endif %} +{% endblock %} diff --git a/chat/web/search.py b/chat/web/search.py new file mode 100644 index 0000000..51d75ea --- /dev/null +++ b/chat/web/search.py @@ -0,0 +1,92 @@ +"""T100 (Phase 4): cross-chat search UX route. + +Wraps T93's :func:`chat.services.cross_chat_search.search_all_memories` +in a small read-only HTML surface so the top-bar search input has +somewhere to land. The route does no filtering of its own beyond the +empty-query fast-path that T93 already implements; ranking, owner +scope, and witness scope all live in the service layer. + +For each match we hydrate just enough metadata to render a row: +* the owner bot's display name (so users see "BOTA" not "bot_a"), +* the originating ``chat_id`` (the link target — there's no per-turn + anchor today because memories don't carry an ``event_id`` column, + so we deep-link to the chat as a whole), +* the originating scene title when one exists, +* and the ``pov_summary`` itself. + +We deliberately keep this module synchronous and template-only — no +HTMX swaps, no JSON API — because the search box is a "leave the +current chat to look something up" surface, not an inline drawer. +""" + +from __future__ import annotations + +from pathlib import Path + +from fastapi import APIRouter, Depends, Request +from fastapi.responses import HTMLResponse +from fastapi.templating import Jinja2Templates + +from chat.services.cross_chat_search import search_all_memories +from chat.state.entities import get_bot +from chat.state.world import get_chat, get_scene +from chat.web.bots import get_conn + +TEMPLATES = Jinja2Templates( + directory=str(Path(__file__).resolve().parent.parent / "templates") +) + +router = APIRouter() + + +@router.get("/search", response_class=HTMLResponse) +async def search(request: Request, q: str = "", conn=Depends(get_conn)): + """Render ``search.html`` with up to 50 cross-chat FTS matches. + + ``q`` is intentionally allowed to be empty — that path renders the + page's "enter a query" placeholder rather than a 400, because the + top-bar form submits to this URL even with an empty input. T93's + service short-circuits whitespace-only queries to ``[]`` so there + is no FTS5 ``MATCH ''`` syntax error to guard against here. + """ + raw_results = search_all_memories(conn, query=q, k=50) if q else [] + + # Hydrate display fields per row. We do this in the route (not the + # service) so the service stays a pure FTS shim that other UIs + # can reuse. + results = [] + for row in raw_results: + bot = get_bot(conn, row["owner_id"]) + chat = get_chat(conn, row["chat_id"]) + scene = get_scene(conn, row["scene_id"]) if row["scene_id"] else None + results.append( + { + "memory_id": row["memory_id"], + "owner_id": row["owner_id"], + "owner_name": bot["name"] if bot else row["owner_id"], + "chat_id": row["chat_id"], + "chat_name": ( + chat.get("narrative_anchor") if chat else None + ), + "scene_id": row["scene_id"], + # Scenes have no ``title`` column today; surface the + # ``started_at`` timestamp as a human-friendly label + # when a scene is set, otherwise leave it blank. + "scene_label": ( + scene.get("started_at") if scene else None + ), + "pov_summary": row["pov_summary"], + "significance": row["significance"], + "ts": row["ts"], + } + ) + + return TEMPLATES.TemplateResponse( + request, + "search.html", + { + "query": q, + "results": results, + "active_nav": "search", + }, + ) diff --git a/tests/test_search_ux.py b/tests/test_search_ux.py new file mode 100644 index 0000000..7254549 --- /dev/null +++ b/tests/test_search_ux.py @@ -0,0 +1,135 @@ +"""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