Files
poimen-memory/crates/mem-cli/src/context_endpoint.rs
T

273 lines
7.7 KiB
Rust
Raw Normal View History

//! 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<String>,
/// Task or operation name
pub task: Option<String>,
/// Raw error/log output for signature extraction
pub signature_source: Option<String>,
/// Project ID (defaults to "all" for federation)
pub project: Option<String>,
/// Scope: "project" or "all-projects"
pub scope: Option<String>,
/// Token budget for response (default: 6000)
pub budget: Option<usize>,
}
/// 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<f32>, // Similarity score (tier 2+)
pub seen_count: Option<i32>, // How many times we've seen this (tier 1)
pub last_seen: Option<String>, // When we last saw this (tier 1)
pub matched_kind: Option<String>, // "symptom" or "text" for tier 2
pub text: String, // Content
pub parents: Option<Vec<serde_json::Value>>, // Provenance chain
}
/// A skill recommendation
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkillRecommendation {
pub name: String,
pub score: f32,
pub description: Option<String>,
}
/// Budget tracking
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BudgetInfo {
pub limit: usize,
pub used: usize,
pub dropped: Vec<String>, // 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<TieredLesson>,
pub skills: Vec<SkillRecommendation>,
pub budget: BudgetInfo,
pub degraded: Option<bool>, // 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<ContextResponse> {
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);
}
}