From 84d33768b8aba7a3b152f33aa51ea0192b45723b Mon Sep 17 00:00:00 2001 From: LR90 <52204121+loveRhythm1990@users.noreply.github.com> Date: Fri, 14 Aug 2026 17:13:16 +0800 Subject: [PATCH] feat: restore filtered fulltext memory search --- memoria/CHANGELOG.md | 6 + memoria/crates/memoria-api/src/lib.rs | 4 + memoria/crates/memoria-api/src/models.rs | 75 ++++- .../crates/memoria-api/src/routes/memory.rs | 24 ++ memoria/crates/memoria-api/tests/api_e2e.rs | 260 ++++++++++++++++++ memoria/crates/memoria-service/src/lib.rs | 5 +- memoria/crates/memoria-service/src/service.rs | 44 +++ .../crates/memoria-storage/src/graph/store.rs | 24 +- memoria/crates/memoria-storage/src/lib.rs | 7 +- memoria/crates/memoria-storage/src/store.rs | 186 +++++++++++-- sdk/python/CHANGELOG.md | 4 + sdk/python/README.md | 11 + sdk/python/src/memoria/resources/memories.py | 226 +++++++++++++++ sdk/python/tests/unit/test_memories.py | 138 ++++++++++ sdk/python/tests/unit/test_memories_async.py | 34 +++ skills/api-reference/SKILL.md | 28 ++ 16 files changed, 1032 insertions(+), 44 deletions(-) diff --git a/memoria/CHANGELOG.md b/memoria/CHANGELOG.md index a4109560..676bada5 100644 --- a/memoria/CHANGELOG.md +++ b/memoria/CHANGELOG.md @@ -4,6 +4,12 @@ ### Added +**Filtered Full-text Search** (`POST /v1/memories/fulltext-search`) +- Pure MatrixOne lexical full-text search without embedding, vector, graph, or hybrid retrieval +- Exact metadata, subject, memory type, session, trust tier, user/group, active-memory, and branch SQL pre-filters +- Strict session equality (unscoped memories are excluded when `session_id` is supplied) +- REST and sync/async Python SDK only; intentionally not added to the MCP tool surface + **Admin API** (`GET/DELETE /admin/*`, `POST /admin/governance/:id/trigger`) - User listing, per-user stats, user deletion - Trigger governance/consolidate per user on demand diff --git a/memoria/crates/memoria-api/src/lib.rs b/memoria/crates/memoria-api/src/lib.rs index 230ef930..69010d6c 100644 --- a/memoria/crates/memoria-api/src/lib.rs +++ b/memoria/crates/memoria-api/src/lib.rs @@ -239,6 +239,10 @@ pub fn build_router(state: AppState) -> Router { // Memory reads .route("/v1/memories", get(routes::memory::list_memories)) .route("/v1/memories/query", post(routes::memory::query_memories)) + .route( + "/v1/memories/fulltext-search", + post(routes::memory::fulltext_search_memories), + ) .route("/v1/memories/retrieve", post(routes::memory::retrieve)) .route("/v1/memories/search", post(routes::memory::search)) .route("/v1/memories/:id", get(routes::memory::get_memory)) diff --git a/memoria/crates/memoria-api/src/models.rs b/memoria/crates/memoria-api/src/models.rs index 0adfea60..8e2ee200 100644 --- a/memoria/crates/memoria-api/src/models.rs +++ b/memoria/crates/memoria-api/src/models.rs @@ -85,6 +85,15 @@ fn parse_memory_types_opt( Ok(parsed) } +fn parse_fulltext_memory_types_opt( + types: Option<&Vec>, +) -> Result>, String> { + if types.is_some_and(|types| types.iter().any(|value| value.trim().is_empty())) { + return Err("memory_types entries must not be empty when provided".to_string()); + } + parse_memory_types_opt(types) +} + impl RetrieveRequest { pub fn session_scope(&self) -> Result, String> { parse_session_scope(self.session_scope.as_deref()) @@ -232,6 +241,60 @@ fn normalized(value: Option<&str>) -> Option { .map(str::to_string) } +fn default_fulltext_search_limit() -> i64 { + memoria_storage::FULLTEXT_SEARCH_DEFAULT_LIMIT +} + +/// Pure MatrixOne full-text search with exact structured SQL pre-filters. +/// Session filtering is strict and does not include unscoped memories. +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub struct FulltextSearchRequest { + pub query: String, + #[serde(default)] + pub extra_metadata_filter: HashMap, + pub subject_id: Option, + pub memory_types: Option>, + pub session_id: Option, + pub trust_tier: Option, + pub branch: Option, + #[serde(default = "default_fulltext_search_limit")] + pub limit: i64, +} + +impl FulltextSearchRequest { + pub fn fulltext_options(&self) -> Result { + if !(1..=memoria_storage::FULLTEXT_SEARCH_MAX_LIMIT).contains(&self.limit) { + return Err(format!( + "limit must be between 1 and {}", + memoria_storage::FULLTEXT_SEARCH_MAX_LIMIT + )); + } + memoria_storage::validate_fulltext_query(&self.query).map_err(|error| error.to_string())?; + memoria_storage::validate_extra_metadata_filter(&self.extra_metadata_filter) + .map_err(|error| error.to_string())?; + + let session_id = normalized_filter("session_id", self.session_id.as_deref())?; + let subject_id = normalized_filter("subject_id", self.subject_id.as_deref())?; + let trust_tier = normalized_filter("trust_tier", self.trust_tier.as_deref())? + .as_deref() + .map(parse_trust_tier) + .transpose()?; + Ok(memoria_service::FulltextSearchOptions { + limit: self.limit, + memory_types: parse_fulltext_memory_types_opt(self.memory_types.as_ref())?, + session_id, + trust_tier, + subject_id, + extra_metadata_filter: self.extra_metadata_filter.clone(), + }) + } + + pub fn fulltext_branch(&self) -> Result, String> { + normalized_filter("branch", self.branch.as_deref()) + } +} + fn normalized_filter(name: &str, value: Option<&str>) -> Result, String> { value .map(|value| { @@ -600,7 +663,9 @@ pub fn parse_trust_tier(s: &str) -> Result { #[cfg(test)] mod tests { - use super::{parse_memory_types_opt, PurgeRequest, PurgeSelector}; + use super::{ + parse_fulltext_memory_types_opt, parse_memory_types_opt, PurgeRequest, PurgeSelector, + }; use memoria_core::MemoryType; #[test] @@ -617,6 +682,14 @@ mod tests { Some(vec![MemoryType::Semantic, MemoryType::Profile]) ); } + #[test] + fn fulltext_memory_type_parser_rejects_blank_entries_but_allows_empty_arrays() { + let blank = vec!["semantic".to_string(), " ".to_string()]; + assert!(parse_fulltext_memory_types_opt(Some(&blank)).is_err()); + + let empty = vec![]; + assert_eq!(parse_fulltext_memory_types_opt(Some(&empty)).unwrap(), None); + } #[test] fn purge_selector_ignores_empty_arrays() { diff --git a/memoria/crates/memoria-api/src/routes/memory.rs b/memoria/crates/memoria-api/src/routes/memory.rs index a8f8b2c6..b84bf440 100644 --- a/memoria/crates/memoria-api/src/routes/memory.rs +++ b/memoria/crates/memoria-api/src/routes/memory.rs @@ -208,6 +208,30 @@ pub async fn query_memories( Ok(Json(ListResponse { items, next_cursor })) } +pub async fn fulltext_search_memories( + State(state): State, + auth: AuthUser, + Json(req): Json, +) -> ApiResult> { + let branch = req + .fulltext_branch() + .map_err(|error| (StatusCode::UNPROCESSABLE_ENTITY, error))?; + let options = req + .fulltext_options() + .map_err(|error| (StatusCode::UNPROCESSABLE_ENTITY, error))?; + let memories = state + .service + .search_fulltext_structured_on_branch( + auth.scope_id(), + branch.as_deref(), + &req.query, + &options, + ) + .await + .map_err(api_err_typed)?; + Ok(Json(memories.into_iter().map(Into::into).collect())) +} + pub async fn store_memory( State(state): State, auth: AuthUser, diff --git a/memoria/crates/memoria-api/tests/api_e2e.rs b/memoria/crates/memoria-api/tests/api_e2e.rs index d6448f9d..ef1af09a 100644 --- a/memoria/crates/memoria-api/tests/api_e2e.rs +++ b/memoria/crates/memoria-api/tests/api_e2e.rs @@ -739,6 +739,266 @@ async fn test_api_structured_query_on_branch() { // ── 2b. list response is lightweight (no embedding) and respects limit ──────── +#[tokio::test] +async fn test_api_fulltext_search_with_structured_prefilters() { + let (base, client, _server) = spawn_server().await; + let user_id = uid(); + let other_user_id = uid(); + let token = format!("fulltext{}", uuid::Uuid::new_v4().simple()); + let subject_id = format!("subject_{}", uuid::Uuid::new_v4().simple()); + let session_id = format!("session_{}", uuid::Uuid::new_v4().simple()); + + for (suffix, memory_type, subject, session, tier, metadata) in [ + ( + "target", + "semantic", + subject_id.as_str(), + Some(session_id.as_str()), + "T2", + json!({"scene": "incident", "rank": 2}), + ), + ( + "wrong metadata", + "semantic", + subject_id.as_str(), + Some(session_id.as_str()), + "T2", + json!({"scene": "review", "rank": 2}), + ), + ( + "wrong metadata type", + "semantic", + subject_id.as_str(), + Some(session_id.as_str()), + "T2", + json!({"scene": "incident", "rank": "2"}), + ), + ( + "wrong session", + "semantic", + subject_id.as_str(), + Some("another_session"), + "T2", + json!({"scene": "incident", "rank": 2}), + ), + ( + "unscoped session", + "semantic", + subject_id.as_str(), + None, + "T2", + json!({"scene": "incident", "rank": 2}), + ), + ( + "wrong subject", + "semantic", + "another_subject", + Some(session_id.as_str()), + "T2", + json!({"scene": "incident", "rank": 2}), + ), + ( + "wrong trust tier", + "semantic", + subject_id.as_str(), + Some(session_id.as_str()), + "T3", + json!({"scene": "incident", "rank": 2}), + ), + ( + "wrong memory type", + "profile", + subject_id.as_str(), + Some(session_id.as_str()), + "T2", + json!({"scene": "incident", "rank": 2}), + ), + ] { + let response = client + .post(format!("{base}/v1/memories")) + .header("X-User-Id", &user_id) + .json(&json!({ + "content": format!("{token} {suffix}"), + "memory_type": memory_type, + "subject_id": subject, + "session_id": session, + "trust_tier": tier, + "extra_metadata": metadata + })) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 201); + } + + let response = client + .post(format!("{base}/v1/memories")) + .header("X-User-Id", &other_user_id) + .json(&json!({ + "content": format!("{token} wrong user"), + "memory_type": "semantic", + "subject_id": subject_id, + "session_id": session_id, + "trust_tier": "T2", + "extra_metadata": {"scene": "incident", "rank": 2} + })) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 201); + + let body = wait_for_api_payload_contains( + &client, + &base, + &user_id, + "/v1/memories/fulltext-search", + json!({ + "query": token, + "extra_metadata_filter": {"scene": "incident", "rank": 2}, + "subject_id": subject_id, + "memory_types": ["semantic"], + "session_id": session_id, + "trust_tier": "T2", + "limit": 10 + }), + &["target"], + ) + .await; + let items = body.as_array().expect("fulltext response array"); + assert_eq!(items.len(), 1, "all pre-filters must be applied: {body}"); + assert_eq!(items[0]["content"], format!("{token} target")); + assert!(items[0]["retrieval_score"].is_number()); + + for invalid_request in [ + json!({"query": ""}), + json!({"query": "!!!"}), + json!({"query": "valid", "limit": 0}), + json!({"query": "valid", "limit": 101}), + json!({"query": "valid", "session_id": " "}), + json!({"query": "valid", "subject_id": " "}), + json!({"query": "valid", "trust_tier": " "}), + json!({"query": "valid", "branch": " "}), + json!({"query": "valid", "memory_types": [" "]}), + json!({"query": "valid", "memory_types": ["semantic", ""]}), + json!({"query": "a".repeat(memoria_storage::FULLTEXT_QUERY_MAX_BYTES + 1)}), + json!({"query": "valid", "extra_metadata_filters": {"scene": "incident"}}), + json!({"query": "valid", "extra_metadata_filter": {"nested": {"value": 1}}}), + ] { + let response = client + .post(format!("{base}/v1/memories/fulltext-search")) + .header("X-User-Id", &user_id) + .json(&invalid_request) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 422, "request: {invalid_request}"); + } + + // MatrixOne tokenizes a single-character NGRAM query to an empty pattern. + // The public endpoint treats that database condition as a valid empty result. + let response = client + .post(format!("{base}/v1/memories/fulltext-search")) + .header("X-User-Id", &user_id) + .json(&json!({"query": "a"})) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 200); + let body: Value = response.json().await.unwrap(); + assert!(body.as_array().unwrap().is_empty()); +} + +#[tokio::test] +async fn test_api_fulltext_search_orders_by_score_then_memory_id() { + let (base, client, _server) = spawn_server().await; + let user_id = uid(); + let primary = format!("primary{}", uuid::Uuid::new_v4().simple()); + let secondary = format!("secondary{}", uuid::Uuid::new_v4().simple()); + + let high_id = + store_memory_unscoped(&client, &base, &user_id, &format!("{primary} {secondary}")).await; + let first_tie_id = store_memory_unscoped(&client, &base, &user_id, &primary).await; + let second_tie_id = store_memory_unscoped(&client, &base, &user_id, &primary).await; + + let body = wait_for_api_payload_contains( + &client, + &base, + &user_id, + "/v1/memories/fulltext-search", + json!({"query": format!("{primary} {secondary}"), "limit": 10}), + &[&high_id, &first_tie_id, &second_tie_id], + ) + .await; + let items = body.as_array().expect("fulltext response array"); + assert_eq!( + items.len(), + 3, + "all ranked fixtures must be returned: {body}" + ); + assert_eq!(items[0]["memory_id"], high_id); + + let high_score = items[0]["retrieval_score"].as_f64().unwrap(); + let first_tie_score = items[1]["retrieval_score"].as_f64().unwrap(); + let second_tie_score = items[2]["retrieval_score"].as_f64().unwrap(); + assert!(high_score > first_tie_score); + assert_eq!(first_tie_score, second_tie_score); + + let mut expected_tie_ids = [first_tie_id, second_tie_id]; + expected_tie_ids.sort_by(|left, right| right.cmp(left)); + assert_eq!(items[1]["memory_id"], expected_tie_ids[0]); + assert_eq!(items[2]["memory_id"], expected_tie_ids[1]); +} + +#[tokio::test] +async fn test_api_fulltext_search_on_branch_is_isolated_from_main() { + let (base, client, _server) = spawn_server().await; + let user_id = uid(); + let branch = format!( + "fulltext_{}", + &uuid::Uuid::new_v4().simple().to_string()[..8] + ); + let token = format!("branchfulltext{}", uuid::Uuid::new_v4().simple()); + + let response = client + .post(format!("{base}/v1/branches")) + .header("X-User-Id", &user_id) + .json(&json!({"name": branch})) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 201); + let response = client + .post(format!("{base}/v1/memories")) + .header("X-User-Id", &user_id) + .json(&json!({"content": token, "branch": branch})) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 201); + + let branch_body = wait_for_api_payload_contains( + &client, + &base, + &user_id, + "/v1/memories/fulltext-search", + json!({"query": token, "branch": branch}), + &[&token], + ) + .await; + assert_eq!(branch_body.as_array().unwrap().len(), 1); + + let main_response = client + .post(format!("{base}/v1/memories/fulltext-search")) + .header("X-User-Id", &user_id) + .json(&json!({"query": token})) + .send() + .await + .unwrap(); + assert_eq!(main_response.status(), 200); + let main_body: Value = main_response.json().await.unwrap(); + assert!(main_body.as_array().unwrap().is_empty()); +} + #[tokio::test] async fn test_api_list_no_embedding_and_limit() { let (base, client, _server) = spawn_server().await; diff --git a/memoria/crates/memoria-service/src/lib.rs b/memoria/crates/memoria-service/src/lib.rs index 6c2cb2bb..7ec034ad 100644 --- a/memoria/crates/memoria-service/src/lib.rs +++ b/memoria/crates/memoria-service/src/lib.rs @@ -45,8 +45,9 @@ pub use scoring::{ DefaultScoringPlugin, FeedbackTotals, ScoringPlugin, ScoringStore, TuningResult, }; pub use service::{ - CandidateScore, ExplainLevel, InMemoryFlusher, ListActiveOptions, MemoryService, PurgeResult, - RetrievalExplain, RetrieveOptions, SessionScope, StructuredQueryOptions, + CandidateScore, ExplainLevel, FulltextSearchOptions, InMemoryFlusher, ListActiveOptions, + MemoryService, PurgeResult, RetrievalExplain, RetrieveOptions, SessionScope, + StructuredQueryOptions, ENTITY_EXTRACTION_DROPS, }; pub use stats_reporter::StatsReporter; diff --git a/memoria/crates/memoria-service/src/service.rs b/memoria/crates/memoria-service/src/service.rs index b3641f4a..b3c86e6e 100644 --- a/memoria/crates/memoria-service/src/service.rs +++ b/memoria/crates/memoria-service/src/service.rs @@ -176,6 +176,19 @@ pub struct StructuredQueryOptions { pub extra_metadata_filter: HashMap, } +/// Filters for pure MatrixOne full-text search. Structured fields are exact SQL +/// pre-filters applied before the Top-K limit; session filtering does not include +/// unscoped memories. +#[derive(Debug, Clone)] +pub struct FulltextSearchOptions { + pub limit: i64, + pub memory_types: Option>, + pub session_id: Option, + pub trust_tier: Option, + pub subject_id: Option, + pub extra_metadata_filter: HashMap, +} + impl ListActiveOptions<'_> { pub fn new(limit: i64) -> Self { Self { @@ -2628,6 +2641,37 @@ impl MemoryService { )) } + /// Run pure MatrixOne full-text search with structured SQL pre-filters. + pub async fn search_fulltext_structured_on_branch( + &self, + user_id: &str, + branch: Option<&str>, + query: &str, + options: &FulltextSearchOptions, + ) -> Result, MemoriaError> { + if self.sql_store.is_none() { + return Err(MemoriaError::Internal( + "structured fulltext search requires a SQL-backed memory store".to_string(), + )); + } + + let sql = self.user_sql_store(user_id).await?; + let table = sql.table_for_branch(user_id, branch).await?; + let trust_tier = options.trust_tier.as_ref().map(ToString::to_string); + sql.search_fulltext_structured_lite( + &table, + user_id, + query, + options.limit, + options.memory_types.as_deref(), + options.session_id.as_deref(), + trust_tier.as_deref(), + options.subject_id.as_deref(), + &options.extra_metadata_filter, + ) + .await + } + pub async fn embed(&self, text: &str) -> Result>, MemoriaError> { match self.embedder.as_ref() { None => Ok(None), diff --git a/memoria/crates/memoria-storage/src/graph/store.rs b/memoria/crates/memoria-storage/src/graph/store.rs index e32053d9..d4946994 100644 --- a/memoria/crates/memoria-storage/src/graph/store.rs +++ b/memoria/crates/memoria-storage/src/graph/store.rs @@ -2,7 +2,7 @@ //! Mirrors Python's graph/graph_store.py core methods needed for consolidation. use crate::graph::types::{edge_type, GraphEdge, GraphNode, NodeType}; -use crate::store::db_err; +use crate::store::{db_err, fulltext_rows_or_empty}; use memoria_core::{nullable_str, nullable_str_from_row, MemoriaError}; use sqlx::{MySqlPool, Row}; use uuid::Uuid; @@ -829,21 +829,13 @@ impl GraphStore { ORDER BY ft_score DESC LIMIT ?", self.t("memory_graph_nodes"), ); - let rows = match sqlx::query(&sql) - .bind(user_id) - .bind(top_k) - .fetch_all(&self.pool) - .await - { - Ok(rows) => rows, - Err(e) => { - let msg = e.to_string(); - if msg.contains("20101") && msg.contains("empty pattern") { - return Ok(vec![]); - } - return Err(db_err(e)); - } - }; + let rows = fulltext_rows_or_empty( + sqlx::query(&sql) + .bind(user_id) + .bind(top_k) + .fetch_all(&self.pool) + .await, + )?; Ok(rows .iter() .map(|r| { diff --git a/memoria/crates/memoria-storage/src/lib.rs b/memoria/crates/memoria-storage/src/lib.rs index 0687bb28..ade2bcdb 100644 --- a/memoria/crates/memoria-storage/src/lib.rs +++ b/memoria/crates/memoria-storage/src/lib.rs @@ -23,8 +23,9 @@ pub use pool_config::{ pub use router::{DbRouter, UserDatabaseRecord}; pub use store::{ snapshot_extra_memory_count, snapshot_extra_with_memory_count, validate_extra_metadata_filter, - FeedbackStats, MemoryFeedback, OwnedEditLogEntry, PoolHealthLevel, PoolHealthSnapshot, - SqlMemoryStore, TierFeedback, UserRetrievalParams, ACTOR_USER_ID, + validate_fulltext_query, FeedbackStats, MemoryFeedback, OwnedEditLogEntry, PoolHealthLevel, + PoolHealthSnapshot, SqlMemoryStore, TierFeedback, UserRetrievalParams, ACTOR_USER_ID, EXTRA_METADATA_FILTER_MAX_FIELDS, EXTRA_METADATA_FILTER_MAX_KEY_BYTES, - EXTRA_METADATA_FILTER_MAX_VALUE_BYTES, + EXTRA_METADATA_FILTER_MAX_VALUE_BYTES, FULLTEXT_QUERY_MAX_BYTES, + FULLTEXT_SEARCH_DEFAULT_LIMIT, FULLTEXT_SEARCH_MAX_LIMIT, }; diff --git a/memoria/crates/memoria-storage/src/store.rs b/memoria/crates/memoria-storage/src/store.rs index eb99df06..c10e49c7 100644 --- a/memoria/crates/memoria-storage/src/store.rs +++ b/memoria/crates/memoria-storage/src/store.rs @@ -15,6 +15,9 @@ use std::time::{SystemTime, UNIX_EPOCH}; pub const EXTRA_METADATA_FILTER_MAX_FIELDS: usize = 16; pub const EXTRA_METADATA_FILTER_MAX_KEY_BYTES: usize = 64; pub const EXTRA_METADATA_FILTER_MAX_VALUE_BYTES: usize = 1024; +pub const FULLTEXT_SEARCH_DEFAULT_LIMIT: i64 = 20; +pub const FULLTEXT_SEARCH_MAX_LIMIT: i64 = 100; +pub const FULLTEXT_QUERY_MAX_BYTES: usize = 4096; /// Validate the public structured-query metadata contract at the storage boundary. /// Keys become JSON paths, so the first character must be an ASCII letter or @@ -27,7 +30,6 @@ pub fn validate_extra_metadata_filter( "extra_metadata_filter must not contain more than {EXTRA_METADATA_FILTER_MAX_FIELDS} fields" ))); } - for (key, value) in filter { let mut chars = key.chars(); let valid_first = chars @@ -75,6 +77,44 @@ pub(crate) fn db_err(e: sqlx::Error) -> MemoriaError { MemoriaError::Database(e.to_string()) } +/// MatrixOne currently reports an empty tokenized full-text pattern as generic +/// internal error 20101. The code is shared by many internal errors, so retain +/// the specific message check in one place until MatrixOne exposes a dedicated +/// error code. See MatrixOne `pkg/fulltext/fulltext.go`. +fn is_empty_fulltext_pattern_error(error: &sqlx::Error) -> bool { + use sqlx::mysql::MySqlDatabaseError; + + error + .as_database_error() + .and_then(|database_error| { + database_error + .as_error() + .downcast_ref::() + }) + .is_some_and(|mysql_error| { + mysql_error.number() == 20101 + && mysql_error.message().contains("empty pattern") + }) +} + +pub(crate) fn fulltext_rows_or_empty( + result: Result, sqlx::Error>, +) -> Result, MemoriaError> { + match result { + Ok(rows) => Ok(rows), + Err(error) if is_empty_fulltext_pattern_error(&error) => Ok(Vec::new()), + Err(error) => Err(db_err(error)), + } +} + +fn apply_fulltext_score(row: &sqlx::mysql::MySqlRow, memory: &mut Memory) { + if let Ok(score) = row.try_get::("ft_score") { + memory.retrieval_score = Some(score); + } else if let Ok(score) = row.try_get::("ft_score") { + memory.retrieval_score = Some(score as f64); + } +} + /// Returns true when a failed ALTER TABLE ADD COLUMN was rejected because /// the column already exists (MySQL/MatrixOne error 1060). /// This is the expected outcome when the column was created by CREATE TABLE @@ -739,6 +779,23 @@ fn sanitize_fulltext_query(s: &str) -> String { .join(" ") } +/// Validate the public full-text query contract. Stopword-only queries remain +/// valid and may return no rows after MatrixOne tokenization. +pub fn validate_fulltext_query(query: &str) -> Result<(), MemoriaError> { + if query.len() > FULLTEXT_QUERY_MAX_BYTES { + return Err(MemoriaError::Validation(format!( + "fulltext query must not exceed {FULLTEXT_QUERY_MAX_BYTES} bytes" + ))); + } + if sanitize_fulltext_query(query).is_empty() { + return Err(MemoriaError::Validation( + "fulltext query must contain at least one Unicode letter, number, or underscore" + .to_string(), + )); + } + Ok(()) +} + /// Sanitize a string for use in a LIKE pattern (escapes `%`). fn sanitize_like_pattern(s: &str) -> String { s.chars() @@ -5357,7 +5414,7 @@ impl SqlMemoryStore { validate_extra_metadata_filter(extra_metadata_filter)?; let table = self.t(table); let mut metadata_filters: Vec<_> = extra_metadata_filter.iter().collect(); - metadata_filters.sort_by(|(left, _), (right, _)| left.cmp(right)); + metadata_filters.sort_by_key(|(key, _)| *key); let mut inner = format!("SELECT memory_id FROM {table} WHERE user_id = ? AND is_active = 1"); @@ -5576,31 +5633,110 @@ impl SqlMemoryStore { stmt = stmt.bind(mt.to_string()); } } - let rows = match stmt.bind(limit).fetch_all(&self.pool).await { - Ok(rows) => rows, - Err(e) => { - // MatrixOne returns 20101 when the search string tokenizes to an empty pattern - // (e.g. all stopwords, single chars, or unsupported Unicode). Treat as no results. - let msg = e.to_string(); - if msg.contains("20101") && msg.contains("empty pattern") { - return Ok(vec![]); - } - return Err(db_err(e)); - } - }; + let rows = fulltext_rows_or_empty(stmt.bind(limit).fetch_all(&self.pool).await)?; rows.iter() .map(|r| { let mut m = row_to_memory(r)?; - if let Ok(ft) = r.try_get::("ft_score") { - m.retrieval_score = Some(ft); - } else if let Ok(ft) = r.try_get::("ft_score") { - m.retrieval_score = Some(ft as f64); - } + apply_fulltext_score(r, &mut m); Ok(m) }) .collect() } + /// Pure MatrixOne full-text search with exact structured SQL pre-filters. + /// This path performs no embedding, vector, graph, hybrid, temporal, or + /// confidence scoring. `session_id` is strict and does not include unscoped + /// rows. Metadata equality follows JSON type-family semantics: number `2` + /// may equal `2.0`, but does not equal string `"2"`. + #[allow(clippy::too_many_arguments)] + pub async fn search_fulltext_structured_lite( + &self, + table: &str, + user_id: &str, + query: &str, + limit: i64, + memory_types: Option<&[MemoryType]>, + session_id: Option<&str>, + trust_tier: Option<&str>, + subject_id: Option<&str>, + extra_metadata_filter: &std::collections::HashMap, + ) -> Result, MemoriaError> { + if !(1..=FULLTEXT_SEARCH_MAX_LIMIT).contains(&limit) { + return Err(MemoriaError::Validation( + format!( + "fulltext search storage limit must be between 1 and {FULLTEXT_SEARCH_MAX_LIMIT}" + ), + )); + } + validate_fulltext_query(query)?; + validate_extra_metadata_filter(extra_metadata_filter)?; + + let safe_query = sanitize_fulltext_query(query); + let table = self.t(table); + let mut metadata_filters: Vec<_> = extra_metadata_filter.iter().collect(); + metadata_filters.sort_by_key(|(key, _)| *key); + + let mut sql = format!( + "SELECT memory_id, user_id, author_id, subject_id, memory_type, content, \ + session_id, is_active, superseded_by, trust_tier, \ + initial_confidence, observed_at, created_at, updated_at, \ + CAST(extra_metadata AS CHAR) AS extra_meta, \ + MATCH(content) AGAINST('{safe_query}' IN BOOLEAN MODE) AS ft_score \ + FROM {table} WHERE user_id = ? AND is_active = 1" + ); + if let Some(types) = memory_types.filter(|types| !types.is_empty()) { + sql.push_str(" AND memory_type IN ("); + sql.push_str(&vec!["?"; types.len()].join(", ")); + sql.push(')'); + } + if session_id.is_some() { + sql.push_str(" AND session_id = ?"); + } + if trust_tier.is_some() { + sql.push_str(" AND trust_tier = ?"); + } + if subject_id.is_some() { + sql.push_str(" AND subject_id = ?"); + } + for (key, _) in &metadata_filters { + sql.push_str(&format!( + " AND json_extract(extra_metadata, '$.{key}') = CAST(? AS JSON)" + )); + } + sql.push_str(&format!( + " AND MATCH(content) AGAINST('{safe_query}' IN BOOLEAN MODE) \ + ORDER BY ft_score DESC, memory_id DESC LIMIT ?" + )); + + let mut statement = sqlx::query(&sql).bind(user_id); + if let Some(types) = memory_types.filter(|types| !types.is_empty()) { + for memory_type in types { + statement = statement.bind(memory_type.to_string()); + } + } + if let Some(value) = session_id { + statement = statement.bind(value); + } + if let Some(value) = trust_tier { + statement = statement.bind(value); + } + if let Some(value) = subject_id { + statement = statement.bind(value); + } + for (_, value) in metadata_filters { + statement = statement.bind(serde_json::to_string(value)?); + } + + let rows = fulltext_rows_or_empty(statement.bind(limit).fetch_all(&self.pool).await)?; + rows.iter() + .map(|row| { + let mut memory = row_to_memory_lite(row)?; + apply_fulltext_score(row, &mut memory); + Ok(memory) + }) + .collect() + } + pub async fn search_vector_from( &self, table: &str, @@ -6135,8 +6271,9 @@ fn build_safety_snapshot_name(db_name: Option<&str>, operation: &str) -> String mod tests { use super::{ classify_pool_health, detect_connection_anomaly, should_emit_saturated_warning, - validate_extra_metadata_filter, ConnectionAnomalyKind, OwnedEditLogEntry, PoolHealthLevel, - PoolHealthSnapshot, SqlMemoryStore, + validate_extra_metadata_filter, validate_fulltext_query, ConnectionAnomalyKind, + OwnedEditLogEntry, PoolHealthLevel, PoolHealthSnapshot, SqlMemoryStore, + FULLTEXT_QUERY_MAX_BYTES, }; use sqlx::mysql::MySqlPoolOptions; use std::io::{self, Write}; @@ -6145,7 +6282,7 @@ mod tests { static LOG_TEST_LOCK: OnceLock> = OnceLock::new(); #[test] - fn structured_metadata_filter_validation_is_enforced_at_storage_boundary() { + fn metadata_and_fulltext_validation_are_enforced_at_storage_boundary() { let valid = std::collections::HashMap::from([ ("_scene".to_string(), serde_json::json!("incident")), ("rank2".to_string(), serde_json::json!(2)), @@ -6167,6 +6304,11 @@ mod tests { .map(|index| (format!("key_{index}"), serde_json::json!(index))) .collect(); assert!(validate_extra_metadata_filter(&too_many).is_err()); + assert!(validate_fulltext_query("MatrixOne database").is_ok()); + assert!(validate_fulltext_query(" !@#$ ").is_err()); + assert!(validate_fulltext_query(&"a".repeat(FULLTEXT_QUERY_MAX_BYTES)).is_ok()); + assert!(validate_fulltext_query(&"a".repeat(FULLTEXT_QUERY_MAX_BYTES + 1)).is_err()); + assert!(validate_fulltext_query(&"界".repeat(FULLTEXT_QUERY_MAX_BYTES / 3 + 1)).is_err()); } #[test] diff --git a/sdk/python/CHANGELOG.md b/sdk/python/CHANGELOG.md index 4457b030..9a0fcc7e 100644 --- a/sdk/python/CHANGELOG.md +++ b/sdk/python/CHANGELOG.md @@ -5,6 +5,10 @@ ### Added - Sync and async `memories.query()` for exact structured filtering through the REST API, including scalar `extra_metadata`, subject, type, session, trust tier, branch, and pagination. +- Sync and async `memories.fulltext_search()` for pure MatrixOne full-text search with + exact scalar `extra_metadata_filter` and fixed-field SQL pre-filters, without vector or + graph retrieval. Session filtering is strict and the endpoint is intentionally not exposed + by MCP. ### Fixed - `ping()` no longer wraps `MemoriaAuthError` / `MemoriaNotFoundError` and other API errors diff --git a/sdk/python/README.md b/sdk/python/README.md index 5167a0a3..48431809 100644 --- a/sdk/python/README.md +++ b/sdk/python/README.md @@ -71,6 +71,17 @@ result = client.memories.retrieve(query="...", top_k=5) # Search result = client.memories.search(query="...", top_k=10) +# Pure MatrixOne full-text search with structured pre-filters (query max 4096 UTF-8 bytes) +result = client.memories.fulltext_search( + "MatrixOne database", + extra_metadata_filter={"scene": "incident"}, + subject_id="subject-123", + session_id="session-123", + limit=20, +) +# session_id is an exact filter: memories with session_id=None are excluded. +# Metadata preserves JSON type families: 2 may match 2.0, but not the string "2". + # List with pagination page = client.memories.list(limit=100, cursor=None) # page.next_cursor — pass as cursor= to get the next page diff --git a/sdk/python/src/memoria/resources/memories.py b/sdk/python/src/memoria/resources/memories.py index 9c840ddb..0c1052b5 100644 --- a/sdk/python/src/memoria/resources/memories.py +++ b/sdk/python/src/memoria/resources/memories.py @@ -12,6 +12,15 @@ if TYPE_CHECKING: from .._http import _HttpTransport +# Public REST contract. Keep these values synchronized with the exported +# constants in `memoria-storage/src/store.rs`. +_EXTRA_METADATA_FILTER_MAX_FIELDS = 16 +_EXTRA_METADATA_FILTER_MAX_KEY_BYTES = 64 +_EXTRA_METADATA_FILTER_MAX_VALUE_BYTES = 1024 +_FULLTEXT_QUERY_MAX_BYTES = 4096 +_FULLTEXT_SEARCH_DEFAULT_LIMIT = 20 +_FULLTEXT_SEARCH_MAX_LIMIT = 100 + _MEMORY_TYPE_NAMES = { "semantic", "working", @@ -150,6 +159,135 @@ def _validate_structured_query( return memory_types, subject_id, session_id, trust_tier, branch +def _validate_fulltext_metadata_filter( + extra_metadata_filter: dict[str, Any] | None, +) -> None: + if extra_metadata_filter is None: + return + if not isinstance(extra_metadata_filter, dict): + raise MemoriaValidationError("extra_metadata_filter must be a dictionary") + if len(extra_metadata_filter) > _EXTRA_METADATA_FILTER_MAX_FIELDS: + raise MemoriaValidationError( + "extra_metadata_filter must not contain more than " + f"{_EXTRA_METADATA_FILTER_MAX_FIELDS} fields" + ) + for key, value in extra_metadata_filter.items(): + if not isinstance(key, str): + raise MemoriaValidationError("extra_metadata_filter keys must be strings") + try: + key_bytes = len(key.encode("utf-8")) + except UnicodeEncodeError as error: + raise MemoriaValidationError( + "extra_metadata_filter keys must be valid UTF-8" + ) from error + valid_key = ( + bool(key) + and key_bytes <= _EXTRA_METADATA_FILTER_MAX_KEY_BYTES + and (key[0].isascii() and (key[0].isalpha() or key[0] == "_")) + and all(char.isascii() and (char.isalnum() or char == "_") for char in key[1:]) + ) + if not valid_key: + raise MemoriaValidationError( + "extra_metadata_filter keys must start with an ASCII letter or underscore " + "and contain only ASCII letters, digits, or underscore, and must not " + f"exceed {_EXTRA_METADATA_FILTER_MAX_KEY_BYTES} bytes" + ) + if not isinstance(value, (str, int, float, bool)): + raise MemoriaValidationError( + "extra_metadata_filter values must be strings, numbers, or booleans" + ) + if isinstance(value, float) and not math.isfinite(value): + raise MemoriaValidationError("extra_metadata_filter numeric values must be finite") + try: + encoded_value = json.dumps( + value, ensure_ascii=False, separators=(",", ":") + ).encode("utf-8") + except (ValueError, UnicodeEncodeError) as error: + raise MemoriaValidationError( + "extra_metadata_filter values must be valid JSON scalars encoded as UTF-8" + ) from error + if len(encoded_value) > _EXTRA_METADATA_FILTER_MAX_VALUE_BYTES: + raise MemoriaValidationError( + "extra_metadata_filter values must not exceed " + f"{_EXTRA_METADATA_FILTER_MAX_VALUE_BYTES} bytes" + ) + + +def _normalize_fulltext_memory_types( + memory_types: list[str] | None, +) -> list[str] | None: + if memory_types is None: + return None + if not isinstance(memory_types, list): + raise MemoriaValidationError("fulltext_search: memory_types must be a list") + normalized: list[str] = [] + seen: set[str] = set() + for value in memory_types: + if not isinstance(value, str): + raise MemoriaValidationError( + "fulltext_search: memory_types entries must be strings" + ) + value = value.strip() + if not value: + raise MemoriaValidationError( + "fulltext_search: memory_types entries must not be empty when provided" + ) + if value not in _MEMORY_TYPE_NAMES: + raise MemoriaValidationError(f"fulltext_search: unknown memory type: {value}") + if value not in seen: + seen.add(value) + normalized.append(value) + return normalized or None + + +def _validate_fulltext_search( + query: str, + extra_metadata_filter: dict[str, Any] | None, + limit: int, + *, + subject_id: str | None, + session_id: str | None, + trust_tier: str | None, + branch: str | None, + memory_types: list[str] | None, +) -> list[str] | None: + if type(limit) is not int or limit < 1 or limit > _FULLTEXT_SEARCH_MAX_LIMIT: + raise MemoriaValidationError( + f"fulltext_search: limit must be between 1 and {_FULLTEXT_SEARCH_MAX_LIMIT}" + ) + if not isinstance(query, str): + raise MemoriaValidationError("fulltext_search: query must be a string") + try: + query_bytes = len(query.encode("utf-8")) + except UnicodeEncodeError as error: + raise MemoriaValidationError( + "fulltext_search: query must be valid UTF-8" + ) from error + if query_bytes > _FULLTEXT_QUERY_MAX_BYTES: + raise MemoriaValidationError( + f"fulltext_search: query must not exceed {_FULLTEXT_QUERY_MAX_BYTES} bytes" + ) + if not query or not any(char.isalnum() or char == "_" for char in query): + raise MemoriaValidationError( + "fulltext_search: query must contain at least one Unicode letter, number, or underscore" + ) + for name, value in [ + ("subject_id", subject_id), + ("session_id", session_id), + ("trust_tier", trust_tier), + ("branch", branch), + ]: + if value is not None and (not isinstance(value, str) or not value.strip()): + raise MemoriaValidationError( + f"fulltext_search: {name} must be a non-empty string when provided" + ) + try: + _validate_fulltext_metadata_filter(extra_metadata_filter) + except MemoriaValidationError as error: + raise MemoriaValidationError(f"fulltext_search: {error}") from error + return _normalize_fulltext_memory_types(memory_types) + + class MemoriesResource: def __init__(self, client: _HttpTransport) -> None: self._client = client @@ -240,6 +378,49 @@ def search( data = self._client._request("POST", "/v1/memories/search", json=body) return RetrieveResult.from_dict(data) + def fulltext_search( + self, + query: str, + *, + extra_metadata_filter: dict[str, Any] | None = None, + subject_id: str | None = None, + memory_types: list[str] | None = None, + session_id: str | None = None, + trust_tier: str | None = None, + branch: str | None = None, + limit: int = _FULLTEXT_SEARCH_DEFAULT_LIMIT, + ) -> RetrieveResult: + """Run pure full-text search with exact structured pre-filters. + + ``session_id`` matches only that session; unlike retrieve/search session + scoping, unscoped memories are not included. Metadata equality preserves + JSON type families: number ``2`` may equal ``2.0`` but not string ``"2"``. + """ + memory_types = _validate_fulltext_search( + query, + extra_metadata_filter, + limit, + subject_id=subject_id, + session_id=session_id, + trust_tier=trust_tier, + branch=branch, + memory_types=memory_types, + ) + body = _strip_none( + { + "query": query, + "extra_metadata_filter": extra_metadata_filter, + "subject_id": subject_id, + "memory_types": memory_types, + "session_id": session_id, + "trust_tier": trust_tier, + "branch": branch, + "limit": limit, + } + ) + data = self._client._request("POST", "/v1/memories/fulltext-search", json=body) + return RetrieveResult.from_dict(data) + def list( self, *, @@ -490,6 +671,51 @@ async def search( data = await self._client._arequest("POST", "/v1/memories/search", json=body) return RetrieveResult.from_dict(data) + async def fulltext_search( + self, + query: str, + *, + extra_metadata_filter: dict[str, Any] | None = None, + subject_id: str | None = None, + memory_types: list[str] | None = None, + session_id: str | None = None, + trust_tier: str | None = None, + branch: str | None = None, + limit: int = _FULLTEXT_SEARCH_DEFAULT_LIMIT, + ) -> RetrieveResult: + """Run pure full-text search with exact structured pre-filters. + + ``session_id`` matches only that session; unlike retrieve/search session + scoping, unscoped memories are not included. Metadata equality preserves + JSON type families: number ``2`` may equal ``2.0`` but not string ``"2"``. + """ + memory_types = _validate_fulltext_search( + query, + extra_metadata_filter, + limit, + subject_id=subject_id, + session_id=session_id, + trust_tier=trust_tier, + branch=branch, + memory_types=memory_types, + ) + body = _strip_none( + { + "query": query, + "extra_metadata_filter": extra_metadata_filter, + "subject_id": subject_id, + "memory_types": memory_types, + "session_id": session_id, + "trust_tier": trust_tier, + "branch": branch, + "limit": limit, + } + ) + data = await self._client._arequest( + "POST", "/v1/memories/fulltext-search", json=body + ) + return RetrieveResult.from_dict(data) + async def list( self, *, diff --git a/sdk/python/tests/unit/test_memories.py b/sdk/python/tests/unit/test_memories.py index 05989c32..514673ab 100644 --- a/sdk/python/tests/unit/test_memories.py +++ b/sdk/python/tests/unit/test_memories.py @@ -152,6 +152,144 @@ def test_retrieve_with_explain(httpx_mock: HTTPXMock, client: MemoriaClient) -> assert result.explain["path"] == "hybrid" +def test_fulltext_search_with_structured_filters( + httpx_mock: HTTPXMock, client: MemoriaClient +) -> None: + response = { + **MEMORY_STUB, + "retrieval_score": 1.25, + "subject_id": "subject_1", + "extra_metadata": {"scene": "incident", "rank": 2}, + } + httpx_mock.add_response(json=[response]) + result = client.memories.fulltext_search( + "MatrixOne database", + extra_metadata_filter={"scene": "incident", "rank": 2}, + subject_id="subject_1", + memory_types=["semantic", " semantic ", "semantic"], + session_id="session_1", + trust_tier="T2", + branch="experiment", + limit=20, + ) + assert isinstance(result, RetrieveResult) + assert result.items[0].retrieval_score == 1.25 + assert result.items[0].subject_id == "subject_1" + assert result.items[0].extra_metadata == {"scene": "incident", "rank": 2} + request = httpx_mock.get_request() + assert request is not None + assert request.url.path == "/v1/memories/fulltext-search" + body = json.loads(request.content) + assert body["extra_metadata_filter"] == {"scene": "incident", "rank": 2} + assert body["memory_types"] == ["semantic"] + assert body["branch"] == "experiment" + + +@pytest.mark.parametrize("query", ["", "!!!"]) +def test_fulltext_search_rejects_empty_token_query( + client: MemoriaClient, query: str +) -> None: + with pytest.raises(MemoriaValidationError, match="query"): + client.memories.fulltext_search(query) + + +def test_fulltext_search_rejects_invalid_limit_and_metadata( + client: MemoriaClient, +) -> None: + with pytest.raises(MemoriaValidationError, match="100"): + client.memories.fulltext_search("valid", limit=101) + with pytest.raises(MemoriaValidationError, match="finite"): + client.memories.fulltext_search( + "valid", extra_metadata_filter={"rank": float("nan")} + ) + + +@pytest.mark.parametrize("query", [123, None]) +def test_fulltext_search_rejects_non_string_query( + client: MemoriaClient, query: object +) -> None: + with pytest.raises(MemoriaValidationError, match="string"): + client.memories.fulltext_search(query) # type: ignore[arg-type] + + +@pytest.mark.parametrize("limit", [True, "10"]) +def test_fulltext_search_rejects_non_integer_limit( + client: MemoriaClient, limit: object +) -> None: + with pytest.raises(MemoriaValidationError, match="limit"): + client.memories.fulltext_search("valid", limit=limit) # type: ignore[arg-type] + + +def test_fulltext_search_rejects_oversized_utf8_query(client: MemoriaClient) -> None: + with pytest.raises(MemoriaValidationError, match="4096 bytes"): + client.memories.fulltext_search("界" * 1366) + + +def test_fulltext_search_translates_invalid_query_unicode( + client: MemoriaClient, +) -> None: + with pytest.raises(MemoriaValidationError, match="valid UTF-8"): + client.memories.fulltext_search("\ud800valid") + + +@pytest.mark.parametrize("metadata", [[], {1: "value"}]) +def test_fulltext_search_rejects_invalid_metadata_container_or_key( + client: MemoriaClient, metadata: object +) -> None: + with pytest.raises(MemoriaValidationError, match="dictionary|keys must be strings"): + client.memories.fulltext_search( + "valid", extra_metadata_filter=metadata # type: ignore[arg-type] + ) + + +def test_fulltext_search_reports_metadata_key_byte_limit( + client: MemoriaClient, +) -> None: + with pytest.raises(MemoriaValidationError, match="64 bytes"): + client.memories.fulltext_search( + "valid", extra_metadata_filter={"a" * 65: "value"} + ) + + +@pytest.mark.parametrize( + "value", [10**5000, "\ud800"], ids=["large-integer", "lone-surrogate"] +) +def test_fulltext_search_translates_metadata_serialization_errors( + client: MemoriaClient, value: object +) -> None: + with pytest.raises(MemoriaValidationError, match="valid JSON scalars"): + client.memories.fulltext_search("valid", extra_metadata_filter={"value": value}) + + +@pytest.mark.parametrize( + "memory_types", + [["unknown"], [1], "semantic", [" "], ["semantic", ""]], +) +def test_fulltext_search_rejects_invalid_memory_types( + client: MemoriaClient, memory_types: object +) -> None: + with pytest.raises(MemoriaValidationError, match="memory_types|memory type"): + client.memories.fulltext_search( + "valid", memory_types=memory_types # type: ignore[arg-type] + ) + + +@pytest.mark.parametrize( + ("field", "kwargs"), + [ + ("subject_id", {"subject_id": " "}), + ("session_id", {"session_id": " "}), + ("trust_tier", {"trust_tier": " "}), + ("branch", {"branch": " "}), + ], +) +def test_fulltext_search_rejects_blank_structured_filter( + client: MemoriaClient, field: str, kwargs: dict[str, str] +) -> None: + with pytest.raises(MemoriaValidationError, match=field): + client.memories.fulltext_search("valid", **kwargs) # type: ignore[arg-type] + + # --------------------------------------------------------------------------- # list # --------------------------------------------------------------------------- diff --git a/sdk/python/tests/unit/test_memories_async.py b/sdk/python/tests/unit/test_memories_async.py index 0343c1d8..1ee6cdea 100644 --- a/sdk/python/tests/unit/test_memories_async.py +++ b/sdk/python/tests/unit/test_memories_async.py @@ -38,6 +38,40 @@ async def test_retrieve_happy_path(httpx_mock: HTTPXMock, client: AsyncMemoriaCl assert len(result.items) == 1 +@pytest.mark.asyncio +async def test_fulltext_search( + httpx_mock: HTTPXMock, client: AsyncMemoriaClient +) -> None: + response = {**MEMORY_STUB, "retrieval_score": 0.75} + httpx_mock.add_response(json=[response]) + result = await client.memories.fulltext_search( + "MatrixOne", extra_metadata_filter={"scene": "incident"}, limit=10 + ) + assert isinstance(result, RetrieveResult) + assert result.items[0].retrieval_score == 0.75 + request = httpx_mock.get_request() + assert request is not None + assert request.url.path == "/v1/memories/fulltext-search" + + +@pytest.mark.asyncio +async def test_fulltext_search_rejects_invalid_runtime_types( + client: AsyncMemoriaClient, +) -> None: + with pytest.raises(MemoriaValidationError, match="string"): + await client.memories.fulltext_search(123) # type: ignore[arg-type] + with pytest.raises(MemoriaValidationError, match="limit"): + await client.memories.fulltext_search("valid", limit=True) + with pytest.raises(MemoriaValidationError, match="dictionary"): + await client.memories.fulltext_search( + "valid", extra_metadata_filter=[] # type: ignore[arg-type] + ) + with pytest.raises(MemoriaValidationError, match="session_id"): + await client.memories.fulltext_search("valid", session_id=" ") + with pytest.raises(MemoriaValidationError, match="memory_types"): + await client.memories.fulltext_search("valid", memory_types=[" "]) + + @pytest.mark.asyncio async def test_list_happy_path(httpx_mock: HTTPXMock, client: AsyncMemoriaClient) -> None: httpx_mock.add_response(json={"items": [MEMORY_STUB], "next_cursor": "cursor_xyz"}) diff --git a/skills/api-reference/SKILL.md b/skills/api-reference/SKILL.md index e6d692d7..9c2776f0 100644 --- a/skills/api-reference/SKILL.md +++ b/skills/api-reference/SKILL.md @@ -42,6 +42,34 @@ Hybrid vector + fulltext search, ranked by relevance. Same as retrieve but without session prioritization. +### Full-text Search: `POST /v1/memories/fulltext-search` + +Pure MatrixOne lexical full-text search with optional exact SQL pre-filters. It +does not generate embeddings or run vector, graph, hybrid, temporal, or +confidence scoring and is intentionally not exposed as an MCP tool. + +```json +{ + "query": "MatrixOne database", + "extra_metadata_filter": {"scene": "incident", "rank": 2}, + "subject_id": "subject-123", + "memory_types": ["semantic"], + "session_id": "session-123", + "trust_tier": "T2", + "branch": "main", + "limit": 20 +} +``` + +All supplied filters use `AND`. `session_id` is strict: unscoped memories with +`session_id: null` are not included. This differs from retrieve/search session +scoping, which can include unscoped memories. Metadata equality preserves JSON +type families: number `2` may equal `2.0`, while string `"2"` does not equal +number `2`. Query length is limited to 4096 UTF-8 bytes and `limit` to 1–100. + +Returns a plain memory array ordered by MatrixOne full-text score and then +`memory_id`; each result exposes the score as `retrieval_score`. + ### Correct by ID: `PUT /v1/memories/{id}/correct` ```json