//! M3.7.4 — `/memory/context` endpoint //! //! Three-tier context lookup for failure diagnosis: //! 1. Exact signature match (failure_signature table) //! 2. Vector search on symptoms + text //! 3. Reference corpus fallback //! //! Returns: {"tier": 1|2|3, "lessons": [...], "skills": [...], "budget": {...}} use anyhow::Result; use serde::{Deserialize, Serialize}; use std::sync::Arc; /// Request to the context endpoint #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ContextRequest { /// Tool name (e.g., "github-actions", "docker", "kubectl") pub tool: Option, /// Task or operation name pub task: Option, /// Raw error/log output for signature extraction pub signature_source: Option, /// Project ID (defaults to "all" for federation) pub project: Option, /// Scope: "project" or "all-projects" pub scope: Option, /// Token budget for response (default: 6000) pub budget: Option, } /// A retrieved lesson with tier information #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TieredLesson { pub tier: u8, // 1, 2, or 3 pub level: String, // L0, L1, L2, R pub score: Option, // Similarity score (tier 2+) pub seen_count: Option, // How many times we've seen this (tier 1) pub last_seen: Option, // When we last saw this (tier 1) pub matched_kind: Option, // "symptom" or "text" for tier 2 pub text: String, // Content pub parents: Option>, // Provenance chain } /// A skill recommendation #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SkillRecommendation { pub name: String, pub score: f32, pub description: Option, } /// Budget tracking #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BudgetInfo { pub limit: usize, pub used: usize, pub dropped: Vec, // What was dropped to stay in budget } /// Response from the context endpoint #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ContextResponse { pub tier: u8, // Highest tier that has results (1, 2, or 3) pub lessons: Vec, pub skills: Vec, pub budget: BudgetInfo, pub degraded: Option, // If some leg failed (skills timeout, etc.) } impl Default for ContextResponse { fn default() -> Self { Self { tier: 0, lessons: vec![], skills: vec![], budget: BudgetInfo { limit: 6000, used: 0, dropped: vec![], }, degraded: None, } } } /// Context lookup orchestrator pub struct ContextLookup { pub budget_limit: usize, pub project: String, pub scope: String, } impl ContextLookup { pub fn new(budget_limit: usize, project: String, scope: String) -> Self { Self { budget_limit, project, scope, } } /// Execute three-tier context lookup pub async fn lookup(&self, req: ContextRequest) -> Result { let mut response = ContextResponse { budget: BudgetInfo { limit: req.budget.unwrap_or(6000), used: 0, dropped: vec![], }, ..Default::default() }; // Validate that at least one input is provided if req.tool.is_none() && req.task.is_none() && req.signature_source.is_none() { anyhow::bail!("At least one of tool, task, or signature_source is required"); } // Tier 1: Exact signature match if let Some(sig_source) = &req.signature_source { // Extract signature from raw log (M3.7.7) // TODO: Call signature extractor tracing::debug!("Tier 1: Looking up signature"); } // Tier 2: Vector search (concurrent) if response.lessons.is_empty() { tracing::debug!("Tier 2: Vector search on symptoms"); // TODO: Search pgvector for similar symptoms // TODO: Search for related text // TODO: Merge and rerank } // Tier 3: Reference corpus fallback if response.budget.used < response.budget.limit { tracing::debug!("Tier 3: Fallback to reference corpus"); // TODO: Query Obsidian reference docs } // Concurrent: Skills recommendations // TODO: Call skills endpoint with timeout response.skills = vec![]; // Set response tier (highest tier with results) response.tier = if !response.lessons.is_empty() { response .lessons .iter() .map(|l| l.tier) .max() .unwrap_or(0) } else { 0 }; tracing::info!( tier = response.tier, lesson_count = response.lessons.len(), skill_count = response.skills.len(), budget_used = response.budget.used, "context lookup complete" ); Ok(response) } } #[cfg(test)] mod tests { use super::*; #[test] fn test_context_response_default() { let resp = ContextResponse::default(); assert_eq!(resp.tier, 0); assert_eq!(resp.lessons.len(), 0); assert_eq!(resp.budget.limit, 6000); } #[test] fn test_context_request_validation() { let req = ContextRequest { tool: None, task: None, signature_source: None, project: None, scope: None, budget: None, }; // Should require at least one input assert!(req.tool.is_none()); } #[test] fn test_tiered_lesson_creation() { let lesson = TieredLesson { tier: 1, level: "L1".to_string(), score: None, seen_count: Some(3), last_seen: Some("2024-01-15".to_string()), matched_kind: None, text: "npm ci --legacy-peer-deps".to_string(), parents: None, }; assert_eq!(lesson.tier, 1); assert_eq!(lesson.seen_count, Some(3)); } #[test] fn test_budget_info_default() { let budget = BudgetInfo { limit: 6000, used: 2140, dropped: vec!["reference".to_string()], }; assert_eq!(budget.limit - budget.used, 3860); } #[tokio::test] async fn test_context_lookup_empty_request() { let lookup = ContextLookup::new(6000, "test".to_string(), "project".to_string()); let req = ContextRequest { tool: None, task: None, signature_source: None, project: None, scope: None, budget: None, }; let result = lookup.lookup(req).await; assert!(result.is_err()); } #[tokio::test] async fn test_context_lookup_with_tool() { let lookup = ContextLookup::new(6000, "test".to_string(), "project".to_string()); let req = ContextRequest { tool: Some("github-actions".to_string()), task: None, signature_source: None, project: Some("test".to_string()), scope: None, budget: Some(6000), }; let result = lookup.lookup(req).await; assert!(result.is_ok()); let resp = result.unwrap(); assert_eq!(resp.budget.limit, 6000); } #[test] fn test_skill_recommendation() { let skill = SkillRecommendation { name: "ci-triage".to_string(), score: 0.77, description: Some("CI troubleshooting".to_string()), }; assert_eq!(skill.name, "ci-triage"); assert!(skill.score > 0.7); } }