stemedb/crates/stemedb-api/src/handlers/layered.rs
jordan ad07a75d0a feat: add source content to source registry, signed assertions, feed endpoint, dashboard enhancements
- Add `content: Option<String>` to SourceRecord with rkyv schema evolution
  (LegacySourceRecord compat deserializer for backward compatibility)
- Add MAX_SOURCE_CONTENT_LEN (1MB) limit with API validation
- Strip content from list responses, include in single-source GET
- Update Go SDK RegisterSourceRequest with Content field
- FCM pipeline extracts PDF text via pdftotext and passes to registration
- Dashboard impact panel fetches and displays source content with expand/collapse
- Add feed endpoint, dashboard feed panel, and signed assertion support
- Update data-structures.md, API docs, and storage docs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 21:54:27 -07:00

191 lines
6.6 KiB
Rust

//! 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<AppState>,
AxumQuery(params): AxumQuery<SkepticQueryParams>,
) -> Result<Json<LayeredQueryResponse>> {
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<TierResolutionDto> = 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::<Result<Vec<_>>>()?;
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<AssertionResponse> {
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
}