use crate::AppState; use axum::{ extract::State, http::StatusCode, response::IntoResponse, routing::{get, post}, Json, Router, }; use cache::ai as ai_cache; use contracts::auth_middleware::AuthUser; use serde::{Deserialize, Serialize}; use std::sync::Arc; use uuid::Uuid; #[derive(sqlx::FromRow)] struct KbArticleRow { id: Uuid, title: String, slug: String, summary: Option, category_name: String, } 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)) .route("/generate-job-field", post(ai_generate_job_field)) .route("/generate-cover-letter", post(ai_generate_cover_letter)) .route("/tailor-resume", post(ai_tailor_resume)) .route("/auto-apply", post(ai_auto_apply)) .route("/auto-respond-to-lead", post(ai_auto_respond_to_lead)) .route("/usage", get(ai_usage_status)) } #[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://ollama.nxtgauge-ai.svc.cluster.local: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, job_description_generation, 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", "job_description_generation" => "job_description_generation", _ => "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://ollama.nxtgauge-ai.svc.cluster.local:11434".to_string()); let model = std::env::var("OLLAMA_CHAT_MODEL").unwrap_or_else(|_| "gemma3:270m".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 response_text = match intent.as_str() { "help_search" => { let q = body.message.to_lowercase(); let rows = sqlx::query_as::<_, KbArticleRow>( r#" SELECT a.id, a.title, a.slug, a.summary, c.name AS category_name FROM kb_articles a JOIN kb_categories c ON c.id = a.category_id WHERE a.status = 'PUBLISHED' AND c.is_active = true AND (LOWER(a.title) LIKE '%' || $1 || '%' OR LOWER(COALESCE(a.summary, '')) LIKE '%' || $1 || '%') ORDER BY a.updated_at DESC LIMIT 5 "#, ) .bind(&q) .fetch_all(&state.pool) .await; match rows { Ok(articles) if !articles.is_empty() => { let links: Vec = articles .iter() .map(|a| { format!( "- **{}** ({})\n {}\n /help-center/article/{}", a.title, a.category_name, a.summary.as_deref().unwrap_or(""), a.slug ) }) .collect(); format!( "I found {} help article(s) for you:\n\n{}\n\nIs any of these what you were looking for?", articles.len(), links.join("\n\n") ) } _ => { "I couldn't find any help articles matching your question. \ Try rephrasing or contact support if you need further assistance." .to_string() } } } "job_description_generation" => { let jd_prompt = format!( "Generate a professional job description with the following sections: \ **Job Title**, **Summary**, **Key Responsibilities**, **Required Skills & Qualifications**, \ **Preferred Qualifications**, **What We Offer**. \ Format each section clearly with bullet points where appropriate.\n\n\ User's request: {}\n\n\ Job Description:", body.message ); match call_ollama(&state, &model, &jd_prompt).await { Ok(r) => r, Err(e) => { tracing::error!("Ollama JD generation error: {}", e); "I'm having trouble generating a job description right now. Please try again.".to_string() } } } "ticket_creation" => { let system_prompt = "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."; let full_prompt = format!("{}\n\nUser: {}\nAssistant:", system_prompt, body.message); 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() } } } "form_filling" => { let system_prompt = "You are a form filling assistant. Help users fill out forms by extracting relevant information \ from their message. Extract key:value pairs when possible."; let full_prompt = format!("{}\n\nUser: {}\nAssistant:", system_prompt, body.message); 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() } } } _ => { let system_prompt = "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); 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://ollama.nxtgauge-ai.svc.cluster.local:11434".to_string()); let model = std::env::var("OLLAMA_CHAT_MODEL").unwrap_or_else(|_| "gemma3:270m".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 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, } // ── AI Pack & Rate Limit Helpers ──────────────────────────────────────────────── const BASE_AI_LIMIT: i32 = 5; fn get_ai_limit_for_package(features: &serde_json::Value) -> i32 { features .get("ai_generations_per_day") .and_then(|v| v.as_i64()) .map(|v| v as i32) .unwrap_or(BASE_AI_LIMIT) } async fn has_active_ai_pack( pool: &sqlx::PgPool, user_role_profile_id: Uuid, role_key: &str, ) -> (bool, i32) { let now = chrono::Utc::now(); let result = sqlx::query_as::<_, (Option,)>( r#" SELECT pp.features FROM pricing_packages pp JOIN payments p ON p.package_id = pp.id WHERE pp.package_type = 'AI_PACK' AND pp.is_active = true AND p.user_role_profile_id = $1 AND $2 = ANY(pp.applicable_roles) AND p.tracecoins_credited > 0 AND (pp.valid_from IS NULL OR pp.valid_from <= $3) AND (pp.valid_until IS NULL OR pp.valid_until >= $3) ORDER BY p.created_at DESC LIMIT 1 "#, ) .bind(user_role_profile_id) .bind(role_key) .bind(now) .fetch_optional(pool) .await; match result { Ok(Some((Some(features),))) => { let limit = get_ai_limit_for_package(&features); (true, limit) } _ => (false, BASE_AI_LIMIT), } } async fn check_and_increment_usage( pool: &sqlx::PgPool, redis: &mut cache::RedisPool, profile_id: Uuid, is_company: bool, daily_limit: i32, ) -> Result<(i32, i32), String> { let user_id_str = profile_id.to_string(); // Fast path: check Redis first for rate limiting let redis_allowed = ai_cache::check_ai_rate_limit(redis, &user_id_str, daily_limit as i64) .await .map_err(|e| e.to_string())?; if !redis_allowed { return Err("Daily AI generation limit reached".to_string()); } // DB is source of truth - check and increment let today = chrono::Utc::now().date_naive(); let table = if is_company { "company_ai_usage" } else { "job_seeker_ai_usage" }; let id_col = if is_company { "company_id" } else { "job_seeker_id" }; let current: Option = sqlx::query_scalar(&format!( "SELECT generations_used FROM {} WHERE {} = $1 AND usage_date = $2", table, id_col )) .bind(profile_id) .bind(today) .fetch_optional(pool) .await .map_err(|e| e.to_string())?; let used = current.unwrap_or(0); if used >= daily_limit { return Err("Daily AI generation limit reached".to_string()); } sqlx::query(&format!( r#" INSERT INTO {} ({} , usage_date, generations_used) VALUES ($1, $2, 1) ON CONFLICT ({}, usage_date) DO UPDATE SET generations_used = {}.generations_used + 1, updated_at = NOW() "#, table, id_col, id_col, table )) .bind(profile_id) .bind(today) .execute(pool) .await .map_err(|e| e.to_string())?; Ok((used + 1, daily_limit)) } // ── Job Field Generation (Companies) ────────────────────────────────────────── #[derive(Debug, Deserialize)] struct GenerateJobFieldBody { field: String, context: String, } #[derive(Debug, Serialize)] struct GenerateFieldResponse { generated_text: String, remaining_today: i32, daily_limit: i32, has_ai_pack: bool, } async fn ai_generate_job_field( State(state): State, auth: AuthUser, Json(body): Json, ) -> impl IntoResponse { let company: Option = sqlx::query_scalar( "SELECT id FROM company_profiles WHERE user_id = $1" ) .bind(auth.user_id) .fetch_optional(&state.pool) .await .map_err(|e| e.to_string()) .ok() .flatten(); let Some(company_id) = company else { return (StatusCode::FORBIDDEN, Json(serde_json::json!({ "error": "No company profile found" }))).into_response(); }; let (has_pack, daily_limit) = { let profile_id: Option = sqlx::query_scalar( "SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = 'COMPANY'" ) .bind(auth.user_id) .fetch_optional(&state.pool) .await .ok() .flatten(); match profile_id { Some(pid) => has_active_ai_pack(&state.pool, pid, "COMPANY").await, None => (false, BASE_AI_LIMIT), } }; let mut redis = state.redis.clone(); let (used, limit) = match check_and_increment_usage(&state.pool, &mut redis, company_id, true, daily_limit).await { Ok((u, l)) => (u, l), Err(msg) => { return (StatusCode::TOO_MANY_REQUESTS, Json(serde_json::json!({ "error": msg }))).into_response(); } }; let ollama_base = std::env::var("OLLAMA_BASE_URL").unwrap_or_else(|_| "http://ollama.nxtgauge-ai.svc.cluster.local:11434".to_string()); let model = std::env::var("OLLAMA_CHAT_MODEL").unwrap_or_else(|_| "gemma3:270m".to_string()); let field_prompt = match body.field.as_str() { "title" => format!( "Generate a concise, engaging job title (max 80 chars) for: {}. \ Only return the title, nothing else.", body.context ), "description" => format!( "Generate a professional job description with sections: **Summary**, **Key Responsibilities**, **Required Skills**, **Preferred Qualifications**, **What We Offer**. \ Use markdown formatting. Based on: {}\n\nJob Description:", body.context ), "skills" => format!( "List 6-10 relevant skills for this role, as a comma-separated string (no descriptions): {}", body.context ), "category" => format!( "Suggest a single job category/department name (max 50 chars) for: {}. Only return the category name.", body.context ), _ => { return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "Invalid field. Use: title, description, skills, category" }))).into_response(); } }; let generated = match call_ollama_inline(&ollama_base, &model, &field_prompt).await { Ok(r) => r.trim().to_string(), Err(e) => { tracing::error!("Ollama job field generation error: {}", e); return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Generation failed" }))).into_response(); } }; ( StatusCode::OK, Json(GenerateFieldResponse { generated_text: generated, remaining_today: limit - used, daily_limit: limit, has_ai_pack: has_pack, }), ).into_response() } // ── Cover Letter Generation (Job Seekers) ────────────────────────────────────── #[derive(Debug, Deserialize)] struct CoverLetterBody { job_id: Uuid, additional_notes: Option, } async fn ai_generate_cover_letter( State(state): State, auth: AuthUser, Json(body): Json, ) -> impl IntoResponse { let seeker: Option<(Uuid, String, Option, i32, Vec)> = sqlx::query_as( "SELECT id, full_name, summary, experience_years, skills FROM job_seeker_profiles WHERE user_id = $1" ) .bind(auth.user_id) .fetch_optional(&state.pool) .await .map_err(|e| e.to_string()) .ok() .and_then(|r| r); let Some((seeker_id, full_name, summary, experience, skills)) = seeker else { return (StatusCode::FORBIDDEN, Json(serde_json::json!({ "error": "No job seeker profile found" }))).into_response(); }; let job: Option<(String, String, String)> = sqlx::query_as( "SELECT title, description, location FROM jobs WHERE id = $1" ) .bind(body.job_id) .fetch_optional(&state.pool) .await .map_err(|e| e.to_string()) .ok() .and_then(|r| r); let Some((job_title, job_desc, location)) = job else { return (StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "Job not found" }))).into_response(); }; let (has_pack, daily_limit) = { let profile_id: Option = sqlx::query_scalar( "SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = 'JOB_SEEKER'" ) .bind(auth.user_id) .fetch_optional(&state.pool) .await .ok() .flatten(); match profile_id { Some(pid) => has_active_ai_pack(&state.pool, pid, "JOB_SEEKER").await, None => (false, BASE_AI_LIMIT), } }; let mut redis = state.redis.clone(); let (used, limit) = match check_and_increment_usage(&state.pool, &mut redis, seeker_id, false, daily_limit).await { Ok((u, l)) => (u, l), Err(msg) => { return (StatusCode::TOO_MANY_REQUESTS, Json(serde_json::json!({ "error": msg }))).into_response(); } }; let ollama_base = std::env::var("OLLAMA_BASE_URL").unwrap_or_else(|_| "http://ollama.nxtgauge-ai.svc.cluster.local:11434".to_string()); let model = std::env::var("OLLAMA_CHAT_MODEL").unwrap_or_else(|_| "gemma3:270m".to_string()); let notes = body.additional_notes.as_deref().unwrap_or(""); let skills_str = skills.join(", "); let prompt = format!( "Write a personalized, professional cover letter for a job application.\n\n\ IMPORTANT: Do NOT include phone number, email, or any contact information. \ Only use the information provided below. Companies pay to view candidate contact details through the platform.\n\n\ CANDIDATE INFO:\n\ Name: {}\n\ Experience: {} years\n\ Summary: {}\n\ Skills: {}\n\ Notes: {}\n\n\ JOB INFO:\n\ Title: {}\n\ Description: {}\n\ Location: {}\n\n\ Write a compelling cover letter that highlights how the candidate's experience and skills match the role. \ Use a professional tone, 3-4 short paragraphs.", full_name, experience, summary.as_deref().unwrap_or("N/A"), skills_str, notes, job_title, job_desc, location ); let generated = match call_ollama_inline(&ollama_base, &model, &prompt).await { Ok(r) => r.trim().to_string(), Err(e) => { tracing::error!("Ollama cover letter generation error: {}", e); return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Generation failed" }))).into_response(); } }; ( StatusCode::OK, Json(GenerateFieldResponse { generated_text: generated, remaining_today: limit - used, daily_limit: limit, has_ai_pack: has_pack, }), ).into_response() } // ── Tailor Resume (Job Seekers) ───────────────────────────────────────────────── #[derive(Debug, Deserialize)] struct TailorResumeBody { job_id: Uuid, resume_text: Option, } async fn ai_tailor_resume( State(state): State, auth: AuthUser, Json(body): Json, ) -> impl IntoResponse { let seeker: Option<(Uuid, String, Option, i32, Vec)> = sqlx::query_as( "SELECT id, full_name, summary, experience_years, skills FROM job_seeker_profiles WHERE user_id = $1" ) .bind(auth.user_id) .fetch_optional(&state.pool) .await .map_err(|e| e.to_string()) .ok() .and_then(|r| r); let Some((seeker_id, full_name, summary, experience, skills)) = seeker else { return (StatusCode::FORBIDDEN, Json(serde_json::json!({ "error": "No job seeker profile found" }))).into_response(); }; let job: Option<(String, String)> = sqlx::query_as( "SELECT title, description FROM jobs WHERE id = $1" ) .bind(body.job_id) .fetch_optional(&state.pool) .await .map_err(|e| e.to_string()) .ok() .and_then(|r| r); let Some((job_title, job_desc)) = job else { return (StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "Job not found" }))).into_response(); }; let (has_pack, daily_limit) = { let profile_id: Option = sqlx::query_scalar( "SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = 'JOB_SEEKER'" ) .bind(auth.user_id) .fetch_optional(&state.pool) .await .ok() .flatten(); match profile_id { Some(pid) => has_active_ai_pack(&state.pool, pid, "JOB_SEEKER").await, None => (false, BASE_AI_LIMIT), } }; let mut redis = state.redis.clone(); let (used, limit) = match check_and_increment_usage(&state.pool, &mut redis, seeker_id, false, daily_limit).await { Ok((u, l)) => (u, l), Err(msg) => { return (StatusCode::TOO_MANY_REQUESTS, Json(serde_json::json!({ "error": msg }))).into_response(); } }; let ollama_base = std::env::var("OLLAMA_BASE_URL").unwrap_or_else(|_| "http://ollama.nxtgauge-ai.svc.cluster.local:11434".to_string()); let model = std::env::var("OLLAMA_CHAT_MODEL").unwrap_or_else(|_| "gemma3:270m".to_string()); let existing_resume = body.resume_text.as_deref().unwrap_or("Not provided"); let skills_str = skills.join(", "); let prompt = format!( "Rewrite the following resume to better match the target job role. \ IMPORTANT: Do NOT add phone number, email, or any contact information. \ Only use the information provided. Companies pay to view candidate contact details through the platform.\n\n\ CANDIDATE:\n\ Name: {}\n\ Experience: {} years\n\ Summary: {}\n\ Skills: {}\n\ Current Resume:\n{}\n\n\ TARGET JOB:\n\ Title: {}\n\ Description: {}\n\n\ Rewrite the resume to emphasize relevant experience and skills for this role. \ Keep the same format (bullet points, sections). Do not add contact info.", full_name, experience, summary.as_deref().unwrap_or("N/A"), skills_str, existing_resume, job_title, job_desc ); let generated = match call_ollama_inline(&ollama_base, &model, &prompt).await { Ok(r) => r.trim().to_string(), Err(e) => { tracing::error!("Ollama resume tailoring error: {}", e); return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Generation failed" }))).into_response(); } }; ( StatusCode::OK, Json(GenerateFieldResponse { generated_text: generated, remaining_today: limit - used, daily_limit: limit, has_ai_pack: has_pack, }), ).into_response() } // ── Auto Apply (Job Seekers) ─────────────────────────────────────────────────── #[derive(Debug, Deserialize)] struct AutoApplyBody { job_ids: Vec, } #[derive(Debug, Serialize)] struct AutoApplyResponse { applications_created: i32, already_applied: Vec, failed: Vec, remaining_today: i32, daily_limit: i32, } async fn ai_auto_apply( State(state): State, auth: AuthUser, Json(body): Json, ) -> impl IntoResponse { if body.job_ids.is_empty() || body.job_ids.len() > 10 { return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "Select 1-10 jobs at a time" }))).into_response(); } let seeker: Option<(Uuid, String, Option, i32, Vec)> = sqlx::query_as( "SELECT id, full_name, summary, experience_years, skills FROM job_seeker_profiles WHERE user_id = $1" ) .bind(auth.user_id) .fetch_optional(&state.pool) .await .map_err(|e| e.to_string()) .ok() .and_then(|r| r); let Some((seeker_id, full_name, summary, experience, skills)) = seeker else { return (StatusCode::FORBIDDEN, Json(serde_json::json!({ "error": "No job seeker profile found" }))).into_response(); }; if full_name.is_empty() || skills.is_empty() { return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "Complete your profile (name and skills required) before auto-applying" }))).into_response(); } let (has_pack, daily_limit) = { let profile_id: Option = sqlx::query_scalar( "SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = 'JOB_SEEKER'" ) .bind(auth.user_id) .fetch_optional(&state.pool) .await .ok() .flatten(); match profile_id { Some(pid) => has_active_ai_pack(&state.pool, pid, "JOB_SEEKER").await, None => (false, BASE_AI_LIMIT), } }; let remaining = daily_limit - { let today = chrono::Utc::now().date_naive(); let used: Option = sqlx::query_scalar( "SELECT generations_used FROM job_seeker_ai_usage WHERE job_seeker_id = $1 AND usage_date = $2" ) .bind(seeker_id) .bind(today) .fetch_optional(&state.pool) .await .ok() .flatten(); used.unwrap_or(0) }; if remaining < body.job_ids.len() as i32 { return (StatusCode::TOO_MANY_REQUESTS, Json(serde_json::json!({ "error": format!("Only {} generations left today", remaining) }))).into_response(); } let ollama_base = std::env::var("OLLAMA_BASE_URL").unwrap_or_else(|_| "http://ollama.nxtgauge-ai.svc.cluster.local:11434".to_string()); let model = std::env::var("OLLAMA_CHAT_MODEL").unwrap_or_else(|_| "gemma3:270m".to_string()); let skills_str = skills.join(", "); let mut created = 0; let mut already = vec![]; let mut failed = vec![]; let mut redis = state.redis.clone(); for job_id in &body.job_ids { let existing: Option = sqlx::query_scalar( "SELECT id FROM job_applications WHERE job_id = $1 AND applicant_user_id = $2" ) .bind(job_id) .bind(auth.user_id) .fetch_optional(&state.pool) .await .ok() .flatten(); if existing.is_some() { already.push(*job_id); continue; } let job: Option<(String, String)> = sqlx::query_as( "SELECT title, description FROM jobs WHERE id = $1" ) .bind(job_id) .fetch_optional(&state.pool) .await .ok() .and_then(|r| r); let Some((job_title, job_desc)) = job else { failed.push(*job_id); continue; }; let cover_prompt = format!( "Write a brief, professional cover letter (max 200 words).\n\n\ IMPORTANT: Do NOT include phone number, email, or any contact information. \ Only use the information provided below.\n\n\ CANDIDATE: Name: {}, Experience: {} years, Skills: {}, Summary: {}\n\ JOB: Title: {}, Description: {}\n\n\ Cover Letter:", full_name, experience, skills_str, summary.as_deref().unwrap_or(""), job_title, job_desc ); let cover_letter = match call_ollama_inline(&ollama_base, &model, &cover_prompt).await { Ok(r) => r.trim().to_string(), Err(_) => "I am excited to apply for this position.".to_string(), }; let result = sqlx::query( r#" INSERT INTO job_applications (job_id, applicant_user_id, cover_letter, applied_via_ai) VALUES ($1, $2, $3, true) ON CONFLICT (job_id, applicant_user_id) DO NOTHING "# ) .bind(job_id) .bind(auth.user_id) .bind(&cover_letter) .execute(&state.pool) .await; match result { Ok(r) => { if r.rows_affected() > 0 { created += 1; let _ = check_and_increment_usage(&state.pool, &mut redis, seeker_id, false, daily_limit).await; } else { already.push(*job_id); } } Err(_) => { failed.push(*job_id); } } } let new_remaining = remaining - created; ( StatusCode::OK, Json(AutoApplyResponse { applications_created: created, already_applied: already, failed, remaining_today: new_remaining.max(0), daily_limit, }), ).into_response() } // ── Auto Respond to Lead (Professionals) ─────────────────────────────────────── #[derive(Debug, Deserialize)] struct AutoRespondToLeadBody { lead_id: Uuid, profession_key: String, } async fn ai_auto_respond_to_lead( State(state): State, auth: AuthUser, Json(body): Json, ) -> impl IntoResponse { let leads_service_url = std::env::var("LEADS_SERVICE_URL") .unwrap_or_else(|_| "http://localhost:9118".to_string()); let profile_id: Option = sqlx::query_scalar( "SELECT id FROM user_role_profiles WHERE user_id = $1" ) .bind(auth.user_id) .fetch_optional(&state.pool) .await .map_err(|e| e.to_string()) .ok() .flatten(); let Some(profile_id) = profile_id else { return (StatusCode::FORBIDDEN, Json(serde_json::json!({ "error": "Profile not found" }))).into_response(); }; let today = chrono::Utc::now().date_naive(); let used: Option = sqlx::query_scalar( "SELECT generations_used FROM job_seeker_ai_usage WHERE job_seeker_id = $1 AND usage_date = $2" ) .bind(profile_id) .bind(today) .fetch_optional(&state.pool) .await .ok() .flatten(); let daily_limit = 10; if used.unwrap_or(0) >= daily_limit { return (StatusCode::TOO_MANY_REQUESTS, Json(serde_json::json!({ "error": "Daily AI auto-respond limit reached (10/day)" }))).into_response(); } let url = format!("{}/api/lead-requests/send-ai", leads_service_url.trim_end_matches('/')); let client = reqwest::Client::new(); let payload = serde_json::json!({ "lead_id": body.lead_id.to_string(), "user_id": auth.user_id.to_string(), "profession_key": body.profession_key }); let res = client .post(&url) .json(&payload) .send() .await .map_err(|e| e.to_string()); let Ok(res) = res else { return (StatusCode::BAD_GATEWAY, Json(serde_json::json!({ "error": "Failed to reach leads service" }))).into_response(); }; let status = res.status(); if !status.is_success() { let body = res.text().await.unwrap_or_default(); return (StatusCode::from_u16(status.as_u16()).unwrap_or(StatusCode::BAD_GATEWAY), Json(serde_json::json!({ "error": body }))).into_response(); } let _ = sqlx::query( r#" INSERT INTO job_seeker_ai_usage (job_seeker_id, usage_date, generations_used) VALUES ($1, $2, 1) ON CONFLICT (job_seeker_id, usage_date) DO UPDATE SET generations_used = job_seeker_ai_usage.generations_used + 1, updated_at = NOW() "# ) .bind(profile_id) .bind(today) .execute(&state.pool) .await; let remaining = daily_limit - used.unwrap_or(0) - 1; (StatusCode::OK, Json(serde_json::json!({ "success": true, "remaining_today": remaining.max(0), "daily_limit": daily_limit, "message": "AI response sent successfully" }))).into_response() } // ── Usage Status ─────────────────────────────────────────────────────────────── #[derive(Debug, Serialize)] struct UsageStatusResponse { remaining_today: i32, daily_limit: i32, has_ai_pack: bool, } async fn ai_usage_status( State(state): State, auth: AuthUser, ) -> impl IntoResponse { let (is_company, profile_id) = { if let Some(cid) = sqlx::query_scalar::<_, Uuid>("SELECT id FROM company_profiles WHERE user_id = $1") .bind(auth.user_id) .fetch_optional(&state.pool) .await .ok() .flatten() { (true, cid) } else if let Some(sid) = sqlx::query_scalar::<_, Uuid>("SELECT id FROM job_seeker_profiles WHERE user_id = $1") .bind(auth.user_id) .fetch_optional(&state.pool) .await .ok() .flatten() { (false, sid) } else { return (StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "No profile found" }))).into_response(); } }; let today = chrono::Utc::now().date_naive(); let used: Option = if is_company { sqlx::query_scalar("SELECT generations_used FROM company_ai_usage WHERE company_id = $1 AND usage_date = $2") .bind(profile_id) .bind(today) .fetch_optional(&state.pool) .await .ok() .flatten() } else { sqlx::query_scalar("SELECT generations_used FROM job_seeker_ai_usage WHERE job_seeker_id = $1 AND usage_date = $2") .bind(profile_id) .bind(today) .fetch_optional(&state.pool) .await .ok() .flatten() }; let role_key = if is_company { "COMPANY" } else { "JOB_SEEKER" }; let (has_pack, daily_limit) = { let urp_id: Option = sqlx::query_scalar( "SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = $2" ) .bind(auth.user_id) .bind(role_key) .fetch_optional(&state.pool) .await .ok() .flatten(); match urp_id { Some(pid) => has_active_ai_pack(&state.pool, pid, role_key).await, None => (false, BASE_AI_LIMIT), } }; let remaining = daily_limit - used.unwrap_or(0); (StatusCode::OK, Json(UsageStatusResponse { remaining_today: remaining.max(0), daily_limit, has_ai_pack: has_pack, })).into_response() } #[cfg(test)] mod tests { use super::*; #[test] fn test_generate_field_request_deserialization() { let json = serde_json::json!({ "field": "title", "context": "Senior Rust Developer" }); let body: GenerateJobFieldBody = serde_json::from_value(json).unwrap(); assert_eq!(body.field, "title"); assert_eq!(body.context, "Senior Rust Developer"); } #[test] fn test_generate_field_request_all_fields() { for field in ["title", "description", "skills", "category"] { let json = serde_json::json!({ "field": field, "context": "Test context" }); let body: GenerateJobFieldBody = serde_json::from_value(json).unwrap(); assert_eq!(body.field, field); } } #[test] fn test_generate_field_response_serialization() { let response = GenerateFieldResponse { generated_text: "Senior Rust Developer".to_string(), remaining_today: 4, daily_limit: 5, has_ai_pack: false, }; let json = serde_json::to_value(&response).unwrap(); assert_eq!(json["generated_text"], "Senior Rust Developer"); assert_eq!(json["remaining_today"], 4); assert_eq!(json["daily_limit"], 5); assert_eq!(json["has_ai_pack"], false); } #[test] fn test_generate_field_response_with_ai_pack() { let response = GenerateFieldResponse { generated_text: "Generated content".to_string(), remaining_today: 15, daily_limit: 20, has_ai_pack: true, }; let json = serde_json::to_value(&response).unwrap(); assert_eq!(json["has_ai_pack"], true); assert_eq!(json["daily_limit"], 20); } #[test] fn test_cover_letter_body_deserialization() { let json = serde_json::json!({ "job_id": "550e8400-e29b-41d4-a716-446655440000", "additional_notes": "Available from next month" }); let body: CoverLetterBody = serde_json::from_value(json).unwrap(); assert_eq!(body.job_id.to_string(), "550e8400-e29b-41d4-a716-446655440000"); assert_eq!(body.additional_notes, Some("Available from next month".to_string())); } #[test] fn test_cover_letter_body_without_notes() { let json = serde_json::json!({ "job_id": "550e8400-e29b-41d4-a716-446655440000" }); let body: CoverLetterBody = serde_json::from_value(json).unwrap(); assert_eq!(body.additional_notes, None); } #[test] fn test_tailor_resume_body_deserialization() { let json = serde_json::json!({ "job_id": "550e8400-e29b-41d4-a716-446655440000", "resume_text": "My existing resume..." }); let body: TailorResumeBody = serde_json::from_value(json).unwrap(); assert_eq!(body.job_id.to_string(), "550e8400-e29b-41d4-a716-446655440000"); assert_eq!(body.resume_text, Some("My existing resume...".to_string())); } #[test] fn test_tailor_resume_body_without_resume() { let json = serde_json::json!({ "job_id": "550e8400-e29b-41d4-a716-446655440000" }); let body: TailorResumeBody = serde_json::from_value(json).unwrap(); assert_eq!(body.resume_text, None); } #[test] fn test_auto_apply_body_deserialization() { let json = serde_json::json!({ "job_ids": [ "550e8400-e29b-41d4-a716-446655440000", "550e8400-e29b-41d4-a716-446655440001" ] }); let body: AutoApplyBody = serde_json::from_value(json).unwrap(); assert_eq!(body.job_ids.len(), 2); } #[test] fn test_auto_apply_response_serialization() { let response = AutoApplyResponse { applications_created: 2, already_applied: vec![], failed: vec![], remaining_today: 8, daily_limit: 10, }; let json = serde_json::to_value(&response).unwrap(); assert_eq!(json["applications_created"], 2); assert_eq!(json["remaining_today"], 8); assert_eq!(json["daily_limit"], 10); } #[test] fn test_usage_status_response_serialization() { let response = UsageStatusResponse { remaining_today: 3, daily_limit: 5, has_ai_pack: false, }; let json = serde_json::to_value(&response).unwrap(); assert_eq!(json["remaining_today"], 3); assert_eq!(json["daily_limit"], 5); assert_eq!(json["has_ai_pack"], false); } #[test] fn test_base_ai_limit_constant() { assert_eq!(BASE_AI_LIMIT, 5); } #[test] fn test_get_ai_limit_from_features_with_value() { let features = serde_json::json!({"ai_generations_per_day": 20}); let limit = get_ai_limit_for_package(&features); assert_eq!(limit, 20); } #[test] fn test_get_ai_limit_from_features_defaults_to_base() { let features = serde_json::json!({}); assert_eq!(get_ai_limit_for_package(&features), BASE_AI_LIMIT); let features_null = serde_json::json!({"ai_generations_per_day": null}); assert_eq!(get_ai_limit_for_package(&features_null), BASE_AI_LIMIT); let features_wrong_type = serde_json::json!({"ai_generations_per_day": "unlimited"}); assert_eq!(get_ai_limit_for_package(&features_wrong_type), BASE_AI_LIMIT); } #[test] fn test_invalid_field_error() { let json = serde_json::json!({ "field": "invalid_field", "context": "test" }); let body: GenerateJobFieldBody = serde_json::from_value(json).unwrap(); assert_eq!(body.field, "invalid_field"); } }