1#![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#[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 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, };
71
72 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, };
80
81 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, };
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#[derive(Clone)]
119pub struct SemanticDriftState {
120 pub manager: Arc<SemanticIncidentManager>,
122 #[cfg(feature = "database")]
124 pub database: Option<Database>,
125}
126
127impl SemanticDriftState {
128 pub fn new(manager: Arc<SemanticIncidentManager>) -> Self {
132 Self {
133 manager,
134 #[cfg(feature = "database")]
135 database: None,
136 }
137 }
138
139 #[cfg(feature = "database")]
141 pub fn with_database(mut self, database: Option<Database>) -> Self {
142 self.database = database;
143 self
144 }
145}
146
147#[derive(Debug, Deserialize)]
149pub struct ListSemanticIncidentsQuery {
150 pub workspace_id: Option<String>,
152 pub endpoint: Option<String>,
154 pub method: Option<String>,
156 pub status: Option<String>,
158 pub limit: Option<usize>,
160}
161
162#[derive(Debug, Serialize)]
164pub struct SemanticIncidentListResponse {
165 pub incidents: Vec<SemanticIncident>,
167 pub total: usize,
169}
170
171pub async fn list_semantic_incidents(
175 State(state): State<SemanticDriftState>,
176 Query(params): Query<ListSemanticIncidentsQuery>,
177) -> Result<Json<SemanticIncidentListResponse>, StatusCode> {
178 #[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 let _ = bind_index; let mut q = sqlx::query(&query);
219 if let Some(ws_id) = ¶ms.workspace_id {
220 q = q.bind(ws_id);
221 }
222 if let Some(ep) = ¶ms.endpoint {
223 q = q.bind(ep);
224 }
225 if let Some(m) = ¶ms.method {
226 q = q.bind(m);
227 }
228 if let Some(s) = ¶ms.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 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 }
255
256 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
282pub async fn get_semantic_incident(
286 State(state): State<SemanticDriftState>,
287 Path(id): Path<String>,
288) -> Result<Json<SemanticIncident>, StatusCode> {
289 #[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 }
308 }
309 }
310 }
311
312 match state.manager.get_incident(&id).await {
314 Some(incident) => Ok(Json(incident)),
315 None => Err(StatusCode::NOT_FOUND),
316 }
317}
318
319#[derive(Debug, Deserialize)]
321pub struct AnalyzeSemanticDriftRequest {
322 pub before_spec: String,
324 pub after_spec: String,
326 pub endpoint: String,
328 pub method: String,
330 pub workspace_id: Option<String>,
332}
333
334pub async fn analyze_semantic_drift(
338 State(state): State<SemanticDriftState>,
339 Json(request): Json<AnalyzeSemanticDriftRequest>,
340) -> Result<Json<serde_json::Value>, StatusCode> {
341 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 let config = ContractDiffConfig::default();
349 let analyzer =
350 ContractDiffAnalyzer::new(config).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
351
352 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 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, None, )
371 .await;
372
373 #[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#[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
456pub 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}