feat: cross-chat search deep-links to turn via memories.event_id (T111.2)
Add ``m.event_id`` (T109's nullable column from migration 0014) to
``search_all_memories``'s SELECT, propagate it through the route's
template context, and have ``search.html`` build result links as
``/chats/{chat_id}#turn-{event_id}`` — matching the ``id="turn-{event_id}"``
anchor that Phase 3.5 T86 stamps on each turn DOM node so the chat page
scrolls to the originating turn on load. Memory rows projected before
the 0014 migration ran read NULL ``event_id``; the template falls back
to a chat-level link in that case so we never emit ``#turn-None``.
Pre-existing tests that asserted on the bare ``href="/chats/{chat_id}"``
contract are updated to assert on the ``href="/chats/{chat_id}#turn-``
prefix to reflect the new deep-link.
This commit is contained in:
@@ -26,8 +26,17 @@ def search_all_memories(
|
||||
"""Search FTS5 across all owners and chats.
|
||||
|
||||
Returns rows with ``{memory_id, owner_id, chat_id, scene_id,
|
||||
pov_summary, snippet, significance, ts, fts_rank}``, sorted by FTS5
|
||||
BM25 rank ascending (lower rank = stronger match, surfaced first).
|
||||
event_id, pov_summary, snippet, significance, ts, fts_rank}``,
|
||||
sorted by FTS5 BM25 rank ascending (lower rank = stronger match,
|
||||
surfaced first).
|
||||
|
||||
``event_id`` (T111.2 / T109) is the id of the ``event_log`` row that
|
||||
drove the projecting ``memory_written`` event. May be ``None`` for
|
||||
memory rows projected before the 0014 schema migration ran (the
|
||||
column is nullable on purpose; T109 did not backfill historical
|
||||
rows). The search-results UI uses it to deep-link to the originating
|
||||
turn anchor (Phase 3.5 T86 stamps ``id="turn-{event_id}"`` on each
|
||||
turn DOM node) and falls back to a chat-level link when ``None``.
|
||||
|
||||
The ``memories`` table has no ``ts`` column; we expose ``created_at``
|
||||
(the projector-side row insertion timestamp) under that key so the
|
||||
@@ -60,7 +69,7 @@ def search_all_memories(
|
||||
# before/after match markers, so the only HTML in the output is the
|
||||
# ``<mark>`` we injected — safe to render with ``|safe`` server-side.
|
||||
rows = conn.execute(
|
||||
"SELECT m.id, m.owner_id, m.chat_id, m.scene_id, "
|
||||
"SELECT m.id, m.owner_id, m.chat_id, m.scene_id, m.event_id, "
|
||||
" m.pov_summary, "
|
||||
" snippet(memories_fts, 0, '<mark>', '</mark>', '…', 32) "
|
||||
" AS snippet, "
|
||||
@@ -80,11 +89,12 @@ def search_all_memories(
|
||||
"owner_id": r[1],
|
||||
"chat_id": r[2],
|
||||
"scene_id": r[3],
|
||||
"pov_summary": r[4],
|
||||
"snippet": r[5],
|
||||
"significance": r[6],
|
||||
"ts": r[7],
|
||||
"fts_rank": r[8],
|
||||
"event_id": r[4],
|
||||
"pov_summary": r[5],
|
||||
"snippet": r[6],
|
||||
"significance": r[7],
|
||||
"ts": r[8],
|
||||
"fts_rank": r[9],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
@@ -21,7 +21,14 @@
|
||||
<ul class="search-results">
|
||||
{% for r in results %}
|
||||
<li class="search-result">
|
||||
<a class="search-result-link" href="/chats/{{ r.chat_id }}">
|
||||
{# T111.2: deep-link to the originating turn via the
|
||||
``id="turn-{event_id}"`` anchor stamped by Phase 3.5 T86.
|
||||
``event_id`` may be NULL for memory rows projected before the
|
||||
0014 migration ran (T109 did not backfill historical rows); in
|
||||
that case fall back to a chat-level link with no anchor so we
|
||||
never emit ``#turn-None``. #}
|
||||
<a class="search-result-link"
|
||||
href="/chats/{{ r.chat_id }}{% if r.event_id %}#turn-{{ r.event_id }}{% endif %}">
|
||||
<div class="search-result-meta muted">
|
||||
<strong>{{ r.owner_name }}</strong>
|
||||
<span>· {{ r.chat_id }}</span>
|
||||
|
||||
@@ -193,6 +193,13 @@ async def search(request: Request, q: str = "", conn=Depends(get_conn)):
|
||||
chat.get("narrative_anchor") if chat else None
|
||||
),
|
||||
"scene_id": row["scene_id"],
|
||||
# T111.2: event_id deep-links to the originating turn
|
||||
# via the ``id="turn-{event_id}"`` anchor that Phase 3.5
|
||||
# T86 stamps on each turn DOM node. May be ``None`` for
|
||||
# memory rows projected before the 0014 migration ran
|
||||
# (T109 did not backfill historical rows); the template
|
||||
# falls back to a chat-level link in that case.
|
||||
"event_id": row["event_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.
|
||||
|
||||
@@ -867,12 +867,14 @@ def test_cross_chat_search_surfaces_memories_in_three_chats(
|
||||
assert response.status_code == 200
|
||||
body = response.text
|
||||
|
||||
# Each chat_id appears in a result link href, e.g.
|
||||
# ``href="/chats/chat_bot_a"``. The template renders one
|
||||
# ``<a class="search-result-link" href="/chats/{chat_id}">`` per
|
||||
# row, so a substring match per chat is sufficient.
|
||||
# Each chat_id appears in a result link href. T111.2 deep-links to
|
||||
# the originating turn so the href is now
|
||||
# ``href="/chats/{chat_id}#turn-{event_id}"``; we assert on the
|
||||
# ``"/chats/{chat_id}#turn-`` prefix so the per-chat link is
|
||||
# uniquely matched (a bare ``"/chats/chat_bot_a`` substring would
|
||||
# also match ``chat_bot_a_2`` / ``chat_bot_a_3``).
|
||||
for chat_id in chat_ids:
|
||||
assert f'href="/chats/{chat_id}"' in body, (
|
||||
assert f'href="/chats/{chat_id}#turn-' in body, (
|
||||
f"chat {chat_id} missing from /search results: {body!r}"
|
||||
)
|
||||
# The owner display name (BotA) renders for each row — verify >= 3
|
||||
@@ -888,4 +890,4 @@ def test_cross_chat_search_surfaces_memories_in_three_chats(
|
||||
# The "no matches" empty-state copy fires.
|
||||
assert "No matches" in distractor_body
|
||||
for chat_id in chat_ids:
|
||||
assert f'href="/chats/{chat_id}"' not in distractor_body
|
||||
assert f'href="/chats/{chat_id}#turn-' not in distractor_body
|
||||
|
||||
+26
-4
@@ -127,13 +127,19 @@ def test_empty_query_renders_placeholder_not_results(client, tmp_path):
|
||||
|
||||
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."""
|
||||
reopen the thread where the memory was first witnessed.
|
||||
|
||||
Post-T111.2: the link now includes a turn anchor when the memory
|
||||
row carries an ``event_id`` (T109's nullable column is populated for
|
||||
rows projected after migration 0014 ran). We assert on the chat-id
|
||||
portion of the href because the exact event id is autoincrement and
|
||||
depends on seed order; the dedicated
|
||||
``test_search_result_link_includes_turn_anchor`` test below pins the
|
||||
anchor format itself."""
|
||||
_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
|
||||
assert 'href="/chats/chat_a' in resp.text
|
||||
|
||||
|
||||
def test_search_results_include_fts_snippet_with_highlight(client, tmp_path):
|
||||
@@ -152,6 +158,22 @@ def test_search_results_include_fts_snippet_with_highlight(client, tmp_path):
|
||||
assert "<mark>rabbit</mark>" in resp.text
|
||||
|
||||
|
||||
def test_search_result_link_includes_turn_anchor(client, tmp_path):
|
||||
"""T111.2: result links deep-link to the originating turn via the
|
||||
chat-page anchor stamped by Phase 3.5 T86 (``id="turn-{event_id}"``).
|
||||
|
||||
The seeded ``memory_written`` events are projected with
|
||||
``memories.event_id`` populated (T109); the route exposes that id and
|
||||
the template builds the link as ``/chats/{chat_id}#turn-{event_id}``.
|
||||
We don't assert a specific event id (it's an autoincrement that
|
||||
depends on seed order), only that *some* turn anchor is present for
|
||||
the chat link the user is about to click."""
|
||||
_seed_two_chats_with_memories(tmp_path / "test.db")
|
||||
resp = client.get("/search?q=rabbit")
|
||||
assert resp.status_code == 200
|
||||
assert "/chats/chat_a#turn-" in resp.text
|
||||
|
||||
|
||||
def test_search_results_use_batched_lookups(client, tmp_path):
|
||||
"""T106: hydration must not fan out to per-row ``get_bot``/
|
||||
``get_chat``/``get_scene`` calls.
|
||||
|
||||
Reference in New Issue
Block a user