CI / CI (push) Successful in 11m36s
## Problem 8 integration test files failed to compile due to: 1. Ambiguous float types (Rust 2024+ stricter inference) 2. chrono 0.4 API change (`with_hour` removed) 3. Missing `sqlx` + `base64` in `[dev-dependencies]` 4. `<` parsed as generics instead of comparison 5. Incorrect assertion (3^5=243 > 100) ## Fix - Added `f32`/`f64` type annotations to vec declarations and bindings - Replaced `with_hour(0)` with `date_naive().and_hms_opt(0,0,0).unwrap().and_utc()` - Added `sqlx` + `base64` to `[dev-dependencies]` - Wrapped comparison in parens - Fixed assertion: nodes=100 → nodes=1000 ## Validation - `cargo build --release` clean - `cargo test` — 20 test suites, 0 failures - 10 files changed, 46 insertions, 42 deletionsReviewed-on: #46 Co-authored-by: rock <[email protected]>
735 lines
22 KiB
Rust
735 lines
22 KiB
Rust
//! 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<MentionLink>,
|
|
/// Unlinked mention texts
|
|
pub unlinked: Vec<String>,
|
|
/// 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<String>,
|
|
}
|
|
|
|
/// Response from alias detection
|
|
#[derive(Debug, Serialize)]
|
|
pub struct DetectAliasesResponse {
|
|
pub entity_id: String,
|
|
pub entity_name: String,
|
|
pub aliases: Vec<AliasSuggestion>,
|
|
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<MergeSuggestion>,
|
|
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<String>,
|
|
}
|
|
|
|
/// Response from coreference detection
|
|
#[derive(Debug, Serialize)]
|
|
pub struct DetectCoreferencesResponse {
|
|
pub project: String,
|
|
pub clusters: Vec<CoreferenceCluster>,
|
|
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<LinkEntitiesRequest>,
|
|
state: web::Data<AppState>,
|
|
) -> 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<DetectAliasesRequest>,
|
|
state: web::Data<AppState>,
|
|
) -> 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<SuggestMergesRequest>,
|
|
state: web::Data<AppState>,
|
|
) -> 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<DetectCoreferencesRequest>,
|
|
state: web::Data<AppState>,
|
|
) -> 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<InferenceRule>,
|
|
#[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<InferredFact>,
|
|
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<String>,
|
|
#[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<ReasoningPath>,
|
|
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<InferenceRequest>,
|
|
state: web::Data<AppState>,
|
|
) -> 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<TransitiveClosureRequest>,
|
|
state: web::Data<AppState>,
|
|
) -> 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<ReasoningPathsRequest>,
|
|
state: web::Data<AppState>,
|
|
) -> 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);
|
|
|
|
let path_count = paths.len();
|
|
crate::handlers::response_builder::success_response(ReasoningPathsResponse {
|
|
source_id: body.source_id.clone(),
|
|
target_id: body.target_id.clone(),
|
|
paths,
|
|
path_count,
|
|
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<String>,
|
|
pub confidence: f32,
|
|
pub reasoning_steps: Vec<ReasoningStepResponse>,
|
|
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<String>,
|
|
pub confidence: f32,
|
|
}
|
|
|
|
/// POST /memory/synthesis/reason - Answer complex questions via reasoning
|
|
pub async fn reason_query_handler(
|
|
req: HttpRequest,
|
|
body: web::Json<ReasonQueryRequest>,
|
|
state: web::Data<AppState>,
|
|
) -> 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<ReasoningStepResponse> = 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<KeyFactResponse>,
|
|
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<SummarizeRequest>,
|
|
state: web::Data<AppState>,
|
|
) -> 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<KeyFactResponse> = 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,
|
|
})
|
|
}
|
|
|