Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions memoria/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions memoria/crates/memoria-api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,10 @@ pub fn build_router(state: AppState) -> Router {
.route("/metrics", get(routes::metrics::prometheus_metrics))
// Memory reads
.route("/v1/memories", get(routes::memory::list_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))
Expand Down
68 changes: 68 additions & 0 deletions memoria/crates/memoria-api/src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

use memoria_core::{Memory, MemoryType, TrustTier};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::str::FromStr;

// ── Memory ────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -143,6 +144,73 @@ impl SearchRequest {
}
}

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<String, serde_json::Value>,
pub subject_id: Option<String>,
pub memory_types: Option<Vec<String>>,
pub session_id: Option<String>,
pub trust_tier: Option<String>,
pub branch: Option<String>,
#[serde(default = "default_fulltext_search_limit")]
pub limit: i64,
}

impl FulltextSearchRequest {
pub fn fulltext_options(&self) -> Result<memoria_service::FulltextSearchOptions, String> {
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_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<Option<String>, String> {
normalized_filter("branch", self.branch.as_deref())
}
}

fn normalized_filter(name: &str, value: Option<&str>) -> Result<Option<String>, String> {
value
.map(|value| {
let value = value.trim();
if value.is_empty() {
Err(format!("{name} must not be empty when provided"))
} else {
Ok(value.to_string())
}
})
.transpose()
}

fn deserialize_explain<'de, D: serde::Deserializer<'de>>(d: D) -> Result<String, D::Error> {
use serde::Deserialize;
#[derive(Deserialize)]
Expand Down
24 changes: 24 additions & 0 deletions memoria/crates/memoria-api/src/routes/memory.rs
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,30 @@ pub async fn list_memories(
Ok(Json(ListResponse { items, next_cursor }))
}

pub async fn fulltext_search_memories(
State(state): State<AppState>,
auth: AuthUser,
Json(req): Json<FulltextSearchRequest>,
) -> ApiResult<Vec<MemoryResponse>> {
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<AppState>,
auth: AuthUser,
Expand Down
217 changes: 217 additions & 0 deletions memoria/crates/memoria-api/tests/api_e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,223 @@ async fn test_api_extra_metadata_round_trip_and_dedup() {

// ── 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": "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_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;
Expand Down
5 changes: 3 additions & 2 deletions memoria/crates/memoria-service/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,9 @@ pub use scoring::{
DefaultScoringPlugin, FeedbackTotals, ScoringPlugin, ScoringStore, TuningResult,
};
pub use service::{
CandidateScore, ExplainLevel, InMemoryFlusher, ListActiveOptions, MemoryService, PurgeResult,
RetrievalExplain, RetrieveOptions, SessionScope, ENTITY_EXTRACTION_DROPS,
CandidateScore, ExplainLevel, FulltextSearchOptions, InMemoryFlusher, ListActiveOptions,
MemoryService, PurgeResult, RetrievalExplain, RetrieveOptions, SessionScope,
ENTITY_EXTRACTION_DROPS,
};
pub use stats_reporter::StatsReporter;

Expand Down
Loading
Loading