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.
93 lines
3.5 KiB
Python
93 lines
3.5 KiB
Python
"""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",
|
|
},
|
|
)
|