diff --git a/Cargo.lock b/Cargo.lock index 0560f80..f6d2d86 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -788,6 +788,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chrono" version = "0.4.44" @@ -1522,9 +1528,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 5.3.0", "wasip2", + "wasm-bindgen", ] [[package]] @@ -1826,6 +1834,7 @@ dependencies = [ "tokio", "tokio-rustls 0.26.4", "tower-service", + "webpki-roots 1.0.6", ] [[package]] @@ -2248,6 +2257,12 @@ dependencies = [ "hashbrown 0.15.5", ] +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "makeup_artists" version = "0.1.0" @@ -2723,6 +2738,61 @@ dependencies = [ "cc", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls 0.23.37", + "socket2 0.5.10", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash", + "rustls 0.23.37", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.5.10", + "tracing", + "windows-sys 0.52.0", +] + [[package]] name = "quote" version = "1.0.45" @@ -2914,6 +2984,8 @@ dependencies = [ "native-tls", "percent-encoding", "pin-project-lite", + "quinn", + "rustls 0.23.37", "rustls-pki-types", "serde", "serde_json", @@ -2921,6 +2993,7 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-native-tls", + "tokio-rustls 0.26.4", "tokio-util", "tower", "tower-http", @@ -2930,6 +3003,7 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", + "webpki-roots 1.0.6", ] [[package]] @@ -2977,6 +3051,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + [[package]] name = "rustc_version" version = "0.4.1" @@ -3045,6 +3125,7 @@ version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" dependencies = [ + "web-time", "zeroize", ] @@ -4099,6 +4180,7 @@ dependencies = [ "db", "email", "rand 0.8.5", + "reqwest", "serde", "serde_json", "sqlx", @@ -4323,6 +4405,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "webpki-roots" version = "0.26.11" diff --git a/Cargo.toml b/Cargo.toml index dcf1d42..b074324 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,3 +54,4 @@ redis = { version = "0.27", features = ["tokio-comp", "connection-manager"] } async-trait = "0.1" bytes = "1" tower-http = "0.6" +reqwest = { version = "0.12", features = ["json", "rustls-tls"] } diff --git a/apps/users/Cargo.toml b/apps/users/Cargo.toml index 3428eda..f8b84e7 100644 --- a/apps/users/Cargo.toml +++ b/apps/users/Cargo.toml @@ -20,4 +20,5 @@ contracts = { path = "../../crates/contracts" } cache = { path = "../../crates/cache" } rand = "0.8" anyhow = { workspace = true } +reqwest = { workspace = true } diff --git a/apps/users/src/handlers/ai.rs b/apps/users/src/handlers/ai.rs new file mode 100644 index 0000000..94d2b37 --- /dev/null +++ b/apps/users/src/handlers/ai.rs @@ -0,0 +1,352 @@ +use crate::AppState; +use axum::{ + extract::{Query, State}, + http::StatusCode, + response::IntoResponse, + routing::{get, post}, + Json, Router, +}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +pub fn ai_router() -> Router { + Router::new() + .route("/chat/message", post(ai_chat_message)) + .route("/tickets/create", post(ai_create_ticket)) + .route("/tickets/:id", get(ai_get_ticket)) + .route("/forms/extract", post(ai_extract_form)) +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct OllamaChatRequest { + pub model: Option, + pub message: String, + pub conversation_id: Option, + pub user_id: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct OllamaChatResponse { + pub message: String, + pub conversation_id: String, + pub intent: String, + pub confidence: f32, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +struct OllamaGenerateRequest { + model: String, + prompt: String, + stream: bool, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +struct OllamaGenerateResponse { + response: String, +} + +async fn call_ollama(state: &AppState, model: &str, prompt: &str) -> Result { + let base_url = std::env::var("OLLAMA_BASE_URL").unwrap_or_else(|_| "http://localhost:11434".to_string()); + let url = format!("{}/api/generate", base_url); + + let req = OllamaGenerateRequest { + model: model.to_string(), + prompt: prompt.to_string(), + stream: false, + }; + + let client = reqwest::Client::new(); + let response = client + .post(&url) + .json(&req) + .send() + .await + .map_err(|e| format!("ollama request failed: {}", e))?; + + if !response.status().is_success() { + return Err(format!("ollama returned status: {}", response.status())); + } + + let result: OllamaGenerateResponse = response + .json() + .await + .map_err(|e| format!("failed to parse ollama response: {}", e))?; + + Ok(result.response) +} + +async fn classify_intent(message: &str, ollama_base: &str, model: &str) -> (String, f32) { + let prompt = format!( + "Classify this user message into one intent category. Categories: ticket_creation, form_filling, help_search, general. \ + Return ONLY the intent name, nothing else.\n\nMessage: {}", + message + ); + + match call_ollama_inline(ollama_base, model, &prompt).await { + Ok(response) => { + let intent = response.trim().to_lowercase(); + let confidence = if intent.is_empty() { 0.5 } else { 0.85 }; + let intent = match intent.as_str() { + "ticket_creation" => "ticket_creation", + "form_filling" => "form_filling", + "help_search" => "help_search", + _ => "general", + }; + (intent.to_string(), confidence) + } + Err(_) => ("general".to_string(), 0.5), + } +} + +async fn call_ollama_inline(base_url: &str, model: &str, prompt: &str) -> Result { + let url = format!("{}/api/generate", base_url); + let req = OllamaGenerateRequest { + model: model.to_string(), + prompt: prompt.to_string(), + stream: false, + }; + + let client = reqwest::Client::new(); + let response = client + .post(&url) + .json(&req) + .send() + .await + .map_err(|e| format!("ollama request failed: {}", e))?; + + if !response.status().is_success() { + return Err(format!("ollama returned status: {}", response.status())); + } + + let result: OllamaGenerateResponse = response + .json() + .await + .map_err(|e| format!("failed to parse ollama response: {}", e))?; + + Ok(result.response) +} + +async fn ai_chat_message( + State(state): State, + Json(body): Json, +) -> impl IntoResponse { + let ollama_base = std::env::var("OLLAMA_BASE_URL").unwrap_or_else(|_| "http://localhost:11434".to_string()); + let model = std::env::var("OLLAMA_CHAT_MODEL").unwrap_or_else(|_| "smollm2:360m".to_string()); + let default_conversation = Uuid::new_v4().to_string(); + + let conversation_id = body.conversation_id.unwrap_or_else(|| default_conversation); + + let (intent, confidence) = classify_intent(&body.message, &ollama_base, &model).await; + + let system_prompt = match intent.as_str() { + "ticket_creation" => { + "You are a support ticket assistant. Help users create clear, actionable support tickets. \ + Ask for: subject, description of issue, category, priority if not provided. \ + Summarize the ticket in a structured way." + } + "form_filling" => { + "You are a form filling assistant. Help users fill out forms by extracting relevant information \ + from their message. Extract key:value pairs when possible." + } + "help_search" => { + "You are a help center assistant. Help users find relevant help articles based on their query. \ + Ask clarifying questions to narrow down the search." + } + _ => { + "You are a helpful AI assistant for Nxtgauge platform. Provide clear, concise responses. \ + If the user needs support, guide them to create a ticket." + } + }; + + let full_prompt = format!("{}\n\nUser: {}\nAssistant:", system_prompt, body.message); + + let response_text = match call_ollama(&state, &model, &full_prompt).await { + Ok(r) => r, + Err(e) => { + tracing::error!("Ollama error: {}", e); + "I'm having trouble processing your request right now. Please try again or contact support.".to_string() + } + }; + + ( + StatusCode::OK, + Json(OllamaChatResponse { + message: response_text, + conversation_id, + intent, + confidence, + }), + ) + .into_response() +} + +async fn ai_create_ticket( + State(state): State, + Json(body): Json, +) -> impl IntoResponse { + let subject = body.get("subject").and_then(|v| v.as_str()).unwrap_or("AI Assisted Request"); + let description = body.get("description").and_then(|v| v.as_str()); + let category = body.get("category").and_then(|v| v.as_str()).unwrap_or("ai_assisted"); + let priority = body.get("priority").and_then(|v| v.as_str()).unwrap_or("medium"); + let user_id = body.get("user_id").and_then(|v| v.as_str()) + .and_then(|s| Uuid::parse_str(s).ok()) + .unwrap_or_else(Uuid::nil); + + let result = sqlx::query_as::<_, TicketRow>( + r#" + INSERT INTO support_tickets (user_id, subject, description, category, priority, status) + VALUES ($1, $2, $3, $4, $5, 'new') + RETURNING id, subject, description, category, priority, status, + requester_name, requester_email, assigned_to, created_at, updated_at + "#, + ) + .bind(user_id) + .bind(subject) + .bind(description) + .bind(category) + .bind(priority) + .fetch_one(&state.pool) + .await; + + match result { + Ok(r) => ( + StatusCode::CREATED, + Json(serde_json::json!({ + "id": r.id, + "subject": r.subject, + "status": r.status, + "ticket_id": r.id, + })), + ) + .into_response(), + Err(e) => { + tracing::error!("AI ticket creation failed: {}", e); + (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Failed to create ticket" }))).into_response() + } + } +} + +async fn ai_get_ticket( + State(state): State, + axum::extract::Path(id): axum::extract::Path, +) -> impl IntoResponse { + let result = sqlx::query_as::<_, TicketRow>( + r#" + SELECT id, subject, description, category, priority, status, + requester_name, requester_email, assigned_to, created_at, updated_at + FROM support_tickets WHERE id = $1 + "#, + ) + .bind(id) + .fetch_optional(&state.pool) + .await; + + match result { + Ok(Some(r)) => ( + StatusCode::OK, + Json(serde_json::json!({ + "id": r.id, + "subject": r.subject, + "description": r.description, + "category": r.category, + "priority": r.priority, + "status": r.status, + "requester_name": r.requester_name, + "requester_email": r.requester_email, + "assigned_to": r.assigned_to, + "created_at": r.created_at, + "updated_at": r.updated_at, + })), + ) + .into_response(), + Ok(None) => (StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "Ticket not found" }))).into_response(), + Err(e) => { + tracing::error!("Failed to fetch ticket {}: {}", id, e); + (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Failed to fetch ticket" }))).into_response() + } + } +} + +#[derive(Debug, Deserialize)] +struct FormExtractBody { + message: String, + form_type: Option, +} + +#[derive(Debug, Serialize)] +struct FormExtractResponse { + fields: Vec, + missing_fields: Vec, + confidence: f32, +} + +#[derive(Debug, Serialize)] +struct ExtractedField { + key: String, + value: String, + confidence: f32, +} + +async fn ai_extract_form( + State(state): State, + Json(body): Json, +) -> impl IntoResponse { + let ollama_base = std::env::var("OLLAMA_BASE_URL").unwrap_or_else(|_| "http://localhost:11434".to_string()); + let model = std::env::var("OLLAMA_CHAT_MODEL").unwrap_or_else(|_| "smollm2:360m".to_string()); + + let form_type = body.form_type.unwrap_or_else(|| "generic".to_string()); + + let prompt = format!( + "Extract key:value pairs from this message for a {} form. \ + Return ONLY a JSON object with the fields you can identify. \ + Use camelCase for field names.\n\nMessage: {}", + form_type, body.message + ); + + let response_text = match call_ollama_inline(&ollama_base, &model, &prompt).await { + Ok(r) => r, + Err(e) => { + tracing::error!("Ollama form extraction error: {}", e); + return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Form extraction failed" }))).into_response(); + } + }; + + let extracted: serde_json::Value = serde_json::from_str(&response_text) + .unwrap_or_else(|_| serde_json::json!({})); + + let mut fields = Vec::new(); + let mut missing_fields = Vec::new(); + + if let Some(obj) = extracted.as_object() { + for (key, value) in obj { + fields.push(ExtractedField { + key: key.clone(), + value: value.to_string(), + confidence: 0.8, + }); + } + } + + let confidence = if fields.is_empty() { 0.3 } else { 0.75 }; + + (StatusCode::OK, Json(FormExtractResponse { + fields, + missing_fields, + confidence, + })).into_response() +} + +#[derive(sqlx::FromRow)] +struct TicketRow { + id: Uuid, + subject: String, + description: Option, + category: String, + priority: String, + status: String, + requester_name: Option, + requester_email: Option, + assigned_to: Option, + created_at: chrono::DateTime, + updated_at: chrono::DateTime, +} \ No newline at end of file diff --git a/apps/users/src/handlers/mod.rs b/apps/users/src/handlers/mod.rs index ed46976..b606d8f 100644 --- a/apps/users/src/handlers/mod.rs +++ b/apps/users/src/handlers/mod.rs @@ -3,6 +3,7 @@ pub mod admin_email; pub mod activity_logs; pub mod approvals; pub mod auth; +pub mod ai; pub mod config; pub mod coupons; pub mod dashboard; diff --git a/apps/users/src/handlers/support.rs b/apps/users/src/handlers/support.rs index 32d48c0..d7151b4 100644 --- a/apps/users/src/handlers/support.rs +++ b/apps/users/src/handlers/support.rs @@ -18,6 +18,7 @@ pub fn user_router() -> Router { .route("/", post(user_create_ticket).get(user_list_tickets)) .route("/{id}", get(user_get_ticket)) .route("/{id}/messages", post(user_add_message)) + .route("/ai/create", post(ai_create_ticket)) } /// Admin support routes @@ -92,6 +93,61 @@ struct MessageRow { created_at: chrono::DateTime, } +// ── AI Service: create ticket (no user auth required) ──────────────────────── + +#[derive(Deserialize)] +struct AiCreateTicketBody { + subject: String, + description: Option, + category: Option, + priority: Option, + #[serde(rename = "userId")] + user_id: Option, +} + +async fn ai_create_ticket( + State(state): State, + axum::extract::Json(body): axum::extract::Json, +) -> impl IntoResponse { + let user_id = body.user_id.unwrap_or_else(|| Uuid::nil()); + let category = body.category.clone().unwrap_or_else(|| "ai_assisted".to_string()); + let priority = body.priority.clone().unwrap_or_else(|| "medium".to_string()); + + let result = sqlx::query_as::<_, TicketRow>( + r#" + INSERT INTO support_tickets (user_id, subject, description, category, priority, status) + VALUES ($1, $2, $3, $4, $5, 'new') + RETURNING id, subject, description, category, priority, status, + requester_name, requester_email, assigned_to, created_at, updated_at + "#, + ) + .bind(user_id) + .bind(&body.subject) + .bind(&body.description) + .bind(&category) + .bind(&priority) + .fetch_one(&state.pool) + .await; + + match result { + Ok(r) => ( + StatusCode::CREATED, + Json(serde_json::json!({ + "id": r.id, + "subject": r.subject, + "description": r.description, + "category": r.category, + "priority": r.priority, + "status": r.status, + })), + ).into_response(), + Err(e) => { + tracing::error!("AI ticket creation failed: {}", e); + (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Failed to create ticket" }))).into_response() + } + } +} + // ── User: create ticket ─────────────────────────────────────────────────────── #[derive(Deserialize)] diff --git a/apps/users/src/main.rs b/apps/users/src/main.rs index 787875a..f8873db 100644 --- a/apps/users/src/main.rs +++ b/apps/users/src/main.rs @@ -104,6 +104,8 @@ async fn main() { .nest("/api/admin/reports", handlers::pricing::reports_router()) // ── Email Management (admin) ────────────────────────────────────── .nest("/api/admin/email", handlers::admin_email::router()) + // ── AI Assistant ────────────────────────────────────────────────── + .nest("/api/ai", handlers::ai::ai_router()) .route("/health", get(|| async { "Users OK" })) .with_state(state);