//! Handler for skeptic (conflict analysis) queries. use axum::{ extract::{Query as AxumQuery, State}, Json, }; use tracing::instrument; use crate::{ dto::{ErrorResponse, SkepticQueryParams, SkepticResponse, SourceMetadataDto}, error::Result, services::{make_source_warning, SourceStatusEnricher}, state::AppState, }; use stemedb_query::SkepticResolver; use stemedb_storage::{ GenericSourceRegistry, GenericTrustRankStore, GenericVoteStore, SourceRegistry, }; /// Query for conflict analysis using SkepticLens. /// /// This endpoint provides "Trust but Verify" analysis, showing all competing /// claims for a subject+predicate instead of picking a single winner. /// /// # Response /// /// Returns a `SkepticResponse` with: /// - `status`: Unanimous, Agreed, or Contested /// - `conflict_score`: 0.0 (unanimous) to 1.0 (chaos) /// - `claims`: All distinct claims ranked by support /// /// # Example /// /// ```ignore /// GET /v1/skeptic?subject=Semaglutide&predicate=muscle_effect /// /// { /// "status": "Contested", /// "conflict_score": 0.72, /// "claims": [ /// { "value": {"type": "Text", "value": "Significant loss"}, "weight_share": 0.45 }, /// { "value": {"type": "Text", "value": "Minimal loss"}, "weight_share": 0.35 } /// ] /// } /// ``` #[utoipa::path( get, path = "/v1/skeptic", params(SkepticQueryParams), responses( (status = 200, description = "Conflict analysis successful", body = SkepticResponse), (status = 404, description = "No assertions found for subject+predicate", body = ErrorResponse), (status = 400, description = "Invalid request", body = ErrorResponse), (status = 500, description = "Internal server error", body = ErrorResponse) ), tag = "query" )] #[instrument(skip(state), fields(subject = %params.subject, predicate = %params.predicate, include_source_metadata = params.include_source_metadata))] pub async fn skeptic_query( State(state): State, AxumQuery(params): AxumQuery, ) -> Result> { let query_start = std::time::Instant::now(); metrics::counter!("stemedb_queries_total", "endpoint" => "skeptic").increment(1); // Create the resolver with vote and trust stores let vote_store = std::sync::Arc::new(GenericVoteStore::new(state.store.clone())); let trust_store = std::sync::Arc::new(GenericTrustRankStore::new(state.store.clone())); let resolver = SkepticResolver::new(state.store.clone(), vote_store, trust_store); // Execute the skeptic resolution let view = resolver.resolve(¶ms.subject, ¶ms.predicate).await?; // Return 404 if no assertions found let view = view.ok_or_else(|| { crate::error::ApiError::NotFound(format!( "No assertions found for subject '{}' and predicate '{}'", params.subject, params.predicate )) })?; // Convert to response DTO let mut response: SkepticResponse = view.into(); // Enrich with source metadata if requested if params.include_source_metadata { let source_registry = GenericSourceRegistry::new(state.store.clone()); enrich_sources_with_metadata(&source_registry, &mut response).await; } // Enrich with source warnings (P3.2 Cascade Flagging) enrich_claims_with_warnings(&state, &mut response).await; metrics::histogram!("stemedb_query_latency_seconds", "endpoint" => "skeptic") .record(query_start.elapsed().as_secs_f64()); Ok(Json(response)) } /// Enrich SkepticResponse with source metadata from the registry. async fn enrich_sources_with_metadata( registry: &GenericSourceRegistry, response: &mut SkepticResponse, ) { for claim in &mut response.claims { // Try to lookup source metadata by hash if let Ok(hash_bytes) = hex::decode(&claim.source.source_hash) { if hash_bytes.len() == 32 { let mut hash = [0u8; 32]; hash.copy_from_slice(&hash_bytes); if let Ok(Some(record)) = registry.get(&hash).await { claim.source.source_metadata = Some(SourceMetadataDto::from(record)); } } } } } /// Enrich SkepticResponse claims with source warnings (P3.2 Cascade Flagging). /// /// Attaches warnings to claims that cite deprecated or quarantined sources. async fn enrich_claims_with_warnings( state: &crate::state::AppState, response: &mut SkepticResponse, ) { // Collect unique source hashes let source_hashes: Vec<[u8; 32]> = response .claims .iter() .filter_map(|claim| { hex::decode(&claim.source.source_hash).ok().and_then(|bytes| { if bytes.len() == 32 { let mut hash = [0u8; 32]; hash.copy_from_slice(&bytes); Some(hash) } else { None } }) }) .collect(); // Batch lookup source statuses let enricher = SourceStatusEnricher::new(state.store.clone()); let source_statuses = match enricher.batch_lookup(&source_hashes).await { Ok(statuses) => statuses, Err(_) => return, // Don't fail the response if enrichment fails }; // Attach warnings to claims for claim in &mut response.claims { if let Ok(hash_bytes) = hex::decode(&claim.source.source_hash) { if hash_bytes.len() == 32 { let mut hash = [0u8; 32]; hash.copy_from_slice(&hash_bytes); if let Some(record) = source_statuses.get(&hash) { claim.source_warning = make_source_warning(record); } } } } } #[cfg(test)] mod tests { // Integration tests would go here // For now, the unit tests in stemedb-query cover the core functionality }