1use axum::{
6 extract::{Path, Query, State},
7 http::StatusCode,
8 response::Json,
9};
10use mockforge_core::ai_contract_diff::{ContractDiffAnalyzer, ContractDiffConfig};
11use mockforge_core::incidents::semantic_manager::{SemanticIncident, SemanticIncidentManager};
12use mockforge_core::openapi::OpenApiSpec;
13use serde::{Deserialize, Serialize};
14use std::sync::Arc;
15
16#[cfg(feature = "database")]
17use chrono::{DateTime, Utc};
18#[cfg(feature = "database")]
19use uuid::Uuid;
20
21use crate::database::Database;
22
23#[cfg(feature = "database")]
25fn map_row_to_semantic_incident(
26 row: &sqlx::postgres::PgRow,
27) -> Result<SemanticIncident, sqlx::Error> {
28 use mockforge_core::ai_contract_diff::semantic_analyzer::SemanticChangeType;
29 use mockforge_core::incidents::types::{IncidentSeverity, IncidentStatus};
30 use sqlx::Row;
31
32 let id: uuid::Uuid = row.try_get("id")?;
33 let workspace_id: Option<uuid::Uuid> = row.try_get("workspace_id").ok();
34 let endpoint: String = row.try_get("endpoint")?;
35 let method: String = row.try_get("method")?;
36 let semantic_change_type_str: String = row.try_get("semantic_change_type")?;
37 let severity_str: String = row.try_get("severity")?;
38 let status_str: String = row.try_get("status")?;
39 let semantic_confidence: f64 = row.try_get("semantic_confidence")?;
40 let soft_breaking_score: f64 = row.try_get("soft_breaking_score")?;
41 let llm_analysis: serde_json::Value = row.try_get("llm_analysis").unwrap_or_default();
42 let before_semantic_state: serde_json::Value =
43 row.try_get("before_semantic_state").unwrap_or_default();
44 let after_semantic_state: serde_json::Value =
45 row.try_get("after_semantic_state").unwrap_or_default();
46 let details_json: serde_json::Value = row.try_get("details").unwrap_or_default();
47 let related_drift_incident_id: Option<uuid::Uuid> =
48 row.try_get("related_drift_incident_id").ok();
49 let contract_diff_id: Option<String> = row.try_get("contract_diff_id").ok();
50 let external_ticket_id: Option<String> = row.try_get("external_ticket_id").ok();
51 let external_ticket_url: Option<String> = row.try_get("external_ticket_url").ok();
52 let detected_at: DateTime<Utc> = row.try_get("detected_at")?;
53 let created_at: DateTime<Utc> = row.try_get("created_at")?;
54 let acknowledged_at: Option<DateTime<Utc>> = row.try_get("acknowledged_at").ok();
55 let resolved_at: Option<DateTime<Utc>> = row.try_get("resolved_at").ok();
56 let closed_at: Option<DateTime<Utc>> = row.try_get("closed_at").ok();
57 let updated_at: DateTime<Utc> = row.try_get("updated_at")?;
58
59 let semantic_change_type = match semantic_change_type_str.as_str() {
61 "description_change" => SemanticChangeType::DescriptionChange,
62 "enum_narrowing" => SemanticChangeType::EnumNarrowing,
63 "nullability_change" => SemanticChangeType::NullableChange,
64 "error_code_removed" => SemanticChangeType::ErrorCodeRemoved,
65 "meaning_shift" => SemanticChangeType::MeaningShift,
66 _ => SemanticChangeType::MeaningShift, };
68
69 let severity = match severity_str.as_str() {
71 "low" => IncidentSeverity::Low,
72 "medium" => IncidentSeverity::Medium,
73 "high" => IncidentSeverity::High,
74 "critical" => IncidentSeverity::Critical,
75 _ => IncidentSeverity::Medium, };
77
78 let status = match status_str.as_str() {
80 "open" => IncidentStatus::Open,
81 "acknowledged" => IncidentStatus::Acknowledged,
82 "resolved" => IncidentStatus::Resolved,
83 "closed" => IncidentStatus::Closed,
84 _ => IncidentStatus::Open, };
86
87 Ok(SemanticIncident {
88 id: id.to_string(),
89 workspace_id: workspace_id.map(|u| u.to_string()),
90 endpoint,
91 method,
92 semantic_change_type,
93 severity,
94 status,
95 semantic_confidence,
96 soft_breaking_score,
97 llm_analysis,
98 before_semantic_state,
99 after_semantic_state,
100 details: details_json,
101 related_drift_incident_id: related_drift_incident_id.map(|u| u.to_string()),
102 contract_diff_id,
103 external_ticket_id,
104 external_ticket_url,
105 detected_at: detected_at.timestamp(),
106 created_at: created_at.timestamp(),
107 acknowledged_at: acknowledged_at.map(|dt| dt.timestamp()),
108 resolved_at: resolved_at.map(|dt| dt.timestamp()),
109 closed_at: closed_at.map(|dt| dt.timestamp()),
110 updated_at: updated_at.timestamp(),
111 })
112}
113
114#[derive(Clone)]
116pub struct SemanticDriftState {
117 pub manager: Arc<SemanticIncidentManager>,
119 pub database: Option<Database>,
121}
122
123#[derive(Debug, Deserialize)]
125pub struct ListSemanticIncidentsQuery {
126 pub workspace_id: Option<String>,
128 pub endpoint: Option<String>,
130 pub method: Option<String>,
132 pub status: Option<String>,
134 pub limit: Option<usize>,
136}
137
138#[derive(Debug, Serialize)]
140pub struct SemanticIncidentListResponse {
141 pub incidents: Vec<SemanticIncident>,
143 pub total: usize,
145}
146
147pub async fn list_semantic_incidents(
151 State(state): State<SemanticDriftState>,
152 Query(params): Query<ListSemanticIncidentsQuery>,
153) -> Result<Json<SemanticIncidentListResponse>, StatusCode> {
154 #[cfg(feature = "database")]
156 if let Some(pool) = state.database.as_ref().and_then(|db| db.pool()) {
157 let mut query = String::from(
158 "SELECT id, workspace_id, endpoint, method, semantic_change_type, severity, status,
159 semantic_confidence, soft_breaking_score, llm_analysis, before_semantic_state,
160 after_semantic_state, details, related_drift_incident_id, contract_diff_id,
161 external_ticket_id, external_ticket_url, detected_at, created_at, acknowledged_at,
162 resolved_at, closed_at, updated_at
163 FROM semantic_drift_incidents WHERE 1=1",
164 );
165
166 let mut bind_index = 1;
167
168 if let Some(ws_id) = ¶ms.workspace_id {
169 query.push_str(&format!(" AND workspace_id = ${}", bind_index));
170 bind_index += 1;
171 }
172
173 if let Some(ep) = ¶ms.endpoint {
174 query.push_str(&format!(" AND endpoint = ${}", bind_index));
175 bind_index += 1;
176 }
177
178 if let Some(m) = ¶ms.method {
179 query.push_str(&format!(" AND method = ${}", bind_index));
180 bind_index += 1;
181 }
182
183 if let Some(status_str) = ¶ms.status {
184 query.push_str(&format!(" AND status = ${}", bind_index));
185 bind_index += 1;
186 }
187
188 let limit = params.limit.unwrap_or(100);
189 query.push_str(&format!(" ORDER BY detected_at DESC LIMIT {}", limit));
190
191 let rows = sqlx::query(&query).fetch_all(pool).await.map_err(|e| {
193 tracing::error!("Failed to query semantic incidents: {}", e);
194 StatusCode::INTERNAL_SERVER_ERROR
195 })?;
196
197 let mut incidents = Vec::new();
199 for row in rows {
200 match map_row_to_semantic_incident(&row) {
201 Ok(incident) => incidents.push(incident),
202 Err(e) => {
203 tracing::warn!("Failed to map semantic incident row: {}", e);
204 continue;
205 }
206 }
207 }
208 if !incidents.is_empty() {
209 return Ok(Json(SemanticIncidentListResponse {
210 total: incidents.len(),
211 incidents,
212 }));
213 }
214 }
216
217 let status = params.status.as_deref().and_then(|s| match s {
219 "open" => Some(mockforge_core::incidents::types::IncidentStatus::Open),
220 "acknowledged" => Some(mockforge_core::incidents::types::IncidentStatus::Acknowledged),
221 "resolved" => Some(mockforge_core::incidents::types::IncidentStatus::Resolved),
222 "closed" => Some(mockforge_core::incidents::types::IncidentStatus::Closed),
223 _ => None,
224 });
225
226 let incidents = state
227 .manager
228 .list_incidents(
229 params.workspace_id.as_deref(),
230 params.endpoint.as_deref(),
231 params.method.as_deref(),
232 status,
233 params.limit,
234 )
235 .await;
236
237 Ok(Json(SemanticIncidentListResponse {
238 total: incidents.len(),
239 incidents,
240 }))
241}
242
243pub async fn get_semantic_incident(
247 State(state): State<SemanticDriftState>,
248 Path(id): Path<String>,
249) -> Result<Json<SemanticIncident>, StatusCode> {
250 #[cfg(feature = "database")]
252 if let Some(pool) = state.database.as_ref().and_then(|db| db.pool()) {
253 let row = sqlx::query("SELECT * FROM semantic_drift_incidents WHERE id = $1")
254 .bind(&id)
255 .fetch_optional(pool)
256 .await
257 .map_err(|e| {
258 tracing::error!("Failed to query semantic incident: {}", e);
259 StatusCode::INTERNAL_SERVER_ERROR
260 })?;
261
262 if let Some(row) = row {
263 match map_row_to_semantic_incident(&row) {
264 Ok(incident) => return Ok(Json(incident)),
265 Err(e) => {
266 tracing::warn!("Failed to map semantic incident: {}", e);
267 }
269 }
270 }
271 }
272
273 match state.manager.get_incident(&id).await {
275 Some(incident) => Ok(Json(incident)),
276 None => Err(StatusCode::NOT_FOUND),
277 }
278}
279
280#[derive(Debug, Deserialize)]
282pub struct AnalyzeSemanticDriftRequest {
283 pub before_spec: String,
285 pub after_spec: String,
287 pub endpoint: String,
289 pub method: String,
291 pub workspace_id: Option<String>,
293}
294
295pub async fn analyze_semantic_drift(
299 State(state): State<SemanticDriftState>,
300 Json(request): Json<AnalyzeSemanticDriftRequest>,
301) -> Result<Json<serde_json::Value>, StatusCode> {
302 let before_spec = OpenApiSpec::from_string(&request.before_spec, None)
304 .map_err(|_| StatusCode::BAD_REQUEST)?;
305 let after_spec =
306 OpenApiSpec::from_string(&request.after_spec, None).map_err(|_| StatusCode::BAD_REQUEST)?;
307
308 let config = ContractDiffConfig::default();
310 let analyzer =
311 ContractDiffAnalyzer::new(config).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
312
313 let semantic_result = analyzer
315 .compare_specs(&before_spec, &after_spec, &request.endpoint, &request.method)
316 .await
317 .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
318
319 if let Some(result) = semantic_result {
320 if result.semantic_confidence >= 0.65 {
322 let incident = state
323 .manager
324 .create_incident(
325 &result,
326 request.endpoint.clone(),
327 request.method.clone(),
328 request.workspace_id.clone(),
329 None, None, )
332 .await;
333
334 #[cfg(feature = "database")]
336 if let Some(pool) = state.database.as_ref().and_then(|db| db.pool()) {
337 if let Err(e) = store_semantic_incident(pool, &incident).await {
338 tracing::warn!("Failed to store semantic incident in database: {}", e);
339 }
340 }
341
342 return Ok(Json(serde_json::json!({
343 "success": true,
344 "semantic_drift_detected": true,
345 "incident_id": incident.id,
346 "semantic_confidence": result.semantic_confidence,
347 "soft_breaking_score": result.soft_breaking_score,
348 "change_type": format!("{:?}", result.change_type),
349 })));
350 }
351 }
352
353 Ok(Json(serde_json::json!({
354 "success": true,
355 "semantic_drift_detected": false,
356 "message": "No significant semantic drift detected"
357 })))
358}
359
360#[cfg(feature = "database")]
362async fn store_semantic_incident(
363 pool: &sqlx::PgPool,
364 incident: &SemanticIncident,
365) -> Result<(), sqlx::Error> {
366 let id = Uuid::parse_str(&incident.id).unwrap_or_else(|_| Uuid::new_v4());
367 let workspace_uuid = incident.workspace_id.as_ref().and_then(|id| Uuid::parse_str(id).ok());
368 let related_uuid = incident
369 .related_drift_incident_id
370 .as_ref()
371 .and_then(|id| Uuid::parse_str(id).ok());
372
373 sqlx::query(
374 r#"
375 INSERT INTO semantic_drift_incidents (
376 id, workspace_id, endpoint, method, semantic_change_type, severity, status,
377 semantic_confidence, soft_breaking_score, llm_analysis, before_semantic_state,
378 after_semantic_state, details, related_drift_incident_id, contract_diff_id,
379 external_ticket_id, external_ticket_url, detected_at, created_at, updated_at
380 ) VALUES (
381 $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20
382 )
383 ON CONFLICT (id) DO UPDATE SET
384 status = EXCLUDED.status,
385 acknowledged_at = EXCLUDED.acknowledged_at,
386 resolved_at = EXCLUDED.resolved_at,
387 closed_at = EXCLUDED.closed_at,
388 updated_at = EXCLUDED.updated_at
389 "#,
390 )
391 .bind(id)
392 .bind(workspace_uuid)
393 .bind(&incident.endpoint)
394 .bind(&incident.method)
395 .bind(format!("{:?}", incident.semantic_change_type))
396 .bind(format!("{:?}", incident.severity))
397 .bind(format!("{:?}", incident.status))
398 .bind(incident.semantic_confidence)
399 .bind(incident.soft_breaking_score)
400 .bind(&incident.llm_analysis)
401 .bind(&incident.before_semantic_state)
402 .bind(&incident.after_semantic_state)
403 .bind(&incident.details)
404 .bind(related_uuid)
405 .bind(incident.contract_diff_id.as_deref())
406 .bind(incident.external_ticket_id.as_deref())
407 .bind(incident.external_ticket_url.as_deref())
408 .bind(DateTime::<Utc>::from_timestamp(incident.detected_at, 0).unwrap_or_else(Utc::now))
409 .bind(DateTime::<Utc>::from_timestamp(incident.created_at, 0).unwrap_or_else(Utc::now))
410 .bind(DateTime::<Utc>::from_timestamp(incident.updated_at, 0).unwrap_or_else(Utc::now))
411 .execute(pool)
412 .await?;
413
414 Ok(())
415}
416
417pub fn semantic_drift_router(state: SemanticDriftState) -> axum::Router {
419 use axum::routing::{get, post};
420 use axum::Router;
421
422 Router::new()
423 .route("/api/v1/semantic-drift/incidents", get(list_semantic_incidents))
424 .route("/api/v1/semantic-drift/incidents/{id}", get(get_semantic_incident))
425 .route("/api/v1/semantic-drift/analyze", post(analyze_semantic_drift))
426 .with_state(state)
427}