Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
82 changes: 79 additions & 3 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 @@ -143,6 +144,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 +361,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
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
232 changes: 232 additions & 0 deletions memoria/crates/memoria-api/tests/api_e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,238 @@ async fn test_api_extra_metadata_round_trip_and_dedup() {
assert_eq!(response.status(), 422);
}

#[tokio::test]
async fn test_api_structured_query_by_extra_metadata() {
let (base, client, _server) = spawn_server().await;
let user_id = uid();

for (content, metadata) in [
(
"structured incident",
json!({"scene": "incident", "rank": 2, "urgent": true}),
),
(
"structured review",
json!({"scene": "review", "rank": 2, "urgent": true}),
),
(
"structured string rank",
json!({"scene": "incident", "rank": "2", "urgent": true}),
),
] {
let response = client
.post(format!("{base}/v1/memories"))
.header("X-User-Id", &user_id)
.json(&json!({"content": content, "extra_metadata": metadata}))
.send()
.await
.unwrap();
assert_eq!(response.status(), 201);
}

let response = client
.post(format!("{base}/v1/memories/query"))
.header("X-User-Id", &user_id)
.json(&json!({
"extra_metadata_filter": {"scene": "incident", "rank": 2, "urgent": true},
"memory_types": ["semantic"],
"limit": 10
}))
.send()
.await
.unwrap();
assert_eq!(response.status(), 200);
let body: Value = response.json().await.unwrap();
let items = body["items"].as_array().unwrap();
assert_eq!(items.len(), 1);
assert_eq!(items[0]["content"], "structured incident");
assert_eq!(items[0]["retrieval_score"], Value::Null);

let response = client
.post(format!("{base}/v1/memories/query"))
.header("X-User-Id", &user_id)
.json(&json!({}))
.send()
.await
.unwrap();
assert_eq!(response.status(), 422);

let response = client
.post(format!("{base}/v1/memories/query"))
.header("X-User-Id", &user_id)
.json(&json!({"extra_metadata_filter": {"nested": {"value": 1}}}))
.send()
.await
.unwrap();
assert_eq!(response.status(), 422);

for invalid_request in [
json!({"subject_id": "subject", "limit": 0}),
json!({"subject_id": "subject", "limit": 501}),
json!({"subject_id": "subject", "extra_metadata_filters": {"scene": "incident"}}),
json!({"extra_metadata_filter": {"1scene": "incident"}}),
json!({"extra_metadata_filter": {"scene": "x".repeat(1025)}}),
] {
let response = client
.post(format!("{base}/v1/memories/query"))
.header("X-User-Id", &user_id)
.json(&invalid_request)
.send()
.await
.unwrap();
assert_eq!(response.status(), 422, "request: {invalid_request}");
}
}

#[tokio::test]
async fn test_api_structured_query_cursor_and_subject_isolation() {
let (base, client, _server) = spawn_server().await;
let user_id = uid();
let matching_subject = format!("subject_{}", uuid::Uuid::new_v4().simple());
let other_subject = format!("subject_{}", uuid::Uuid::new_v4().simple());
let marker = format!("marker_{}", uuid::Uuid::new_v4().simple());
let mut expected_ids = std::collections::HashSet::new();

for index in 0..3 {
let response = client
.post(format!("{base}/v1/memories"))
.header("X-User-Id", &user_id)
.json(&json!({
"content": format!("structured page {index}"),
"subject_id": matching_subject,
"extra_metadata": {"marker": marker}
}))
.send()
.await
.unwrap();
assert_eq!(response.status(), 201);
expected_ids.insert(
response.json::<Value>().await.unwrap()["memory_id"]
.as_str()
.unwrap()
.to_string(),
);
}

let response = client
.post(format!("{base}/v1/memories"))
.header("X-User-Id", &user_id)
.json(&json!({
"content": "same marker, other subject",
"subject_id": other_subject,
"extra_metadata": {"marker": marker}
}))
.send()
.await
.unwrap();
assert_eq!(response.status(), 201);

let first = client
.post(format!("{base}/v1/memories/query"))
.header("X-User-Id", &user_id)
.json(&json!({
"extra_metadata_filter": {"marker": marker},
"subject_id": matching_subject,
"limit": 2
}))
.send()
.await
.unwrap();
assert_eq!(first.status(), 200);
let first: Value = first.json().await.unwrap();
let cursor = first["next_cursor"].as_str().expect("first page cursor");
let first_ids: std::collections::HashSet<String> = first["items"]
.as_array()
.unwrap()
.iter()
.map(|item| item["memory_id"].as_str().unwrap().to_string())
.collect();
assert_eq!(first_ids.len(), 2);

let second = client
.post(format!("{base}/v1/memories/query"))
.header("X-User-Id", &user_id)
.json(&json!({
"extra_metadata_filter": {"marker": marker},
"subject_id": matching_subject,
"limit": 2,
"cursor": cursor
}))
.send()
.await
.unwrap();
assert_eq!(second.status(), 200);
let second: Value = second.json().await.unwrap();
assert!(second["next_cursor"].is_null());
let second_ids: std::collections::HashSet<String> = second["items"]
.as_array()
.unwrap()
.iter()
.map(|item| item["memory_id"].as_str().unwrap().to_string())
.collect();
assert_eq!(second_ids.len(), 1);
assert!(first_ids.is_disjoint(&second_ids));
assert_eq!(
first_ids
.union(&second_ids)
.cloned()
.collect::<std::collections::HashSet<_>>(),
expected_ids
);
}

#[tokio::test]
async fn test_api_structured_query_on_branch() {
let (base, client, _server) = spawn_server().await;
let user_id = uid();
let branch = format!("query_{}", &uuid::Uuid::new_v4().simple().to_string()[..8]);
let marker = format!("branch_{}", 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": "structured branch only",
"branch": branch,
"extra_metadata": {"marker": marker}
}))
.send()
.await
.unwrap();
assert_eq!(response.status(), 201);

let branch_response = client
.post(format!("{base}/v1/memories/query"))
.header("X-User-Id", &user_id)
.json(&json!({"branch": branch}))
.send()
.await
.unwrap();
assert_eq!(branch_response.status(), 200);
let branch_body: Value = branch_response.json().await.unwrap();
assert_eq!(branch_body["items"].as_array().unwrap().len(), 1);

let main_response = client
.post(format!("{base}/v1/memories/query"))
.header("X-User-Id", &user_id)
.json(&json!({"extra_metadata_filter": {"marker": marker}}))
.send()
.await
.unwrap();
assert_eq!(main_response.status(), 200);
let main_body: Value = main_response.json().await.unwrap();
assert!(main_body["items"].as_array().unwrap().is_empty());
}

// ── 2b. list response is lightweight (no embedding) and respects limit ────────

#[tokio::test]
Expand Down
3 changes: 2 additions & 1 deletion memoria/crates/memoria-service/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ pub use scoring::{
};
pub use service::{
CandidateScore, ExplainLevel, InMemoryFlusher, ListActiveOptions, MemoryService, PurgeResult,
RetrievalExplain, RetrieveOptions, SessionScope, ENTITY_EXTRACTION_DROPS,
RetrievalExplain, RetrieveOptions, SessionScope, StructuredQueryOptions,
ENTITY_EXTRACTION_DROPS,
};
pub use stats_reporter::StatsReporter;

Expand Down
Loading
Loading