//! Synthesis Handler (Phase 5) //! //! HTTP endpoints for knowledge synthesis features: //! - Entity linking //! - Inference //! - Reasoning //! - Summarization use actix_web::{web, HttpRequest, HttpResponse}; use serde::{Deserialize, Serialize}; use tracing::{debug, error, info}; use crate::http_server::AppState; use crate::query::{ EntityLinker, MentionLink, AliasSuggestion, MergeSuggestion, CoreferenceCluster, InferenceEngine, InferenceRule, InferredFact, ReasoningPath, TransitiveClosure, QueryReasoner, SubQuery, Constraint, QuestionType, ReasonedAnswer, Summarizer, SummarizationStrategy, Summary, KeyFact, }; /// Request to link entities #[derive(Debug, Deserialize)] pub struct LinkEntitiesRequest { /// Project ID pub project: String, /// Text to link entities in pub text: String, } /// Response from entity linking #[derive(Debug, Serialize)] pub struct LinkEntitiesResponse { /// Linked mentions pub links: Vec, /// Unlinked mention texts pub unlinked: Vec, /// Total mentions found pub total_mentions: usize, /// Link success rate pub link_rate: f32, /// Processing time in ms pub process_time_ms: u128, } /// Request to detect aliases #[derive(Debug, Deserialize)] pub struct DetectAliasesRequest { /// Project ID pub project: String, /// Entity ID pub entity_id: String, /// Entity name (canonical) pub entity_name: String, /// Text samples to analyze pub text_samples: Vec, } /// Response from alias detection #[derive(Debug, Serialize)] pub struct DetectAliasesResponse { pub entity_id: String, pub entity_name: String, pub aliases: Vec, pub alias_count: usize, pub process_time_ms: u128, } /// Request to suggest merges #[derive(Debug, Deserialize)] pub struct SuggestMergesRequest { /// Project ID pub project: String, /// Minimum similarity threshold (0.0-1.0, default 0.8) #[serde(default = "default_merge_threshold")] pub similarity_threshold: f32, } /// Response from merge suggestion #[derive(Debug, Serialize)] pub struct SuggestMergesResponse { pub project: String, pub suggestions: Vec, pub suggestion_count: usize, pub process_time_ms: u128, } /// Request to detect coreferences #[derive(Debug, Deserialize)] pub struct DetectCoreferencesRequest { /// Project ID pub project: String, /// Text samples pub texts: Vec, } /// Response from coreference detection #[derive(Debug, Serialize)] pub struct DetectCoreferencesResponse { pub project: String, pub clusters: Vec, pub cluster_count: usize, pub total_mentions: usize, pub process_time_ms: u128, } fn default_merge_threshold() -> f32 { 0.8 } /// POST /memory/synthesis/link-entities - Link mentions to entities pub async fn link_entities_handler( req: HttpRequest, body: web::Json, state: web::Data, ) -> HttpResponse { let start_time = std::time::Instant::now(); // Validate JWT + rate limit if let Err(response) = crate::handlers::middleware::validate_and_rate_limit( &req, &state, "synthesis", 100 ) { return response; } // Validate input if body.text.is_empty() || body.text.len() > 10000 { return crate::handlers::response_builder::bad_request( "Text must be 1-10000 characters" ); } debug!("Entity linking: project='{}', text_len={}", body.project, body.text.len()); // Create entity linker let linker = EntityLinker::new(state.pool.clone()); // Link entities let (links, unlinked) = match linker.link_mentions(&body.text, &body.project).await { Ok((links, unlinked)) => (links, unlinked), Err(e) => { error!("Entity linking failed: {}", e); return crate::handlers::response_builder::internal_error( &format!("Linking failed: {}", e) ); } }; let total = links.len() + unlinked.len(); let link_rate = if total > 0 { (links.len() as f32 / total as f32) } else { 0.0 }; let elapsed = start_time.elapsed().as_millis(); info!("Entity linking completed: {}/{} linked in {}ms", links.len(), total, elapsed); let response = LinkEntitiesResponse { links, unlinked, total_mentions: total, link_rate, process_time_ms: elapsed, }; crate::handlers::response_builder::success_response(response) } /// POST /memory/synthesis/detect-aliases - Detect aliases for entity pub async fn detect_aliases_handler( req: HttpRequest, body: web::Json, state: web::Data, ) -> HttpResponse { let start_time = std::time::Instant::now(); // Validate JWT + rate limit if let Err(response) = crate::handlers::middleware::validate_and_rate_limit( &req, &state, "synthesis", 100 ) { return response; } // Validate input if body.entity_id.is_empty() || body.entity_name.is_empty() { return crate::handlers::response_builder::bad_request( "entity_id and entity_name required" ); } if body.text_samples.is_empty() { return crate::handlers::response_builder::bad_request( "text_samples cannot be empty" ); } debug!("Alias detection: entity='{}', samples={}", body.entity_name, body.text_samples.len()); let linker = EntityLinker::new(state.pool.clone()); let aliases = match linker.detect_aliases( &body.entity_id, &body.entity_name, &body.text_samples, ).await { Ok(aliases) => aliases, Err(e) => { error!("Alias detection failed: {}", e); return crate::handlers::response_builder::internal_error( &format!("Detection failed: {}", e) ); } }; let elapsed = start_time.elapsed().as_millis(); let alias_count = aliases.len(); info!("Alias detection completed: {} aliases found in {}ms", alias_count, elapsed); let response = DetectAliasesResponse { entity_id: body.entity_id.clone(), entity_name: body.entity_name.clone(), aliases, alias_count, process_time_ms: elapsed, }; crate::handlers::response_builder::success_response(response) } /// POST /memory/synthesis/suggest-merges - Suggest entity merges pub async fn suggest_merges_handler( req: HttpRequest, body: web::Json, state: web::Data, ) -> HttpResponse { let start_time = std::time::Instant::now(); // Validate JWT + rate limit if let Err(response) = crate::handlers::middleware::validate_and_rate_limit( &req, &state, "synthesis", 50 ) { return response; } // Validate threshold if body.similarity_threshold < 0.0 || body.similarity_threshold > 1.0 { return crate::handlers::response_builder::bad_request( "similarity_threshold must be 0.0-1.0" ); } debug!("Merge suggestion: project='{}', threshold={}", body.project, body.similarity_threshold); let linker = EntityLinker::new(state.pool.clone()); let suggestions = match linker.suggest_merges(&body.project, body.similarity_threshold).await { Ok(suggestions) => suggestions, Err(e) => { error!("Merge suggestion failed: {}", e); return crate::handlers::response_builder::internal_error( &format!("Suggestion failed: {}", e) ); } }; let elapsed = start_time.elapsed().as_millis(); let suggestion_count = suggestions.len(); info!("Merge suggestion completed: {} suggestions in {}ms", suggestion_count, elapsed); let response = SuggestMergesResponse { project: body.project.clone(), suggestions, suggestion_count, process_time_ms: elapsed, }; crate::handlers::response_builder::success_response(response) } /// POST /memory/synthesis/detect-coreferences - Detect entity coreferences pub async fn detect_coreferences_handler( req: HttpRequest, body: web::Json, state: web::Data, ) -> HttpResponse { let start_time = std::time::Instant::now(); // Validate JWT + rate limit if let Err(response) = crate::handlers::middleware::validate_and_rate_limit( &req, &state, "synthesis", 100 ) { return response; } // Validate input if body.texts.is_empty() { return crate::handlers::response_builder::bad_request( "texts cannot be empty" ); } debug!("Coreference detection: project='{}', texts={}", body.project, body.texts.len()); let linker = EntityLinker::new(state.pool.clone()); let clusters = match linker.detect_coreferences(&body.texts, &body.project).await { Ok(clusters) => clusters, Err(e) => { error!("Coreference detection failed: {}", e); return crate::handlers::response_builder::internal_error( &format!("Detection failed: {}", e) ); } }; let total_mentions: usize = clusters.iter().map(|c| c.mention_count).sum(); let elapsed = start_time.elapsed().as_millis(); let cluster_count = clusters.len(); info!("Coreference detection completed: {} clusters ({} mentions) in {}ms", cluster_count, total_mentions, elapsed); let response = DetectCoreferencesResponse { project: body.project.clone(), clusters, cluster_count, total_mentions, process_time_ms: elapsed, }; crate::handlers::response_builder::success_response(response) } /// Request for inference #[derive(Debug, Deserialize)] pub struct InferenceRequest { pub project: String, pub entity_id: String, pub rules: Vec, #[serde(default = "default_max_hops")] pub max_hops: usize, } /// Response from inference #[derive(Debug, Serialize)] pub struct InferenceResponse { pub entity_id: String, pub inferred_facts: Vec, pub fact_count: usize, pub process_time_ms: u128, } /// Request for transitive closure #[derive(Debug, Deserialize)] pub struct TransitiveClosureRequest { pub project: String, pub entity_id: String, pub relation_type: Option, #[serde(default = "default_max_hops")] pub max_hops: usize, } /// Response from transitive closure #[derive(Debug, Serialize)] pub struct TransitiveClosureResponse { pub source_entity: String, pub closure: TransitiveClosure, pub process_time_ms: u128, } /// Request for reasoning paths #[derive(Debug, Deserialize)] pub struct ReasoningPathsRequest { pub project: String, pub source_id: String, pub target_id: String, #[serde(default = "default_max_hops")] pub max_hops: usize, } /// Response from reasoning paths #[derive(Debug, Serialize)] pub struct ReasoningPathsResponse { pub source_id: String, pub target_id: String, pub paths: Vec, pub path_count: usize, pub process_time_ms: u128, } fn default_max_hops() -> usize { 3 } /// POST /memory/synthesis/infer - Apply inference rules pub async fn infer_facts_handler( req: HttpRequest, body: web::Json, state: web::Data, ) -> HttpResponse { let start_time = std::time::Instant::now(); if let Err(response) = crate::handlers::middleware::validate_and_rate_limit( &req, &state, "synthesis", 50 ) { return response; } if body.entity_id.is_empty() { return crate::handlers::response_builder::bad_request("entity_id required"); } if body.max_hops == 0 || body.max_hops > 5 { return crate::handlers::response_builder::bad_request("max_hops must be 1-5"); } debug!("Inference: entity='{}', hops={}", body.entity_id, body.max_hops); let engine = InferenceEngine::new(state.pool.clone(), body.rules.clone()); let inferred = match engine.infer_facts(&body.project, &body.entity_id, body.max_hops).await { Ok(f) => f, Err(e) => { error!("Inference failed: {}", e); return crate::handlers::response_builder::internal_error(&format!("Failed: {}", e)); } }; let elapsed = start_time.elapsed().as_millis(); info!("Inference: {} facts in {}ms", inferred.len(), elapsed); crate::handlers::response_builder::success_response(InferenceResponse { entity_id: body.entity_id.clone(), inferred_facts: inferred.clone(), fact_count: inferred.len(), process_time_ms: elapsed, }) } /// POST /memory/synthesis/transitive-closure - Compute transitive closure pub async fn transitive_closure_handler( req: HttpRequest, body: web::Json, state: web::Data, ) -> HttpResponse { let start_time = std::time::Instant::now(); if let Err(response) = crate::handlers::middleware::validate_and_rate_limit( &req, &state, "synthesis", 50 ) { return response; } if body.entity_id.is_empty() { return crate::handlers::response_builder::bad_request("entity_id required"); } if body.max_hops == 0 || body.max_hops > 5 { return crate::handlers::response_builder::bad_request("max_hops must be 1-5"); } debug!("Transitive closure: entity='{}'", body.entity_id); let engine = InferenceEngine::new(state.pool.clone(), vec![]); let closure = match engine.transitive_closure( &body.entity_id, &body.project, body.relation_type.as_deref(), body.max_hops ).await { Ok(c) => c, Err(e) => { error!("Closure failed: {}", e); return crate::handlers::response_builder::internal_error(&format!("Failed: {}", e)); } }; let elapsed = start_time.elapsed().as_millis(); info!("Closure: {} entities in {}ms", closure.entity_count, elapsed); crate::handlers::response_builder::success_response(TransitiveClosureResponse { source_entity: body.entity_id.clone(), closure, process_time_ms: elapsed, }) } /// POST /memory/synthesis/reasoning-paths - Find reasoning paths pub async fn reasoning_paths_handler( req: HttpRequest, body: web::Json, state: web::Data, ) -> HttpResponse { let start_time = std::time::Instant::now(); if let Err(response) = crate::handlers::middleware::validate_and_rate_limit( &req, &state, "synthesis", 100 ) { return response; } if body.source_id.is_empty() || body.target_id.is_empty() { return crate::handlers::response_builder::bad_request("source_id and target_id required"); } if body.max_hops == 0 || body.max_hops > 5 { return crate::handlers::response_builder::bad_request("max_hops must be 1-5"); } debug!("Reasoning paths: {} → {}", body.source_id, body.target_id); let engine = InferenceEngine::new(state.pool.clone(), vec![]); let paths = match engine.find_reasoning_paths( &body.source_id, &body.target_id, &body.project, body.max_hops ).await { Ok(p) => p, Err(e) => { error!("Path finding failed: {}", e); return crate::handlers::response_builder::internal_error(&format!("Failed: {}", e)); } }; let elapsed = start_time.elapsed().as_millis(); info!("Paths: {} found in {}ms", paths.len(), elapsed); crate::handlers::response_builder::success_response(ReasoningPathsResponse { source_id: body.source_id.clone(), target_id: body.target_id.clone(), paths, path_count: paths.len(), process_time_ms: elapsed, }) } /// Request for query reasoning #[derive(Debug, Deserialize)] pub struct ReasonQueryRequest { pub project: String, pub question: String, } /// Response from query reasoning #[derive(Debug, Serialize)] pub struct ReasonQueryResponse { pub question: String, pub answers: Vec, pub confidence: f32, pub reasoning_steps: Vec, pub explanation: String, pub process_time_ms: u128, } /// Reasoning step in response #[derive(Debug, Serialize)] pub struct ReasoningStepResponse { pub step_id: usize, pub question: String, pub results: Vec, pub confidence: f32, } /// POST /memory/synthesis/reason - Answer complex questions via reasoning pub async fn reason_query_handler( req: HttpRequest, body: web::Json, state: web::Data, ) -> HttpResponse { let start_time = std::time::Instant::now(); if let Err(response) = crate::handlers::middleware::validate_and_rate_limit( &req, &state, "synthesis", 50 ) { return response; } if body.question.is_empty() || body.question.len() > 1000 { return crate::handlers::response_builder::bad_request( "Question must be 1-1000 characters" ); } debug!("Query reasoning: '{}'", body.question); let reasoner = QueryReasoner::new(state.pool.clone()); // Decompose question let sub_queries = match reasoner.decompose_question(&body.question) { Ok(sq) => sq, Err(e) => { error!("Question decomposition failed: {}", e); return crate::handlers::response_builder::internal_error( &format!("Decomposition failed: {}", e) ); } }; // Execute reasoning let answer = match reasoner.reason_over_subqueries(sub_queries, &body.project).await { Ok(a) => a, Err(e) => { error!("Reasoning failed: {}", e); return crate::handlers::response_builder::internal_error( &format!("Reasoning failed: {}", e) ); } }; let elapsed = start_time.elapsed().as_millis(); // Convert to response let steps: Vec = answer.reasoning_steps.iter().map(|step| { ReasoningStepResponse { step_id: step.step_id, question: step.sub_query.question.clone(), results: step.results.clone(), confidence: step.confidence, } }).collect(); info!("Query reasoning completed: {} answers with {} steps in {}ms", answer.answers.len(), answer.reasoning_steps.len(), elapsed); crate::handlers::response_builder::success_response(ReasonQueryResponse { question: answer.question, answers: answer.answers, confidence: answer.confidence, reasoning_steps: steps, explanation: answer.explanation, process_time_ms: elapsed, }) } /// Request for content summarization #[derive(Debug, Deserialize)] pub struct SummarizeRequest { pub project: String, pub content: String, #[serde(default = "default_max_length")] pub max_length: usize, #[serde(default = "default_strategy")] pub strategy: String, } fn default_max_length() -> usize { 200 } fn default_strategy() -> String { "hybrid".to_string() } /// Response for summarization #[derive(Debug, Serialize)] pub struct SummarizeResponse { pub original_length: usize, pub summary: String, pub summary_length: usize, pub compression_ratio: f32, pub key_facts: Vec, pub coherence: f32, pub process_time_ms: u128, } /// Key fact in response #[derive(Debug, Serialize)] pub struct KeyFactResponse { pub fact: String, pub importance: f32, pub fact_type: String, } /// POST /memory/synthesis/summarize - Summarize and abstract results pub async fn summarize_handler( req: HttpRequest, body: web::Json, state: web::Data, ) -> HttpResponse { let start_time = std::time::Instant::now(); if let Err(response) = crate::handlers::middleware::validate_and_rate_limit( &req, &state, "synthesis", 100 ) { return response; } if body.content.is_empty() || body.content.len() > 50000 { return crate::handlers::response_builder::bad_request( "Content must be 1-50000 characters" ); } if body.max_length < 50 || body.max_length > 10000 { return crate::handlers::response_builder::bad_request( "Max length must be 50-10000" ); } debug!("Summarizing {} chars to ~{} chars", body.content.len(), body.max_length); let strategy = match body.strategy.to_lowercase().as_str() { "extractive" => SummarizationStrategy::Extractive, "abstractive" => SummarizationStrategy::Abstractive, "hybrid" | _ => SummarizationStrategy::Hybrid, }; let summarizer = Summarizer::new(); let summary = match summarizer.summarize(&body.content, body.max_length, strategy) { Ok(s) => s, Err(e) => { error!("Summarization failed: {}", e); return crate::handlers::response_builder::internal_error( &format!("Summarization failed: {}", e) ); } }; let elapsed = start_time.elapsed().as_millis(); // Convert key facts to response let key_facts: Vec = summary.key_facts.into_iter().map(|kf| { KeyFactResponse { fact: kf.fact, importance: kf.importance, fact_type: kf.fact_type, } }).collect(); info!("Summarization completed: {}% compression, {} key facts, coherence {:.2}%", (100.0 * summary.compression_ratio) as u32, key_facts.len(), summary.coherence * 100.0); crate::handlers::response_builder::success_response(SummarizeResponse { original_length: summary.original_length, summary: summary.text, summary_length: summary.summary_length, compression_ratio: summary.compression_ratio, key_facts, coherence: summary.coherence, process_time_ms: elapsed, }) } #[cfg(test)] mod tests { use super::*; #[test] fn test_link_entities_request() { let req = LinkEntitiesRequest { project: "poimen".to_string(), text: "Kubernetes is a container orchestrator.".to_string(), }; assert_eq!(req.project, "poimen"); assert!(!req.text.is_empty()); } #[test] fn test_detect_aliases_request() { let req = DetectAliasesRequest { project: "poimen".to_string(), entity_id: "e1".to_string(), entity_name: "Kubernetes".to_string(), text_samples: vec!["k8s is great".to_string()], }; assert_eq!(req.entity_name, "Kubernetes"); assert_eq!(req.text_samples.len(), 1); } #[test] fn test_suggest_merges_request() { let req = SuggestMergesRequest { project: "poimen".to_string(), similarity_threshold: 0.85, }; assert_eq!(req.similarity_threshold, 0.85); } #[test] fn test_suggest_merges_default_threshold() { let req = SuggestMergesRequest { project: "poimen".to_string(), similarity_threshold: default_merge_threshold(), }; assert_eq!(req.similarity_threshold, 0.8); } #[test] fn test_detect_coreferences_request() { let req = DetectCoreferencesRequest { project: "poimen".to_string(), texts: vec![ "Kubernetes is great.".to_string(), "k8s makes deployments easy.".to_string(), ], }; assert_eq!(req.texts.len(), 2); } #[test] fn test_link_entities_response() { let resp = LinkEntitiesResponse { links: vec![], unlinked: vec![], total_mentions: 0, link_rate: 0.0, process_time_ms: 100, }; assert_eq!(resp.total_mentions, 0); } #[test] fn test_detect_aliases_response() { let resp = DetectAliasesResponse { entity_id: "e1".to_string(), entity_name: "Kubernetes".to_string(), aliases: vec![], alias_count: 0, process_time_ms: 100, }; assert_eq!(resp.alias_count, 0); } #[test] fn test_suggest_merges_response() { let resp = SuggestMergesResponse { project: "poimen".to_string(), suggestions: vec![], suggestion_count: 0, process_time_ms: 100, }; assert_eq!(resp.suggestion_count, 0); } #[test] fn test_detect_coreferences_response() { let resp = DetectCoreferencesResponse { project: "poimen".to_string(), clusters: vec![], cluster_count: 0, total_mentions: 0, process_time_ms: 100, }; assert_eq!(resp.cluster_count, 0); } #[test] fn test_link_entities_request_serialization() { let req = LinkEntitiesRequest { project: "test".to_string(), text: "Kubernetes".to_string(), }; let json = serde_json::to_string(&req).unwrap(); assert!(json.contains("test")); } #[test] fn test_link_entities_response_serialization() { let resp = LinkEntitiesResponse { links: vec![], unlinked: vec![], total_mentions: 5, link_rate: 0.8, process_time_ms: 150, }; let json = serde_json::to_string(&resp).unwrap(); assert!(json.contains("0.8")); } }