Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions memoria/crates/memoria-api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,7 @@ 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/query", post(routes::memory::query_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
130 changes: 124 additions & 6 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 All @@ -20,7 +21,7 @@ pub struct StoreRequest {
pub source: Option<String>,
pub branch: Option<String>,
/// 任意业务元数据(如 scene/agent)。透传落库到 memories.extra_metadata,并在读取时原样
/// 返回给调用方;Memoria 本身不对其做检索/打分逻辑(下游消费者如 matrixflow 的 decay 可自行使用)
/// 返回给调用方;结构化 query 可做精确过滤,但不参与相关性检索或打分
#[serde(default)]
pub extra_metadata: Option<std::collections::HashMap<String, serde_json::Value>>,
}
Expand Down Expand Up @@ -67,7 +68,7 @@ fn parse_session_scope(
fn parse_memory_types_opt(
types: Option<&Vec<String>>,
) -> Result<Option<Vec<MemoryType>>, String> {
types
let mut parsed = types
.map(|ts| {
ts.iter()
.map(|s| s.trim())
Expand All @@ -76,7 +77,12 @@ fn parse_memory_types_opt(
.collect::<Result<Vec<_>, _>>()
})
.transpose()
.map(|v| v.filter(|t| !t.is_empty()))
.map(|v| v.filter(|t| !t.is_empty()))?;
if let Some(types) = parsed.as_mut() {
let mut seen = std::collections::HashSet::new();
types.retain(|memory_type| seen.insert(memory_type.clone()));
}
Ok(parsed)
}

impl RetrieveRequest {
Expand Down Expand Up @@ -143,6 +149,102 @@ impl SearchRequest {
}
}

fn default_structured_query_limit() -> i64 {
100
}

/// A pure structured query. All supplied selectors are combined with AND.
/// Metadata equality preserves JSON type families (for example, string `"2"`
/// does not equal number `2`); JSON numbers `2` and `2.0` may compare equal.
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub struct StructuredQueryRequest {
#[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_structured_query_limit")]
pub limit: i64,
pub cursor: Option<String>,
}

impl StructuredQueryRequest {
pub fn structured_options(&self) -> Result<memoria_service::StructuredQueryOptions, String> {
if !(1..=500).contains(&self.limit) {
return Err("limit must be between 1 and 500".to_string());
}
memoria_storage::validate_extra_metadata_filter(&self.extra_metadata_filter)
.map_err(|err| err.to_string())?;

let subject_id = normalized_filter("subject_id", self.subject_id.as_deref())?;
let session_id = normalized_filter("session_id", self.session_id.as_deref())?;
let normalized_trust_tier =
normalized_filter("trust_tier", self.trust_tier.as_deref())?;
let branch = normalized_filter("branch", self.branch.as_deref())?;
let trust_tier = normalized_trust_tier
.as_deref()
.map(parse_trust_tier)
.transpose()?;
if let Some(memory_types) = self.memory_types.as_ref() {
if memory_types.is_empty() || memory_types.iter().any(|value| value.trim().is_empty()) {
return Err(
"memory_types must contain only non-empty values when provided".to_string(),
);
}
}
let memory_types = parse_memory_types_opt(self.memory_types.as_ref())?;
if self.extra_metadata_filter.is_empty()
&& subject_id.is_none()
&& session_id.is_none()
&& trust_tier.is_none()
&& memory_types.is_none()
&& branch.is_none()
{
return Err("structured query requires at least one filter selector".to_string());
}

let cursor = normalized(self.cursor.as_deref());
if let Some(cursor) = cursor.as_deref() {
if cursor.len() != 32 || !cursor.chars().all(|ch| ch.is_ascii_hexdigit()) {
return Err("cursor must be a 32-character hexadecimal memory_id".to_string());
}
}

Ok(memoria_service::StructuredQueryOptions {
limit: self.limit,
memory_types,
session_id,
trust_tier,
cursor,
subject_id,
extra_metadata_filter: self.extra_metadata_filter.clone(),
})
}
}

fn normalized(value: Option<&str>) -> Option<String> {
value
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
}

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 Expand Up @@ -285,8 +387,8 @@ pub struct MemoryResponse {
pub observed_at: Option<String>,
pub created_at: Option<String>,
pub retrieval_score: Option<f64>,
/// 业务元数据(如 scene/agent)从 memories.extra_metadata 原样透传回给调用方;Memoria 本身
/// 不对其做检索/打分逻辑(下游消费者如 matrixflow 的 decay 可自行使用)
/// 业务元数据(如 scene/agent)从 memories.extra_metadata 原样透传回给调用方;结构化 query
/// 可做精确过滤,但不参与相关性检索或打分
#[serde(skip_serializing_if = "Option::is_none")]
pub extra_metadata: Option<std::collections::HashMap<String, serde_json::Value>>,
}
Expand Down Expand Up @@ -498,7 +600,23 @@ pub fn parse_trust_tier(s: &str) -> Result<TrustTier, String> {

#[cfg(test)]
mod tests {
use super::{PurgeRequest, PurgeSelector};
use super::{parse_memory_types_opt, PurgeRequest, PurgeSelector};
use memoria_core::MemoryType;

#[test]
fn memory_type_parser_deduplicates_before_sql_option_construction() {
let raw = vec![
"semantic".to_string(),
" semantic ".to_string(),
"profile".to_string(),
"semantic".to_string(),
];

assert_eq!(
parse_memory_types_opt(Some(&raw)).unwrap(),
Some(vec![MemoryType::Semantic, MemoryType::Profile])
);
}

#[test]
fn purge_selector_ignores_empty_arrays() {
Expand Down
26 changes: 26 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,32 @@ pub async fn list_memories(
Ok(Json(ListResponse { items, next_cursor }))
}

pub async fn query_memories(
State(state): State<AppState>,
auth: AuthUser,
Json(req): Json<StructuredQueryRequest>,
) -> ApiResult<ListResponse> {
let branch = normalize_branch(req.branch.clone());
let limit = req.limit;
let mut options = req
.structured_options()
.map_err(|err| (StatusCode::UNPROCESSABLE_ENTITY, err))?;
options.limit = limit + 1;

let mut memories = state
.service
.query_active_structured_on_branch(auth.scope_id(), branch.as_deref(), &options)
.await
.map_err(api_err_typed)?;
let has_more = memories.len() > limit as usize;
memories.truncate(limit as usize);
let next_cursor = has_more
.then(|| memories.last().map(|memory| memory.memory_id.clone()))
.flatten();
let items = memories.into_iter().map(Into::into).collect();
Ok(Json(ListResponse { items, next_cursor }))
}

pub async fn store_memory(
State(state): State<AppState>,
auth: AuthUser,
Expand Down
Loading
Loading