mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
fix(memory): sanitize fts5 user queries (#2531)
Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
co-authored by
Steven Enamakel
parent
e0e9a11172
commit
9cffd3a17f
@@ -224,6 +224,17 @@ pub fn event_search_fts(
|
||||
limit: usize,
|
||||
) -> anyhow::Result<Vec<EventRecord>> {
|
||||
let conn = conn.lock();
|
||||
let trimmed = query.trim();
|
||||
if trimmed.is_empty() {
|
||||
tracing::debug!("[events] FTS search skipped — empty query");
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let phrase_query = super::fts5::sanitize_fts_query(trimmed);
|
||||
if phrase_query.is_empty() {
|
||||
tracing::debug!("[events] FTS search skipped — sanitised query is empty");
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT el.event_id, el.segment_id, el.session_id, el.namespace,
|
||||
el.event_type, el.content, el.subject, el.timestamp_ref,
|
||||
@@ -235,13 +246,12 @@ pub fn event_search_fts(
|
||||
LIMIT ?3",
|
||||
)?;
|
||||
let rows = stmt
|
||||
.query_map(params![query, namespace, limit as i64], |row| {
|
||||
.query_map(params![phrase_query, namespace, limit as i64], |row| {
|
||||
row_to_event(row)
|
||||
})?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
tracing::debug!(
|
||||
"[events] FTS search '{}' (ns={}) returned {} results",
|
||||
query,
|
||||
"[events] FTS search ns={} returned {} results",
|
||||
namespace,
|
||||
rows.len()
|
||||
);
|
||||
|
||||
@@ -211,6 +211,32 @@ fn event_fts_matches_subject_field() {
|
||||
assert_eq!(by_subject[0].event_id, "evt-subj");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_fts_sanitises_punctuation_safely() {
|
||||
let conn = setup_db();
|
||||
let event = EventRecord {
|
||||
event_id: "evt-punct".into(),
|
||||
segment_id: "seg-1".into(),
|
||||
session_id: "s1".into(),
|
||||
namespace: "global".into(),
|
||||
event_type: EventType::Decision,
|
||||
content: "We decided to use Rust for backend deployment".into(),
|
||||
subject: Some("backend deployment".into()),
|
||||
timestamp_ref: None,
|
||||
confidence: 0.85,
|
||||
embedding: None,
|
||||
source_turn_ids: None,
|
||||
created_at: 1000.0,
|
||||
};
|
||||
event_insert(&conn, &event).unwrap();
|
||||
|
||||
let results = event_search_fts(&conn, "global", "\"Rust\",(backend)?", 5)
|
||||
.expect("punctuated user query should not trip FTS5 syntax errors");
|
||||
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].event_id, "evt-punct");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_embeddings_are_scoped_by_model_signature() {
|
||||
let conn = setup_db();
|
||||
|
||||
@@ -154,6 +154,17 @@ pub fn episodic_search(
|
||||
limit: usize,
|
||||
) -> anyhow::Result<Vec<EpisodicEntry>> {
|
||||
let conn = conn.lock();
|
||||
let trimmed = query.trim();
|
||||
if trimmed.is_empty() {
|
||||
tracing::debug!("[fts5] search skipped — empty query");
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let phrase_query = sanitize_fts_query(trimmed);
|
||||
if phrase_query.is_empty() {
|
||||
tracing::debug!("[fts5] search skipped — sanitised query is empty");
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT el.id, el.session_id, el.timestamp, el.role, el.content, el.lesson,
|
||||
el.tool_calls_json, el.cost_microdollars
|
||||
@@ -165,7 +176,7 @@ pub fn episodic_search(
|
||||
)?;
|
||||
|
||||
let rows = stmt
|
||||
.query_map(rusqlite::params![query, limit as i64], |row| {
|
||||
.query_map(rusqlite::params![phrase_query, limit as i64], |row| {
|
||||
Ok(EpisodicEntry {
|
||||
id: row.get(0)?,
|
||||
session_id: row.get(1)?,
|
||||
@@ -179,7 +190,7 @@ pub fn episodic_search(
|
||||
})?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
tracing::debug!("[fts5] search '{}' returned {} results", query, rows.len());
|
||||
tracing::debug!("[fts5] search returned {} results", rows.len());
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
@@ -281,17 +292,19 @@ pub fn episodic_cross_session_search(
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Best-effort FTS5 query sanitiser: strip characters that break the
|
||||
/// MATCH grammar (quotes, parens, asterisks) and wrap each surviving
|
||||
/// whitespace-delimited token in double quotes so the FTS engine treats
|
||||
/// it as a literal phrase. Returns an empty string when nothing usable
|
||||
/// survives — the caller short-circuits to "no hits".
|
||||
fn sanitize_fts_query(query: &str) -> String {
|
||||
/// Best-effort FTS5 query sanitiser: split user text on punctuation and
|
||||
/// symbols that break the MATCH grammar, then quote each surviving token
|
||||
/// so FTS5 treats it as literal text. Returns an empty string when
|
||||
/// nothing usable survives — callers short-circuit to "no hits".
|
||||
pub(super) fn sanitize_fts_query(query: &str) -> String {
|
||||
let cleaned: String = query
|
||||
.chars()
|
||||
.map(|c| match c {
|
||||
'"' | '(' | ')' | '*' | ':' => ' ',
|
||||
other => other,
|
||||
.map(|c| {
|
||||
if c.is_alphanumeric() || c == '_' {
|
||||
c
|
||||
} else {
|
||||
' '
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let tokens: Vec<String> = cleaned
|
||||
@@ -539,6 +552,21 @@ mod tests {
|
||||
assert!(hits[0].content.contains("Postgres"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn episodic_search_sanitises_punctuation_safely() {
|
||||
let conn = setup_db();
|
||||
insert_turn(&conn, "session-a", 1000.0, "Postgres deployment notes");
|
||||
|
||||
let hits = episodic_search(&conn, "\"Postgres\",(deployment)?", 10)
|
||||
.expect("punctuated user query should not trip FTS5 syntax errors");
|
||||
|
||||
assert!(
|
||||
!hits.is_empty(),
|
||||
"punctuated query whose surviving tokens match the indexed row must still surface it"
|
||||
);
|
||||
assert!(hits[0].content.contains("Postgres"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cross_session_search_does_not_panic_on_pure_punctuation() {
|
||||
let conn = setup_db();
|
||||
|
||||
Reference in New Issue
Block a user