273 lines
7.7 KiB
Rust
273 lines
7.7 KiB
Rust
//! 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);
|
||
|
|
}
|
||
|
|
}
|