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>
151 lines
4.8 KiB
Rust
151 lines
4.8 KiB
Rust
//! Handler for constraints (agent pre-flight check) queries.
|
|
|
|
use axum::{
|
|
extract::{Query as AxumQuery, State},
|
|
Json,
|
|
};
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
use tracing::instrument;
|
|
|
|
use crate::{
|
|
dto::{ConstraintEntryDto, ConstraintsQueryParams, ConstraintsResponse, ErrorResponse},
|
|
error::Result,
|
|
state::AppState,
|
|
};
|
|
|
|
use stemedb_core::types::Assertion;
|
|
use stemedb_lens::ConstraintsLens;
|
|
use stemedb_query::Query;
|
|
|
|
/// Query for constraint analysis using ConstraintsLens.
|
|
///
|
|
/// This endpoint provides pre-flight constraint checking for AI agents,
|
|
/// categorizing assertions by predicate pattern into must_use, forbidden, and prefer.
|
|
///
|
|
/// # Predicate Patterns
|
|
///
|
|
/// - `must_use:*` → Required, non-negotiable constraints
|
|
/// - `forbidden:*` → Explicitly banned items
|
|
/// - `prefer:*` → Recommended but optional
|
|
///
|
|
/// # Response
|
|
///
|
|
/// Returns a `ConstraintsResponse` with:
|
|
/// - `must_use`: Required constraints sorted by confidence
|
|
/// - `forbidden`: Banned items sorted by confidence
|
|
/// - `prefer`: Recommendations sorted by confidence
|
|
///
|
|
/// # Example
|
|
///
|
|
/// ```ignore
|
|
/// GET /v1/constraints?subject=project_alpha
|
|
///
|
|
/// {
|
|
/// "subject": "project_alpha",
|
|
/// "must_use": [
|
|
/// { "category": "http_client", "value": {"type": "Text", "value": "axios"}, "confidence": 0.95 }
|
|
/// ],
|
|
/// "forbidden": [
|
|
/// { "category": "http_client", "value": {"type": "Text", "value": "requests"}, "confidence": 0.9 }
|
|
/// ],
|
|
/// "prefer": [
|
|
/// { "category": "language", "value": {"type": "Text", "value": "typescript"}, "confidence": 0.8 }
|
|
/// ]
|
|
/// }
|
|
/// ```
|
|
#[utoipa::path(
|
|
get,
|
|
path = "/v1/constraints",
|
|
params(ConstraintsQueryParams),
|
|
responses(
|
|
(status = 200, description = "Constraint analysis successful", body = ConstraintsResponse),
|
|
(status = 400, description = "Invalid request", body = ErrorResponse),
|
|
(status = 500, description = "Internal server error", body = ErrorResponse)
|
|
),
|
|
tag = "query"
|
|
)]
|
|
#[instrument(skip(state), fields(subject = %params.subject))]
|
|
pub async fn constraints_query(
|
|
State(state): State<AppState>,
|
|
AxumQuery(params): AxumQuery<ConstraintsQueryParams>,
|
|
) -> Result<Json<ConstraintsResponse>> {
|
|
let query_start = std::time::Instant::now();
|
|
metrics::counter!("stemedb_queries_total", "endpoint" => "constraints").increment(1);
|
|
// Build query for all assertions with this subject
|
|
// We need ALL predicates, not just one specific one
|
|
let query = Query::builder().subject(¶ms.subject).build();
|
|
|
|
// Execute the query
|
|
let query_engine = state.query_engine();
|
|
let result = query_engine.execute(&query).await?;
|
|
|
|
// Apply ConstraintsLens to categorize
|
|
let lens = ConstraintsLens;
|
|
let constraint_set = lens.resolve_constraints(&result.assertions);
|
|
|
|
// Get current timestamp
|
|
let computed_at =
|
|
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
|
|
|
|
// Convert to DTOs
|
|
let must_use = constraint_set
|
|
.must_use
|
|
.into_iter()
|
|
.map(|a| assertion_to_constraint_entry(a, "must_use:"))
|
|
.collect();
|
|
|
|
let forbidden = constraint_set
|
|
.forbidden
|
|
.into_iter()
|
|
.map(|a| assertion_to_constraint_entry(a, "forbidden:"))
|
|
.collect();
|
|
|
|
let prefer = constraint_set
|
|
.prefer
|
|
.into_iter()
|
|
.map(|a| assertion_to_constraint_entry(a, "prefer:"))
|
|
.collect();
|
|
|
|
metrics::histogram!("stemedb_query_latency_seconds", "endpoint" => "constraints")
|
|
.record(query_start.elapsed().as_secs_f64());
|
|
|
|
Ok(Json(ConstraintsResponse {
|
|
subject: params.subject,
|
|
must_use,
|
|
forbidden,
|
|
prefer,
|
|
candidates_count: constraint_set.candidates_count,
|
|
conflict_score: constraint_set.conflict_score,
|
|
computed_at,
|
|
}))
|
|
}
|
|
|
|
/// Convert an assertion to a ConstraintEntryDto.
|
|
///
|
|
/// Extracts the category from the predicate by removing the prefix.
|
|
fn assertion_to_constraint_entry(assertion: Assertion, prefix: &str) -> ConstraintEntryDto {
|
|
// Extract category from predicate (e.g., "must_use:http_client" -> "http_client")
|
|
let category =
|
|
assertion.predicate.strip_prefix(prefix).unwrap_or(&assertion.predicate).to_string();
|
|
|
|
// Compute assertion hash
|
|
let hash = stemedb_core::serde::serialize(&assertion)
|
|
.map(|bytes| hex::encode(blake3::hash(&bytes).as_bytes()))
|
|
.unwrap_or_default();
|
|
|
|
ConstraintEntryDto {
|
|
category,
|
|
value: assertion.object.into(),
|
|
confidence: assertion.confidence,
|
|
assertion_hash: hash,
|
|
timestamp: assertion.timestamp,
|
|
source_class: assertion.source_class.into(),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
// Integration tests would go here
|
|
// For now, the unit tests in stemedb-lens/src/constraints.rs cover the core functionality
|
|
}
|