Complete Aphoria claims system overhaul: - A1: Rename ExtractedClaim to Observation (extractors produce observations, not claims) - A2: Add AuthoredClaim with full provenance, invariants, and authority tiers - A3: Verify engine comparing observations against authored claims, CLI + formatters - A4: Corpus as first-class assertions with predicate indexing, authority lens, trust packs - A5: Coverage analysis, explain/docs generation, self-audit extractor, claim suggester skill Also includes: 42 extractors updated for Observation type, verifiable_predicates trait, conflict detection with comparison modes, claims TOML persistence, Grafana dashboard, backup/restore scripts, and comprehensive test coverage. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
169 lines
5.8 KiB
Rust
169 lines
5.8 KiB
Rust
//! 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<AppState>,
|
|
AxumQuery(params): AxumQuery<SkepticQueryParams>,
|
|
) -> Result<Json<SkepticResponse>> {
|
|
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<S: stemedb_storage::KVStore + 'static>(
|
|
registry: &GenericSourceRegistry<S>,
|
|
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
|
|
}
|