- 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>
191 lines
6.6 KiB
Rust
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
|
|
}
|