Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
109 changes: 103 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,81 @@ 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(self.subject_id.as_deref());
Comment thread
loveRhythm1990 marked this conversation as resolved.
Outdated
Comment thread
loveRhythm1990 marked this conversation as resolved.
Outdated
let session_id = normalized(self.session_id.as_deref());
let normalized_trust_tier = normalized(self.trust_tier.as_deref());
let branch = normalized(self.branch.as_deref());
let trust_tier = normalized_trust_tier
.as_deref()
.map(parse_trust_tier)
.transpose()?;
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()
{
Comment thread
loveRhythm1990 marked this conversation as resolved.
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 deserialize_explain<'de, D: serde::Deserializer<'de>>(d: D) -> Result<String, D::Error> {
use serde::Deserialize;
#[derive(Deserialize)]
Expand Down Expand Up @@ -285,8 +366,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 +579,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