use do_memory_core::memory::{PatternSearchResult, SearchConfig};
use do_memory_core::{SelfLearningMemory, TaskContext};
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchPatternsInput {
pub query: String,
pub domain: String,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default = "default_limit")]
pub limit: usize,
#[serde(default = "default_min_relevance")]
pub min_relevance: f32,
#[serde(default)]
pub filter_by_domain: bool,
}
fn default_limit() -> usize {
5
}
fn default_min_relevance() -> f32 {
0.3
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AttributionEnvelope {
pub session_id: uuid::Uuid,
pub episode_id: uuid::Uuid,
pub receipt: do_memory_core::PersistenceReceipt,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchPatternsOutput {
pub results: Vec<PatternResult>,
pub total_searched: usize,
pub query: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub attribution: Option<AttributionEnvelope>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PatternResult {
pub id: String,
pub pattern_type: String,
pub description: String,
pub relevance_score: f32,
pub score_breakdown: ScoreBreakdownResult,
pub success_rate: f32,
pub domain: Option<String>,
pub times_applied: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScoreBreakdownResult {
pub semantic_similarity: f32,
pub context_match: f32,
pub effectiveness: f32,
pub recency: f32,
pub success_rate: f32,
}
pub async fn execute(
memory: &SelfLearningMemory,
mut input: SearchPatternsInput,
) -> anyhow::Result<Value> {
crate::constants::truncate_safe(&mut input.query, crate::constants::MAX_TASK_DESCRIPTION_LEN);
crate::constants::truncate_safe(&mut input.domain, crate::constants::MAX_METRICS_TYPE_LEN);
input
.tags
.truncate(crate::constants::MAX_TAGS_PER_OPERATION);
input.limit = input.limit.clamp(1, crate::constants::MAX_SEARCH_LIMIT);
input.min_relevance = input.min_relevance.clamp(0.0, 1.0);
let context = TaskContext {
domain: input.domain.clone(),
language: None,
framework: None,
complexity: do_memory_core::ComplexityLevel::Moderate,
tags: input.tags.clone(),
};
let config = SearchConfig {
min_relevance: input.min_relevance,
filter_by_domain: input.filter_by_domain,
..SearchConfig::default()
};
let results = memory
.search_patterns_with_config(&input.query, context, config, input.limit)
.await?;
let pattern_results: Vec<PatternResult> = results
.iter()
.map(pattern_search_result_to_pattern_result)
.collect();
let output = SearchPatternsOutput {
results: pattern_results,
total_searched: results.len(),
query: input.query.clone(),
attribution: None,
};
Ok(serde_json::to_value(output)?)
}
fn pattern_search_result_to_pattern_result(result: &PatternSearchResult) -> PatternResult {
use do_memory_core::Pattern;
let pattern = &result.pattern;
let (id, pattern_type, description, domain) = match pattern {
Pattern::ToolSequence {
id, tools, context, ..
} => (
id.to_string(),
"tool_sequence".to_string(),
format!("Tool sequence: {}", tools.join(" → ")),
Some(context.domain.clone()),
),
Pattern::DecisionPoint {
id,
condition,
action,
context,
..
} => (
id.to_string(),
"decision_point".to_string(),
format!("Decision: {} → {}", condition, action),
Some(context.domain.clone()),
),
Pattern::ErrorRecovery {
id,
error_type,
recovery_steps,
context,
..
} => (
id.to_string(),
"error_recovery".to_string(),
format!(
"Error recovery for {}: {}",
error_type,
recovery_steps.join(", ")
),
Some(context.domain.clone()),
),
Pattern::ContextPattern {
id,
recommended_approach,
..
} => (
id.to_string(),
"context_pattern".to_string(),
format!("Context pattern: {}", recommended_approach),
None,
),
};
let effectiveness = pattern.effectiveness();
PatternResult {
id,
pattern_type,
description,
relevance_score: result.relevance_score,
score_breakdown: ScoreBreakdownResult {
semantic_similarity: result.score_breakdown.semantic_similarity,
context_match: result.score_breakdown.context_match,
effectiveness: result.score_breakdown.effectiveness,
recency: result.score_breakdown.recency,
success_rate: result.score_breakdown.success_rate,
},
success_rate: pattern.success_rate(),
domain,
times_applied: effectiveness.times_applied,
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecommendPatternsInput {
pub task_description: String,
pub domain: String,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default = "default_recommendation_limit")]
pub limit: usize,
#[serde(default)]
pub episode_id: Option<uuid::Uuid>,
}
fn default_recommendation_limit() -> usize {
3
}
pub async fn execute_recommend(
memory: &SelfLearningMemory,
mut input: RecommendPatternsInput,
) -> anyhow::Result<Value> {
crate::constants::truncate_safe(
&mut input.task_description,
crate::constants::MAX_TASK_DESCRIPTION_LEN,
);
crate::constants::truncate_safe(&mut input.domain, crate::constants::MAX_METRICS_TYPE_LEN);
input
.tags
.truncate(crate::constants::MAX_TAGS_PER_OPERATION);
input.limit = input.limit.clamp(1, crate::constants::MAX_RECOMMEND_LIMIT);
let context = TaskContext {
domain: input.domain.clone(),
language: None,
framework: None,
complexity: do_memory_core::ComplexityLevel::Moderate,
tags: input.tags.clone(),
};
let (results, attribution) = if let Some(episode_id) = input.episode_id {
let attr_res = memory
.recommend_patterns_attributed(
episode_id,
&input.task_description,
context,
input.limit,
)
.await?;
let envelope = AttributionEnvelope {
session_id: attr_res.session.session_id,
episode_id,
receipt: attr_res.receipt,
};
(attr_res.recommendations, Some(envelope))
} else {
let res = memory
.recommend_patterns_for_task(&input.task_description, context, input.limit)
.await?;
(res, None)
};
let pattern_results: Vec<PatternResult> = results
.iter()
.map(pattern_search_result_to_pattern_result)
.collect();
let output = SearchPatternsOutput {
results: pattern_results,
total_searched: results.len(),
query: input.task_description.clone(),
attribution,
};
Ok(serde_json::to_value(output)?)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_search_patterns_input_defaults() {
let input = SearchPatternsInput {
query: "test".to_string(),
domain: "test".to_string(),
tags: vec![],
limit: default_limit(),
min_relevance: default_min_relevance(),
filter_by_domain: false,
};
assert_eq!(input.limit, 5);
assert_eq!(input.min_relevance, 0.3);
}
#[test]
fn test_recommend_patterns_input_defaults() {
let input = RecommendPatternsInput {
task_description: "test".to_string(),
domain: "test".to_string(),
tags: vec![],
limit: default_recommendation_limit(),
episode_id: None,
};
assert_eq!(input.limit, 3);
assert!(input.episode_id.is_none());
}
#[tokio::test]
async fn test_execute_truncates_input() {
let memory = SelfLearningMemory::new();
let input = SearchPatternsInput {
query: "a".repeat(crate::constants::MAX_TASK_DESCRIPTION_LEN + 1),
domain: "b".repeat(crate::constants::MAX_METRICS_TYPE_LEN + 1),
tags: vec!["t".to_string(); crate::constants::MAX_TAGS_PER_OPERATION + 1],
limit: 9999,
min_relevance: 2.0,
filter_by_domain: false,
};
let _ = execute(&memory, input).await;
}
#[tokio::test]
async fn test_execute_recommend_truncates_and_clamps_input() {
let memory = SelfLearningMemory::new();
let input = RecommendPatternsInput {
task_description: "a".repeat(crate::constants::MAX_TASK_DESCRIPTION_LEN + 10),
domain: "b".repeat(crate::constants::MAX_METRICS_TYPE_LEN + 10),
tags: vec!["t".to_string(); crate::constants::MAX_TAGS_PER_OPERATION + 10],
limit: 9999,
episode_id: None,
};
let _ = execute_recommend(&memory, input).await.unwrap();
}
#[tokio::test]
async fn test_execute_recommend_with_episode_id_attaches_envelope() {
let memory = SelfLearningMemory::new();
let episode_id = memory
.start_episode(
"retry with backoff".to_string(),
do_memory_core::TaskContext::default(),
do_memory_core::TaskType::CodeGeneration,
)
.await;
let input = RecommendPatternsInput {
task_description: "retry with backoff".to_string(),
domain: "error-handling".to_string(),
tags: vec![],
limit: default_recommendation_limit(),
episode_id: Some(episode_id),
};
let value = execute_recommend(&memory, input).await.unwrap();
let output: SearchPatternsOutput = serde_json::from_value(value).unwrap();
let envelope = output
.attribution
.expect("episode_id must produce an attribution envelope");
assert_eq!(envelope.episode_id, episode_id);
assert!(
matches!(
envelope.receipt,
do_memory_core::PersistenceReceipt::MemoryOnly { .. }
),
"no storage backends configured must yield MemoryOnly"
);
let session = memory
.get_recommendation_session_for_episode(episode_id)
.await
.expect("attributed recommendation must record a session");
assert_eq!(session.session_id, envelope.session_id);
}
#[tokio::test]
async fn test_execute_recommend_without_episode_id_has_no_envelope() {
let memory = SelfLearningMemory::new();
let input = RecommendPatternsInput {
task_description: "test task".to_string(),
domain: "test".to_string(),
tags: vec![],
limit: default_recommendation_limit(),
episode_id: None,
};
let value = execute_recommend(&memory, input).await.unwrap();
let output: SearchPatternsOutput = serde_json::from_value(value).unwrap();
assert!(
output.attribution.is_none(),
"no episode_id must produce no attribution envelope"
);
}
}