Skip to main content

mockforge_intelligence/handlers/
semantic_drift.rs

1//! HTTP handlers for semantic drift incidents
2//!
3//! This module provides endpoints for managing semantic drift incidents.
4
5// ContractDiffAnalyzer stays in core (LLM-bound).
6#![allow(deprecated)]
7
8use crate::ai_contract_diff::{ContractDiffAnalyzer, ContractDiffConfig};
9use crate::incidents::semantic_manager::{SemanticIncident, SemanticIncidentManager};
10use axum::{
11    extract::{Path, Query, State},
12    http::StatusCode,
13    response::Json,
14};
15use mockforge_openapi::OpenApiSpec;
16use serde::{Deserialize, Serialize};
17use std::sync::Arc;
18
19#[cfg(feature = "database")]
20use chrono::{DateTime, Utc};
21#[cfg(feature = "database")]
22use uuid::Uuid;
23
24#[cfg(feature = "database")]
25use crate::database::Database;
26
27/// Helper function to map database row to SemanticIncident
28#[cfg(feature = "database")]
29fn map_row_to_semantic_incident(
30    row: &sqlx::postgres::PgRow,
31) -> Result<SemanticIncident, sqlx::Error> {
32    use mockforge_foundation::contract_diff_types::SemanticChangeType;
33    use mockforge_foundation::incidents_types::{IncidentSeverity, IncidentStatus};
34    use sqlx::Row;
35
36    let id: Uuid = row.try_get("id")?;
37    let workspace_id: Option<Uuid> = row.try_get("workspace_id").ok();
38    let endpoint: String = row.try_get("endpoint")?;
39    let method: String = row.try_get("method")?;
40    let semantic_change_type_str: String = row.try_get("semantic_change_type")?;
41    let severity_str: String = row.try_get("severity")?;
42    let status_str: String = row.try_get("status")?;
43    let semantic_confidence: f64 = row.try_get("semantic_confidence")?;
44    let soft_breaking_score: f64 = row.try_get("soft_breaking_score")?;
45    let llm_analysis: serde_json::Value = row.try_get("llm_analysis").unwrap_or_default();
46    let before_semantic_state: serde_json::Value =
47        row.try_get("before_semantic_state").unwrap_or_default();
48    let after_semantic_state: serde_json::Value =
49        row.try_get("after_semantic_state").unwrap_or_default();
50    let details_json: serde_json::Value = row.try_get("details").unwrap_or_default();
51    let related_drift_incident_id: Option<Uuid> = row.try_get("related_drift_incident_id").ok();
52    let contract_diff_id: Option<String> = row.try_get("contract_diff_id").ok();
53    let external_ticket_id: Option<String> = row.try_get("external_ticket_id").ok();
54    let external_ticket_url: Option<String> = row.try_get("external_ticket_url").ok();
55    let detected_at: DateTime<Utc> = row.try_get("detected_at")?;
56    let created_at: DateTime<Utc> = row.try_get("created_at")?;
57    let acknowledged_at: Option<DateTime<Utc>> = row.try_get("acknowledged_at").ok();
58    let resolved_at: Option<DateTime<Utc>> = row.try_get("resolved_at").ok();
59    let closed_at: Option<DateTime<Utc>> = row.try_get("closed_at").ok();
60    let updated_at: DateTime<Utc> = row.try_get("updated_at")?;
61
62    // Parse semantic change type
63    let semantic_change_type = match semantic_change_type_str.as_str() {
64        "description_change" => SemanticChangeType::DescriptionChange,
65        "enum_narrowing" => SemanticChangeType::EnumNarrowing,
66        "nullability_change" => SemanticChangeType::NullableChange,
67        "error_code_removed" => SemanticChangeType::ErrorCodeRemoved,
68        "meaning_shift" => SemanticChangeType::MeaningShift,
69        _ => SemanticChangeType::MeaningShift, // Default fallback
70    };
71
72    // Parse severity
73    let severity = match severity_str.as_str() {
74        "low" => IncidentSeverity::Low,
75        "medium" => IncidentSeverity::Medium,
76        "high" => IncidentSeverity::High,
77        "critical" => IncidentSeverity::Critical,
78        _ => IncidentSeverity::Medium, // Default fallback
79    };
80
81    // Parse status
82    let status = match status_str.as_str() {
83        "open" => IncidentStatus::Open,
84        "acknowledged" => IncidentStatus::Acknowledged,
85        "resolved" => IncidentStatus::Resolved,
86        "closed" => IncidentStatus::Closed,
87        _ => IncidentStatus::Open, // Default fallback
88    };
89
90    Ok(SemanticIncident {
91        id: id.to_string(),
92        workspace_id: workspace_id.map(|u| u.to_string()),
93        endpoint,
94        method,
95        semantic_change_type,
96        severity,
97        status,
98        semantic_confidence,
99        soft_breaking_score,
100        llm_analysis,
101        before_semantic_state,
102        after_semantic_state,
103        details: details_json,
104        related_drift_incident_id: related_drift_incident_id.map(|u| u.to_string()),
105        contract_diff_id,
106        external_ticket_id,
107        external_ticket_url,
108        detected_at: detected_at.timestamp(),
109        created_at: created_at.timestamp(),
110        acknowledged_at: acknowledged_at.map(|dt| dt.timestamp()),
111        resolved_at: resolved_at.map(|dt| dt.timestamp()),
112        closed_at: closed_at.map(|dt| dt.timestamp()),
113        updated_at: updated_at.timestamp(),
114    })
115}
116
117/// State for semantic drift handlers
118#[derive(Clone)]
119pub struct SemanticDriftState {
120    /// Semantic incident manager
121    pub manager: Arc<SemanticIncidentManager>,
122    /// Database connection (optional)
123    #[cfg(feature = "database")]
124    pub database: Option<Database>,
125}
126
127impl SemanticDriftState {
128    /// Create handler state without a persistence connection.
129    ///
130    /// This constructor works when dependency feature unification enables database support.
131    pub fn new(manager: Arc<SemanticIncidentManager>) -> Self {
132        Self {
133            manager,
134            #[cfg(feature = "database")]
135            database: None,
136        }
137    }
138
139    /// Attach the optional persistence connection used by these handlers.
140    #[cfg(feature = "database")]
141    pub fn with_database(mut self, database: Option<Database>) -> Self {
142        self.database = database;
143        self
144    }
145}
146
147/// Query parameters for listing semantic incidents
148#[derive(Debug, Deserialize)]
149pub struct ListSemanticIncidentsQuery {
150    /// Workspace ID filter
151    pub workspace_id: Option<String>,
152    /// Endpoint filter
153    pub endpoint: Option<String>,
154    /// Method filter
155    pub method: Option<String>,
156    /// Status filter
157    pub status: Option<String>,
158    /// Limit results
159    pub limit: Option<usize>,
160}
161
162/// Response for semantic incident list
163#[derive(Debug, Serialize)]
164pub struct SemanticIncidentListResponse {
165    /// Incidents
166    pub incidents: Vec<SemanticIncident>,
167    /// Total count
168    pub total: usize,
169}
170
171/// List semantic drift incidents
172///
173/// GET /api/v1/semantic-drift/incidents
174pub async fn list_semantic_incidents(
175    State(state): State<SemanticDriftState>,
176    Query(params): Query<ListSemanticIncidentsQuery>,
177) -> Result<Json<SemanticIncidentListResponse>, StatusCode> {
178    // Try database first, fallback to in-memory
179    #[cfg(feature = "database")]
180    if let Some(pool) = state.database.as_ref().and_then(|db| db.pool()) {
181        let mut query = String::from(
182            "SELECT id, workspace_id, endpoint, method, semantic_change_type, severity, status,
183             semantic_confidence, soft_breaking_score, llm_analysis, before_semantic_state,
184             after_semantic_state, details, related_drift_incident_id, contract_diff_id,
185             external_ticket_id, external_ticket_url, detected_at, created_at, acknowledged_at,
186             resolved_at, closed_at, updated_at
187             FROM semantic_drift_incidents WHERE 1=1",
188        );
189
190        let mut bind_index = 1;
191
192        if params.workspace_id.is_some() {
193            query.push_str(&format!(" AND workspace_id = ${}", bind_index));
194            bind_index += 1;
195        }
196
197        if params.endpoint.is_some() {
198            query.push_str(&format!(" AND endpoint = ${}", bind_index));
199            bind_index += 1;
200        }
201
202        if params.method.is_some() {
203            query.push_str(&format!(" AND method = ${}", bind_index));
204            bind_index += 1;
205        }
206
207        if params.status.is_some() {
208            query.push_str(&format!(" AND status = ${}", bind_index));
209            bind_index += 1;
210        }
211
212        let limit = params.limit.unwrap_or(100);
213        query.push_str(&format!(" ORDER BY detected_at DESC LIMIT {}", limit));
214
215        // Execute query - use fetch_all for SELECT queries.
216        // Binds match the order in which filters were appended above.
217        let _ = bind_index; // silence unused-assignment after the last filter
218        let mut q = sqlx::query(&query);
219        if let Some(ws_id) = &params.workspace_id {
220            q = q.bind(ws_id);
221        }
222        if let Some(ep) = &params.endpoint {
223            q = q.bind(ep);
224        }
225        if let Some(m) = &params.method {
226            q = q.bind(m);
227        }
228        if let Some(s) = &params.status {
229            q = q.bind(s);
230        }
231        let rows = q.fetch_all(pool).await.map_err(|e| {
232            tracing::error!("Failed to query semantic incidents: {}", e);
233            StatusCode::INTERNAL_SERVER_ERROR
234        })?;
235
236        // Map rows to SemanticIncident
237        let mut incidents = Vec::new();
238        for row in rows {
239            match map_row_to_semantic_incident(&row) {
240                Ok(incident) => incidents.push(incident),
241                Err(e) => {
242                    tracing::warn!("Failed to map semantic incident row: {}", e);
243                    continue;
244                }
245            }
246        }
247        if !incidents.is_empty() {
248            return Ok(Json(SemanticIncidentListResponse {
249                total: incidents.len(),
250                incidents,
251            }));
252        }
253        // Fall through to in-memory manager if no database results
254    }
255
256    // Fallback to in-memory manager
257    let status = params.status.as_deref().and_then(|s| match s {
258        "open" => Some(mockforge_foundation::incidents_types::IncidentStatus::Open),
259        "acknowledged" => Some(mockforge_foundation::incidents_types::IncidentStatus::Acknowledged),
260        "resolved" => Some(mockforge_foundation::incidents_types::IncidentStatus::Resolved),
261        "closed" => Some(mockforge_foundation::incidents_types::IncidentStatus::Closed),
262        _ => None,
263    });
264
265    let incidents = state
266        .manager
267        .list_incidents(
268            params.workspace_id.as_deref(),
269            params.endpoint.as_deref(),
270            params.method.as_deref(),
271            status,
272            params.limit,
273        )
274        .await;
275
276    Ok(Json(SemanticIncidentListResponse {
277        total: incidents.len(),
278        incidents,
279    }))
280}
281
282/// Get a specific semantic incident
283///
284/// GET /api/v1/semantic-drift/incidents/{id}
285pub async fn get_semantic_incident(
286    State(state): State<SemanticDriftState>,
287    Path(id): Path<String>,
288) -> Result<Json<SemanticIncident>, StatusCode> {
289    // Try database first
290    #[cfg(feature = "database")]
291    if let Some(pool) = state.database.as_ref().and_then(|db| db.pool()) {
292        let row = sqlx::query("SELECT * FROM semantic_drift_incidents WHERE id = $1")
293            .bind(&id)
294            .fetch_optional(pool)
295            .await
296            .map_err(|e| {
297                tracing::error!("Failed to query semantic incident: {}", e);
298                StatusCode::INTERNAL_SERVER_ERROR
299            })?;
300
301        if let Some(row) = row {
302            match map_row_to_semantic_incident(&row) {
303                Ok(incident) => return Ok(Json(incident)),
304                Err(e) => {
305                    tracing::warn!("Failed to map semantic incident: {}", e);
306                    // Fall through to in-memory
307                }
308            }
309        }
310    }
311
312    // Fallback to in-memory manager
313    match state.manager.get_incident(&id).await {
314        Some(incident) => Ok(Json(incident)),
315        None => Err(StatusCode::NOT_FOUND),
316    }
317}
318
319/// Request to analyze semantic drift
320#[derive(Debug, Deserialize)]
321pub struct AnalyzeSemanticDriftRequest {
322    /// Before spec (OpenAPI YAML/JSON)
323    pub before_spec: String,
324    /// After spec (OpenAPI YAML/JSON)
325    pub after_spec: String,
326    /// Endpoint path
327    pub endpoint: String,
328    /// HTTP method
329    pub method: String,
330    /// Workspace ID (optional)
331    pub workspace_id: Option<String>,
332}
333
334/// Analyze semantic drift between two specs
335///
336/// POST /api/v1/semantic-drift/analyze
337pub async fn analyze_semantic_drift(
338    State(state): State<SemanticDriftState>,
339    Json(request): Json<AnalyzeSemanticDriftRequest>,
340) -> Result<Json<serde_json::Value>, StatusCode> {
341    // Parse specs
342    let before_spec = OpenApiSpec::from_string(&request.before_spec, None)
343        .map_err(|_| StatusCode::BAD_REQUEST)?;
344    let after_spec =
345        OpenApiSpec::from_string(&request.after_spec, None).map_err(|_| StatusCode::BAD_REQUEST)?;
346
347    // Create analyzer
348    let config = ContractDiffConfig::default();
349    let analyzer =
350        ContractDiffAnalyzer::new(config).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
351
352    // Run semantic analysis
353    let semantic_result = analyzer
354        .compare_specs(&before_spec, &after_spec, &request.endpoint, &request.method)
355        .await
356        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
357
358    if let Some(result) = semantic_result {
359        // Create semantic incident if threshold met
360        if result.semantic_confidence >= 0.65 {
361            let incident = state
362                .manager
363                .create_incident(
364                    &result,
365                    request.endpoint.clone(),
366                    request.method.clone(),
367                    request.workspace_id.clone(),
368                    None, // related_drift_incident_id
369                    None, // contract_diff_id
370                )
371                .await;
372
373            // Store in database if available
374            #[cfg(feature = "database")]
375            if let Some(pool) = state.database.as_ref().and_then(|db| db.pool()) {
376                if let Err(e) = store_semantic_incident(pool, &incident).await {
377                    tracing::warn!("Failed to store semantic incident in database: {}", e);
378                }
379            }
380
381            return Ok(Json(serde_json::json!({
382                "success": true,
383                "semantic_drift_detected": true,
384                "incident_id": incident.id,
385                "semantic_confidence": result.semantic_confidence,
386                "soft_breaking_score": result.soft_breaking_score,
387                "change_type": format!("{:?}", result.change_type),
388            })));
389        }
390    }
391
392    Ok(Json(serde_json::json!({
393        "success": true,
394        "semantic_drift_detected": false,
395        "message": "No significant semantic drift detected"
396    })))
397}
398
399/// Store semantic incident in database
400#[cfg(feature = "database")]
401async fn store_semantic_incident(
402    pool: &sqlx::PgPool,
403    incident: &SemanticIncident,
404) -> Result<(), sqlx::Error> {
405    let id = Uuid::parse_str(&incident.id).unwrap_or_else(|_| Uuid::new_v4());
406    let workspace_uuid = incident.workspace_id.as_ref().and_then(|id| Uuid::parse_str(id).ok());
407    let related_uuid = incident
408        .related_drift_incident_id
409        .as_ref()
410        .and_then(|id| Uuid::parse_str(id).ok());
411
412    sqlx::query(
413        r#"
414        INSERT INTO semantic_drift_incidents (
415            id, workspace_id, endpoint, method, semantic_change_type, severity, status,
416            semantic_confidence, soft_breaking_score, llm_analysis, before_semantic_state,
417            after_semantic_state, details, related_drift_incident_id, contract_diff_id,
418            external_ticket_id, external_ticket_url, detected_at, created_at, updated_at
419        ) VALUES (
420            $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20
421        )
422        ON CONFLICT (id) DO UPDATE SET
423            status = EXCLUDED.status,
424            acknowledged_at = EXCLUDED.acknowledged_at,
425            resolved_at = EXCLUDED.resolved_at,
426            closed_at = EXCLUDED.closed_at,
427            updated_at = EXCLUDED.updated_at
428        "#,
429    )
430    .bind(id)
431    .bind(workspace_uuid)
432    .bind(&incident.endpoint)
433    .bind(&incident.method)
434    .bind(format!("{:?}", incident.semantic_change_type))
435    .bind(format!("{:?}", incident.severity))
436    .bind(format!("{:?}", incident.status))
437    .bind(incident.semantic_confidence)
438    .bind(incident.soft_breaking_score)
439    .bind(&incident.llm_analysis)
440    .bind(&incident.before_semantic_state)
441    .bind(&incident.after_semantic_state)
442    .bind(&incident.details)
443    .bind(related_uuid)
444    .bind(incident.contract_diff_id.as_deref())
445    .bind(incident.external_ticket_id.as_deref())
446    .bind(incident.external_ticket_url.as_deref())
447    .bind(DateTime::<Utc>::from_timestamp(incident.detected_at, 0).unwrap_or_else(Utc::now))
448    .bind(DateTime::<Utc>::from_timestamp(incident.created_at, 0).unwrap_or_else(Utc::now))
449    .bind(DateTime::<Utc>::from_timestamp(incident.updated_at, 0).unwrap_or_else(Utc::now))
450    .execute(pool)
451    .await?;
452
453    Ok(())
454}
455
456/// Create router for semantic drift endpoints
457pub fn semantic_drift_router(state: SemanticDriftState) -> axum::Router {
458    use axum::routing::{get, post};
459    use axum::Router;
460
461    Router::new()
462        .route("/api/v1/semantic-drift/incidents", get(list_semantic_incidents))
463        .route("/api/v1/semantic-drift/incidents/{id}", get(get_semantic_incident))
464        .route("/api/v1/semantic-drift/analyze", post(analyze_semantic_drift))
465        .with_state(state)
466}