//! Handler for layered consensus (per-tier resolution) queries. use axum::{ extract::{Query as AxumQuery, State}, Json, }; use tracing::instrument; use crate::{ dto::{ AssertionResponse, ErrorResponse, LayeredQueryResponse, SkepticQueryParams, SourceClassDto, TierResolutionDto, }, error::{ApiError, Result}, state::AppState, }; use stemedb_core::types::SourceClass; use stemedb_lens::{LayeredConsensusLens, LayeredLens}; use stemedb_query::Query; /// Query for per-tier consensus using LayeredConsensusLens. /// /// This endpoint provides visibility into what each source class tier says, /// enabling "What does Tier 0 (FDA) say? What does Tier 5 (Reddit) say?" queries. /// /// # Response /// /// Returns a `LayeredQueryResponse` with: /// - `tiers`: Per-tier consensus results (only tiers with candidates) /// - `overall_winner`: Winner from the highest-authority tier present /// - `overall_conflict_score`: Cross-tier disagreement (0.0 = tiers agree, 1.0 = tiers disagree) /// /// # Example /// /// ```ignore /// GET /v1/layered?subject=Semaglutide&predicate=muscle_effect /// /// { /// "subject": "Semaglutide", /// "predicate": "muscle_effect", /// "tiers": [ /// { /// "tier": 1, /// "source_class": "Clinical", /// "winner": { ... }, /// "candidates_count": 12, /// "conflict_score": 0.15, /// "resolution_confidence": 0.85 /// }, /// { /// "tier": 5, /// "source_class": "Anecdotal", /// "winner": { ... }, /// "candidates_count": 200, /// "conflict_score": 0.45, /// "resolution_confidence": 0.55 /// } /// ], /// "overall_winner": { ... }, /// "overall_conflict_score": 0.78, /// "total_candidates": 212, /// "lens_name": "LayeredConsensus" /// } /// ``` #[utoipa::path( get, path = "/v1/layered", params(SkepticQueryParams), responses( (status = 200, description = "Layered consensus successful", body = LayeredQueryResponse), (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))] pub async fn layered_query( State(state): State, AxumQuery(params): AxumQuery, ) -> Result> { let query_start = std::time::Instant::now(); metrics::counter!("stemedb_queries_total", "endpoint" => "layered").increment(1); let computed_at = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0); // Build query for subject+predicate let query = Query::builder() .subject(params.subject.clone()) .predicate(params.predicate.clone()) .build(); // Execute the query to get candidates let query_engine = state.query_engine(); let result = query_engine.execute(&query).await?; // Return 404 if no assertions found if result.assertions.is_empty() { return Err(ApiError::NotFound(format!( "No assertions found for subject '{}' and predicate '{}'", params.subject, params.predicate ))); } // Apply LayeredConsensusLens let lens = LayeredConsensusLens::new(); let layered = lens.resolve_layered(&result.assertions); // Convert to DTOs let tiers: Vec = layered .tiers .into_iter() .map(|tr| { let winner = tr.winner.map(assertion_to_dto).transpose()?; Ok(TierResolutionDto { tier: tr.tier, source_class: source_class_to_dto(tr.source_class), winner, candidates_count: tr.candidates_count, conflict_score: tr.conflict_score, resolution_confidence: tr.resolution_confidence, }) }) .collect::>>()?; let overall_winner = layered.overall_winner.map(assertion_to_dto).transpose()?; metrics::histogram!("stemedb_query_latency_seconds", "endpoint" => "layered") .record(query_start.elapsed().as_secs_f64()); Ok(Json(LayeredQueryResponse { subject: params.subject, predicate: params.predicate, tiers, overall_winner, overall_conflict_score: layered.overall_conflict_score, total_candidates: layered.total_candidates, computed_at, lens_name: "LayeredConsensus".to_string(), })) } /// Convert SourceClass to SourceClassDto. fn source_class_to_dto(sc: SourceClass) -> SourceClassDto { match sc { SourceClass::Regulatory => SourceClassDto::Regulatory, SourceClass::Clinical => SourceClassDto::Clinical, SourceClass::Observational => SourceClassDto::Observational, SourceClass::TeamPolicy => SourceClassDto::TeamPolicy, SourceClass::Expert => SourceClassDto::Expert, SourceClass::Community => SourceClassDto::Community, SourceClass::Anecdotal => SourceClassDto::Anecdotal, } } /// Convert an internal Assertion to an AssertionResponse DTO. fn assertion_to_dto(assertion: stemedb_core::types::Assertion) -> Result { let serialized = stemedb_core::serde::serialize(&assertion) .map_err(|e| ApiError::Serialization(format!("Failed to serialize assertion: {}", e)))?; let hash = blake3::hash(&serialized); Ok(AssertionResponse { hash: hash.to_hex().to_string(), subject: assertion.subject, predicate: assertion.predicate, object: assertion.object.into(), parent_hash: assertion.parent_hash.map(hex::encode), source_hash: hex::encode(assertion.source_hash), source_class: assertion.source_class.into(), visual_hash: assertion.visual_hash.map(hex::encode), epoch: assertion.epoch.map(hex::encode), lifecycle: assertion.lifecycle.into(), signatures: assertion.signatures.into_iter().map(Into::into).collect(), confidence: assertion.confidence, timestamp: assertion.timestamp, vector: assertion.vector, source_metadata: assertion.source_metadata.and_then(|bytes| String::from_utf8(bytes).ok()), narrative: assertion.narrative, source_warning: None, // LayeredConsensus doesn't do source status enrichment }) } #[cfg(test)] mod tests { // Integration tests would go here // For now, the unit tests in stemedb-lens cover the core functionality }