feat(search): wire meeting_fts population + real search (T8.2, FR-SEARCH-1)

meeting_fts existed since migration 0001 but nothing ever wrote to it.
Adds reindex_fts (called from finalize_meeting/update_notes so the
index can't drift from what's on disk), a one-time startup backfill
for meetings finalized before this feature existed, delete_meeting
cleanup (meeting_fts is a virtual table with no FK/CASCADE support),
and a real Store::search() replacing the hardcoded stub. Query input
is phrase-quoted per token so stray FTS5 operators in free-text search
input can't throw a MATCH syntax error.
This commit is contained in:
iamdoubz
2026-07-02 10:07:20 -05:00
parent b5aa1714ad
commit 68379f8f56
+156 -9
View File
@@ -6,7 +6,7 @@
use crate::models::{
ActionItem, CalendarEvent, ImportedEvent, MeetingId, MeetingListItem, MeetingStatus,
Participant, SpeakerInfo, TranscriptSegment,
Participant, SearchHit, SpeakerInfo, TranscriptSegment,
};
use crate::paths;
use async_trait::async_trait;
@@ -176,7 +176,7 @@ pub trait Store: Send + Sync {
participant_id: &str,
) -> Result<(), StoreError>;
/// Full-text search across transcripts + notes (Phase 8, FR-SEARCH-1).
async fn search(&self, query: &str) -> Result<Vec<MeetingListItem>, StoreError>;
async fn search(&self, query: &str) -> Result<Vec<SearchHit>, StoreError>;
/// Startup reconcile: meetings with audio but no finalized transcript (FR-REL-1).
async fn recover_scan(&self) -> Result<Vec<MeetingId>, StoreError>;
/// Enforce retention; returns count removed. Skips in-progress meetings
@@ -205,7 +205,29 @@ impl SqliteStore {
.run(&pool)
.await
.map_err(|e| StoreError::Db(e.to_string()))?;
Ok(Self { pool })
let store = Self { pool };
store.backfill_fts().await?;
Ok(store)
}
/// One-time-per-meeting catch-up for meetings finalized before full-text
/// search existed (T8.2, FR-SEARCH-1) — `finalize_meeting`/`update_notes`
/// keep `meeting_fts` current for everything from here on, but that adds
/// nothing retroactively for meetings that predate this feature. Cheap
/// and idempotent: only touches meetings missing a row, so after the
/// first run this is a no-op scan on every later startup.
async fn backfill_fts(&self) -> Result<(), StoreError> {
let missing: Vec<String> = sqlx::query_scalar(
"SELECT id FROM meetings WHERE id NOT IN (SELECT meeting_id FROM meeting_fts)",
)
.fetch_all(&self.pool)
.await?;
for id in missing {
if let Err(e) = self.reindex_fts(&id).await {
tracing::warn!("FTS backfill skipped meeting {id}: {e}");
}
}
Ok(())
}
}
@@ -273,6 +295,51 @@ impl SqliteStore {
.await?;
Ok(id)
}
/// (Re)builds this meeting's `meeting_fts` row from the current title and
/// whatever's on disk for transcript/notes (Phase 8, FR-SEARCH-1).
/// `meeting_fts` is a plain (non-`content=`) FTS5 table, so nothing keeps
/// it in sync automatically — called from `finalize_meeting`/
/// `update_notes` so the index can't drift from what's actually stored.
/// Deletes-then-inserts rather than `INSERT OR REPLACE`: FTS5 has no
/// unique constraint to conflict on.
async fn reindex_fts(&self, id: &MeetingId) -> Result<(), StoreError> {
let title: String = sqlx::query_scalar("SELECT title FROM meetings WHERE id = ?")
.bind(id)
.fetch_one(&self.pool)
.await?;
let transcript_text =
std::fs::read_to_string(paths::meeting_dir(id).join("transcript.json"))
.ok()
.and_then(|s| serde_json::from_str::<TranscriptFile>(&s).ok())
.map(|t| {
t.segments
.iter()
.map(|s| s.text.as_str())
.collect::<Vec<_>>()
.join(" ")
})
.unwrap_or_default();
let notes_text =
std::fs::read_to_string(paths::meeting_dir(id).join("notes.md")).unwrap_or_default();
sqlx::query("DELETE FROM meeting_fts WHERE meeting_id = ?")
.bind(id)
.execute(&self.pool)
.await?;
sqlx::query(
"INSERT INTO meeting_fts (meeting_id, title, transcript_text, notes_text) VALUES (?, ?, ?, ?)",
)
.bind(id)
.bind(&title)
.bind(&transcript_text)
.bind(&notes_text)
.execute(&self.pool)
.await?;
Ok(())
}
}
/// Follows a `label -> merged_into` chain to its canonical label. Capped at 8
@@ -296,6 +363,20 @@ fn now_unix() -> i64 {
.unwrap_or(0)
}
/// Turns free-text user input into a safe FTS5 MATCH query (Phase 8,
/// FR-SEARCH-1). FTS5's query syntax gives special meaning to `" - * AND OR
/// NOT`, so passing a search box's raw text straight to MATCH can either
/// throw a syntax error on stray punctuation or search less literally than
/// the user typed. Quoting each token as its own phrase (doubling embedded
/// quotes) makes every token a literal match, ANDed together by default.
fn fts_query_from_input(input: &str) -> String {
input
.split_whitespace()
.map(|tok| format!("\"{}\"", tok.replace('"', "\"\"")))
.collect::<Vec<_>>()
.join(" ")
}
/// On-disk shape of `transcript.json` (`docs/03-data-model.md`).
#[derive(Debug, Serialize, Deserialize, Default)]
struct TranscriptFile {
@@ -408,6 +489,7 @@ impl Store for SqliteStore {
let json =
serde_json::to_string_pretty(&transcript).map_err(|e| StoreError::Db(e.to_string()))?;
std::fs::write(paths::meeting_dir(id).join("transcript.json"), json)?;
self.reindex_fts(id).await?;
Ok(())
}
@@ -507,6 +589,12 @@ impl Store for SqliteStore {
}
async fn delete_meeting(&self, id: &MeetingId) -> Result<(), StoreError> {
// meeting_fts is a virtual table — no FK/CASCADE support, so its row
// would otherwise outlive the meeting and show up as a ghost result.
sqlx::query("DELETE FROM meeting_fts WHERE meeting_id = ?")
.bind(id)
.execute(&self.pool)
.await?;
sqlx::query("DELETE FROM meetings WHERE id = ?")
.bind(id)
.execute(&self.pool)
@@ -525,6 +613,7 @@ impl Store for SqliteStore {
.bind(id)
.execute(&self.pool)
.await?;
self.reindex_fts(id).await?;
Ok(())
}
@@ -784,12 +873,37 @@ impl Store for SqliteStore {
Ok(imported)
}
async fn search(&self, _query: &str) -> Result<Vec<MeetingListItem>, StoreError> {
// Not wired to a command yet, but never panic on a trait method a
// future caller could reach (see commands::not_implemented).
Err(StoreError::Db(
"full-text search isn't built yet (Phase 8)".to_string(),
))
async fn search(&self, query: &str) -> Result<Vec<SearchHit>, StoreError> {
if query.trim().is_empty() {
return Ok(Vec::new());
}
let fts_query = fts_query_from_input(query);
let rows = sqlx::query(
"SELECT m.id, m.title, m.started_at, m.duration_secs, m.status,
snippet(meeting_fts, -1, '', '', '…', 12) AS snippet
FROM meeting_fts
JOIN meetings m ON m.id = meeting_fts.meeting_id
WHERE meeting_fts MATCH ?
ORDER BY meeting_fts.rank
LIMIT 50",
)
.bind(&fts_query)
.fetch_all(&self.pool)
.await?;
Ok(rows
.iter()
.map(|row| {
let item = row_to_list_item(row);
SearchHit {
id: item.id,
title: item.title,
started_at: item.started_at,
duration_secs: item.duration_secs,
status: item.status,
snippet: row.get("snippet"),
}
})
.collect())
}
async fn recover_scan(&self) -> Result<Vec<MeetingId>, StoreError> {
@@ -883,3 +997,36 @@ fn dir_size(path: &str) -> u64 {
.map(|m| m.len())
.sum()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fts_query_from_input_quotes_each_token_as_a_literal_phrase() {
assert_eq!(
fts_query_from_input("standup notes"),
"\"standup\" \"notes\""
);
}
#[test]
fn fts_query_from_input_escapes_embedded_quotes() {
assert_eq!(fts_query_from_input("say \"hi\""), "\"say\" \"\"\"hi\"\"\"");
}
#[test]
fn fts_query_from_input_neutralizes_fts5_operators() {
// Without quoting, "-" and "OR"/"AND"/"*" carry special FTS5 meaning;
// quoted, they're just literal tokens that can't blow up MATCH.
assert_eq!(
fts_query_from_input("a - b OR c*"),
"\"a\" \"-\" \"b\" \"OR\" \"c*\""
);
}
#[test]
fn fts_query_from_input_of_blank_input_is_blank() {
assert_eq!(fts_query_from_input(" "), "");
}
}