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