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:
@@ -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(¬es_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(" "), "");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user