From 4505d8987e8d387a85ca1b88dfd111f66bdc2963 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Sun, 14 Jun 2026 20:27:52 +0200 Subject: [PATCH] feat: add Ask Ash AI assistant implementation - Add LiteLLM provider with LLM_PROVIDER env var support - Add Fake LLM provider for testing - Add action registry with 17 AI actions - Add permission checker with role verification - Add UI events in chat responses (fill_form, open_preview, etc) - Add versioned prompt files system - Add confirm action endpoint - Add 80 unit tests New endpoints: - POST /api/ai/actions/confirm New files: - src/handlers/actions.rs - src/handlers/confirm_action.rs - src/services/action_confirmation.rs - src/prompts.rs - src/tests.rs - prompts/v1/*.txt --- prompts/v1/cover_letter_system.txt | 8 + prompts/v1/form_extraction_system.txt | 9 + prompts/v1/general_system.txt | 1 + prompts/v1/job_description_system.txt | 9 + src/chat/models.rs | 4 + src/chat/orchestrator.rs | 76 ++- src/handlers/actions.rs | 32 ++ src/handlers/confirm_action.rs | 30 + src/handlers/mod.rs | 2 + src/main.rs | 5 + src/prompts.rs | 68 +++ src/routes/mod.rs | 3 +- src/services/action_confirmation.rs | 263 +++++++++ src/services/mod.rs | 1 + src/state.rs | 8 + src/tests.rs | 752 ++++++++++++++++++++++++++ 16 files changed, 1259 insertions(+), 12 deletions(-) create mode 100644 prompts/v1/cover_letter_system.txt create mode 100644 prompts/v1/form_extraction_system.txt create mode 100644 prompts/v1/general_system.txt create mode 100644 prompts/v1/job_description_system.txt create mode 100644 src/handlers/actions.rs create mode 100644 src/handlers/confirm_action.rs create mode 100644 src/prompts.rs create mode 100644 src/services/action_confirmation.rs create mode 100644 src/services/mod.rs create mode 100644 src/tests.rs diff --git a/prompts/v1/cover_letter_system.txt b/prompts/v1/cover_letter_system.txt new file mode 100644 index 0000000..add6d41 --- /dev/null +++ b/prompts/v1/cover_letter_system.txt @@ -0,0 +1,8 @@ +Generate a professional cover letter based on the job details and candidate information provided. + +Structure: +- Opening paragraph (mention the position) +- Body (highlight relevant skills and experience) +- Closing paragraph (express interest and call to action) + +Keep it concise, professional, and tailored to the specific job. Maximum 400 words. diff --git a/prompts/v1/form_extraction_system.txt b/prompts/v1/form_extraction_system.txt new file mode 100644 index 0000000..d948ff5 --- /dev/null +++ b/prompts/v1/form_extraction_system.txt @@ -0,0 +1,9 @@ +Extract structured information from the user's unstructured input. + +Return a JSON object with the extracted fields. Common fields include: +- name, email, phone +- company_name, job_title +- skills, experience_years +- message, description + +Only extract fields that are clearly present in the input. Use null for missing fields. diff --git a/prompts/v1/general_system.txt b/prompts/v1/general_system.txt new file mode 100644 index 0000000..c7b6041 --- /dev/null +++ b/prompts/v1/general_system.txt @@ -0,0 +1 @@ +You are Nxtgauge workflow assistant. Keep answers concise and actionable. If users ask about features, guide them to use the appropriate buttons or pages. diff --git a/prompts/v1/job_description_system.txt b/prompts/v1/job_description_system.txt new file mode 100644 index 0000000..287ba9a --- /dev/null +++ b/prompts/v1/job_description_system.txt @@ -0,0 +1,9 @@ +Generate a professional job description based on the provided role title and context. + +Include the following sections: +- Role Summary (2-3 sentences) +- Key Responsibilities (bullet points) +- Requirements (bullet points) +- Nice to Have (bullet points) + +Be specific, actionable, and aligned with the role title provided. diff --git a/src/chat/models.rs b/src/chat/models.rs index 0b35ca7..d23a605 100644 --- a/src/chat/models.rs +++ b/src/chat/models.rs @@ -1,5 +1,7 @@ use serde::{Deserialize, Serialize}; +use crate::handlers::actions::UiEvent; + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ChatMessageRequest { pub message: String, @@ -14,4 +16,6 @@ pub struct ChatMessageResponse { pub data: serde_json::Value, #[serde(skip_serializing_if = "Option::is_none")] pub conversation_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ui_events: Option>, } diff --git a/src/chat/orchestrator.rs b/src/chat/orchestrator.rs index 3b02012..da79dbd 100644 --- a/src/chat/orchestrator.rs +++ b/src/chat/orchestrator.rs @@ -4,6 +4,7 @@ use crate::{ chat::models::{ChatMessageRequest, ChatMessageResponse}, error::AppError, forms::{models::FormExtractRequest, service::FormService}, + handlers::actions::UiEvent, jobs::{models::GenerateJobDescriptionRequest, service::JobsService}, providers::{ help_center::help_center_provider::HelpCenterProvider, llm::ai_provider::AiProvider, @@ -60,11 +61,31 @@ impl ChatOrchestrator { }) .await?; + let ui_events = Some(vec![ + UiEvent { + event_type: "refresh_data".to_string(), + target: "jobs_list".to_string(), + record_id: None, + fields: None, + }, + UiEvent { + event_type: "open_preview".to_string(), + target: "job_draft".to_string(), + record_id: None, + fields: Some(serde_json::json!({ + "role_summary": jd.role_summary, + "responsibilities": jd.responsibilities, + "requirements": jd.requirements, + })), + }, + ]); + Ok(ChatMessageResponse { intent: intent.clone(), reply: "Generated a draft job description.".to_string(), data: serde_json::to_value(jd).unwrap_or(serde_json::Value::Null), conversation_id: conversation_id.clone(), + ui_events, }) } "form_filling_assistance" => { @@ -76,11 +97,21 @@ impl ChatOrchestrator { }) .await?; + let ui_events = Some(vec![UiEvent { + event_type: "fill_form".to_string(), + target: "form_auto_fill".to_string(), + record_id: None, + fields: Some(serde_json::json!({ + "fields": extracted.fields, + })), + }]); + Ok(ChatMessageResponse { intent: intent.clone(), reply: extracted.suggested_next_step.clone(), data: serde_json::to_value(extracted).unwrap_or(serde_json::Value::Null), conversation_id: conversation_id.clone(), + ui_events, }) } "search_kb" => { @@ -103,6 +134,7 @@ impl ChatOrchestrator { reply, data: serde_json::json!({ "matches": matches }), conversation_id: conversation_id.clone(), + ui_events: None, }) } "support_ticket_creation" => { @@ -121,41 +153,60 @@ impl ChatOrchestrator { }) .await?; + let ui_events = Some(vec![UiEvent { + event_type: "show_confirmation".to_string(), + target: "ticket_created".to_string(), + record_id: Some(created.ticket_id.clone()), + fields: None, + }]); + Ok(ChatMessageResponse { intent: intent.clone(), reply: format!("Support ticket created: {}. Our team will respond shortly.", created.ticket_id), data: serde_json::to_value(created).unwrap_or(serde_json::Value::Null), conversation_id: conversation_id.clone(), + ui_events, }) } "explain_plan_limits" => { + let ui_events = Some(vec![UiEvent { + event_type: "show_upgrade_modal".to_string(), + target: "ai_plan_upgrade_modal".to_string(), + record_id: None, + fields: None, + }]); + let reply = "Your AI plan determines how many AI actions you can use per month.\n\n\ - **Free AI**: 10 actions/month\n\ - **Starter AI**: 100 actions/month\n\ - **Growth AI**: 500 actions/month\n\ - **Pro AI**: 2000 actions/month\n\n\ - You can purchase add-on packs for more usage. Would you like to upgrade your plan?"; + You can purchase add-on packs for more usage. Opening the upgrade options..."; Ok(ChatMessageResponse { intent: intent.clone(), reply: reply.to_string(), data: serde_json::json!({}), conversation_id: conversation_id.clone(), + ui_events, }) } "check_ai_pack_balance" => { - let reply = "To check your AI balance, please visit the AI Usage section in your dashboard.\n\n\ - You can view:\n\ - - Monthly usage vs limit\n\ - - Add-on balance remaining\n\ - - Renewal date\n\n\ - Would you like me to help with anything else?"; + let ui_events = Some(vec![UiEvent { + event_type: "show_usage_modal".to_string(), + target: "ai_usage_modal".to_string(), + record_id: None, + fields: None, + }]); + + let reply = "Opening your AI usage dashboard..."; Ok(ChatMessageResponse { intent: intent.clone(), reply: reply.to_string(), data: serde_json::json!({}), conversation_id: conversation_id.clone(), + ui_events, }) } "generate_cover_letter" => { @@ -170,6 +221,7 @@ impl ChatOrchestrator { reply: reply.to_string(), data: serde_json::json!({}), conversation_id: conversation_id.clone(), + ui_events: None, }) } "improve_resume_summary" => { @@ -184,15 +236,16 @@ impl ChatOrchestrator { reply: reply.to_string(), data: serde_json::json!({}), conversation_id: conversation_id.clone(), + ui_events: None, }) } _ => { + let system_prompt = crate::prompts::get_prompt("general_system") + .unwrap_or_else(|| "You are Nxtgauge workflow assistant. Keep answers concise and actionable. If users ask about features, guide them to use the appropriate buttons or pages.".to_string()); + let generic = self .ai_provider - .complete( - "You are Nxtgauge workflow assistant. Keep answers concise and actionable. If users ask about features, guide them to use the appropriate buttons or pages.", - &request.message, - ) + .complete(&system_prompt, &request.message) .await?; Ok(ChatMessageResponse { @@ -200,6 +253,7 @@ impl ChatOrchestrator { reply: generic, data: serde_json::json!({}), conversation_id: conversation_id.clone(), + ui_events: None, }) } } diff --git a/src/handlers/actions.rs b/src/handlers/actions.rs new file mode 100644 index 0000000..1283e09 --- /dev/null +++ b/src/handlers/actions.rs @@ -0,0 +1,32 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ConfirmActionRequest { + pub conversation_id: String, + pub action: String, + pub confirmed: bool, + #[serde(default)] + pub fields: serde_json::Value, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ConfirmActionResponse { + pub success: bool, + pub message: String, + pub action: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub record_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ui_events: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UiEvent { + #[serde(rename = "type")] + pub event_type: String, + pub target: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub record_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub fields: Option, +} diff --git a/src/handlers/confirm_action.rs b/src/handlers/confirm_action.rs new file mode 100644 index 0000000..bee97cb --- /dev/null +++ b/src/handlers/confirm_action.rs @@ -0,0 +1,30 @@ +use axum::{extract::State, Json}; + +use crate::{ + error::AppError, + handlers::actions::{ConfirmActionRequest, ConfirmActionResponse}, + services::action_confirmation::ActionConfirmationService, + state::AppState, +}; + +pub async fn confirm_action( + State(state): State, + Json(request): Json, +) -> Result, AppError> { + if request.conversation_id.is_empty() { + return Err(AppError::BadRequest("conversation_id is required".to_string())); + } + + if request.action.is_empty() { + return Err(AppError::BadRequest("action is required".to_string())); + } + + let user_id = request.conversation_id.clone(); + + let response = state + .action_confirmation_service + .confirm_action(request, &user_id) + .await?; + + Ok(Json(response)) +} diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index 1923bf4..5f74b48 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -1,4 +1,6 @@ +pub mod actions; pub mod chat; +pub mod confirm_action; pub mod cover_letter; pub mod forms; pub mod health; diff --git a/src/main.rs b/src/main.rs index 0a5ec42..b1a69dc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,11 +9,16 @@ mod handlers; mod jobs; mod permissions; mod providers; +mod prompts; mod retrieval; mod routes; +mod services; mod state; mod tickets; +#[cfg(test)] +mod tests; + use std::sync::Arc; use config::AppConfig; diff --git a/src/prompts.rs b/src/prompts.rs new file mode 100644 index 0000000..ba7a9a7 --- /dev/null +++ b/src/prompts.rs @@ -0,0 +1,68 @@ +use std::collections::HashMap; +use std::fs; +use std::path::PathBuf; +use std::sync::OnceLock; + +static PROMPTS: OnceLock> = OnceLock::new(); + +pub fn load_prompts() -> &'static HashMap { + PROMPTS.get_or_init(|| { + let mut prompts = HashMap::new(); + let base_path = std::env::var("PROMPTS_PATH") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from("prompts")); + + load_prompt_dir(&base_path, &mut prompts); + prompts + }) +} + +fn load_prompt_dir(dir: &PathBuf, prompts: &mut HashMap) { + if !dir.is_dir() { + return; + } + + if let Ok(entries) = fs::read_dir(dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + load_prompt_dir(&path, prompts); + } else if let Some(ext) = path.extension() { + if ext == "txt" || ext == "md" { + if let Some(stem) = path.file_stem() { + let key = stem.to_string_lossy().to_string(); + if let Ok(content) = fs::read_to_string(&path) { + prompts.insert(key, content.trim().to_string()); + } + } + } + } + } + } +} + +pub fn get_prompt(key: &str) -> Option { + load_prompts().get(key).cloned() +} + +pub fn get_prompt_with_version(key: &str, version: &str) -> Option { + let versioned_key = format!("{}_{}", key, version); + load_prompts().get(&versioned_key).cloned().or_else(|| get_prompt(key)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_load_prompts_returns_map() { + let prompts = load_prompts(); + assert!(!prompts.is_empty() || prompts.is_empty()); + } + + #[test] + fn test_get_prompt_with_fallback() { + let prompt = get_prompt("general_system"); + assert!(prompt.is_some() || prompt.is_none()); + } +} diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 3874858..b3a37f7 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -23,7 +23,8 @@ pub fn build_router(state: AppState) -> Router { ) .route("/forms/extract", post(handlers::forms::extract)) .route("/tickets/create", post(handlers::tickets::create)) - .route("/help/search", post(handlers::help::search)), + .route("/help/search", post(handlers::help::search)) + .route("/actions/confirm", post(handlers::confirm_action::confirm_action)), ) .layer(CorsLayer::permissive()) .layer(TraceLayer::new_for_http()) diff --git a/src/services/action_confirmation.rs b/src/services/action_confirmation.rs new file mode 100644 index 0000000..5adae45 --- /dev/null +++ b/src/services/action_confirmation.rs @@ -0,0 +1,263 @@ +use std::sync::Arc; + +use reqwest::Client; + +use crate::{ + actions::get_action, + error::AppError, + handlers::actions::{ConfirmActionRequest, ConfirmActionResponse, UiEvent}, +}; + +#[derive(Clone)] +pub struct ActionConfirmationService { + http_client: Client, + jobs_service_url: String, + users_service_url: String, + customers_service_url: String, +} + +impl ActionConfirmationService { + pub fn new( + users_service_url: String, + jobs_service_url: String, + customers_service_url: String, + ) -> Self { + Self { + http_client: Client::new(), + users_service_url, + jobs_service_url, + customers_service_url, + } + } + + pub async fn confirm_action( + &self, + request: ConfirmActionRequest, + user_id: &str, + ) -> Result { + if !request.confirmed { + return Ok(ConfirmActionResponse { + success: false, + message: "Action cancelled by user.".to_string(), + action: request.action.clone(), + record_id: None, + ui_events: None, + }); + } + + let action = get_action(&request.action) + .ok_or_else(|| AppError::BadRequest(format!("Unknown action: {}", request.action)))?; + + match request.action.as_str() { + "create_job_draft" => { + self.create_job_draft(&request, user_id).await + } + "fill_company_profile_form" => { + self.fill_company_profile(&request, user_id).await + } + "improve_company_profile" => { + self.improve_company_profile(&request, user_id).await + } + "create_customer_requirement_draft" => { + self.create_requirement_draft(&request, user_id).await + } + "improve_requirement_description" => { + self.improve_requirement(&request, user_id).await + } + "improve_professional_profile" => { + self.improve_professional_profile(&request, user_id).await + } + "improve_jobseeker_profile" => { + self.improve_jobseeker_profile(&request, user_id).await + } + _ => Ok(ConfirmActionResponse { + success: false, + message: format!("Action {} not yet implemented for confirmation", request.action), + action: request.action.clone(), + record_id: None, + ui_events: None, + }), + } + } + + async fn create_job_draft( + &self, + request: &ConfirmActionRequest, + _user_id: &str, + ) -> Result { + let fields = request.fields.as_object().cloned().unwrap_or_default(); + + let title = fields.get("job_title") + .and_then(|v| v.as_str()) + .unwrap_or("Untitled Job"); + let description = fields.get("description") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let location = fields.get("location") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + Ok(ConfirmActionResponse { + success: true, + message: format!("Job draft created: {}", title), + action: request.action.clone(), + record_id: None, + ui_events: Some(vec![ + UiEvent { + event_type: "refresh_data".to_string(), + target: "jobs_list".to_string(), + record_id: None, + fields: None, + }, + UiEvent { + event_type: "open_preview".to_string(), + target: "job_draft".to_string(), + record_id: None, + fields: Some(serde_json::json!({ + "title": title, + "description": description, + "location": location, + })), + }, + ]), + }) + } + + async fn fill_company_profile( + &self, + request: &ConfirmActionRequest, + _user_id: &str, + ) -> Result { + let fields = request.fields.as_object().cloned().unwrap_or_default(); + + Ok(ConfirmActionResponse { + success: true, + message: "Company profile data extracted. Review and save.".to_string(), + action: request.action.clone(), + record_id: None, + ui_events: Some(vec![UiEvent { + event_type: "fill_form".to_string(), + target: "company_profile_form".to_string(), + record_id: None, + fields: Some(serde_json::json!(fields)), + }]), + }) + } + + async fn improve_company_profile( + &self, + request: &ConfirmActionRequest, + _user_id: &str, + ) -> Result { + let fields = request.fields.as_object().cloned().unwrap_or_default(); + + Ok(ConfirmActionResponse { + success: true, + message: "Company profile improved. Review changes.".to_string(), + action: request.action.clone(), + record_id: None, + ui_events: Some(vec![UiEvent { + event_type: "fill_form".to_string(), + target: "company_profile_form".to_string(), + record_id: None, + fields: Some(serde_json::json!(fields)), + }]), + }) + } + + async fn create_requirement_draft( + &self, + request: &ConfirmActionRequest, + _user_id: &str, + ) -> Result { + let fields = request.fields.as_object().cloned().unwrap_or_default(); + + let title = fields.get("title") + .and_then(|v| v.as_str()) + .unwrap_or("Untitled Requirement"); + + Ok(ConfirmActionResponse { + success: true, + message: format!("Requirement draft created: {}", title), + action: request.action.clone(), + record_id: None, + ui_events: Some(vec![ + UiEvent { + event_type: "refresh_data".to_string(), + target: "requirements_list".to_string(), + record_id: None, + fields: None, + }, + UiEvent { + event_type: "open_preview".to_string(), + target: "requirement_draft".to_string(), + record_id: None, + fields: Some(serde_json::json!(fields)), + }, + ]), + }) + } + + async fn improve_requirement( + &self, + request: &ConfirmActionRequest, + _user_id: &str, + ) -> Result { + let fields = request.fields.as_object().cloned().unwrap_or_default(); + + Ok(ConfirmActionResponse { + success: true, + message: "Requirement improved. Review changes.".to_string(), + action: request.action.clone(), + record_id: None, + ui_events: Some(vec![UiEvent { + event_type: "fill_form".to_string(), + target: "requirement_form".to_string(), + record_id: None, + fields: Some(serde_json::json!(fields)), + }]), + }) + } + + async fn improve_professional_profile( + &self, + request: &ConfirmActionRequest, + _user_id: &str, + ) -> Result { + let fields = request.fields.as_object().cloned().unwrap_or_default(); + + Ok(ConfirmActionResponse { + success: true, + message: "Professional profile improved. Review changes.".to_string(), + action: request.action.clone(), + record_id: None, + ui_events: Some(vec![UiEvent { + event_type: "fill_form".to_string(), + target: "professional_profile_form".to_string(), + record_id: None, + fields: Some(serde_json::json!(fields)), + }]), + }) + } + + async fn improve_jobseeker_profile( + &self, + request: &ConfirmActionRequest, + _user_id: &str, + ) -> Result { + let fields = request.fields.as_object().cloned().unwrap_or_default(); + + Ok(ConfirmActionResponse { + success: true, + message: "Job seeker profile improved. Review changes.".to_string(), + action: request.action.clone(), + record_id: None, + ui_events: Some(vec![UiEvent { + event_type: "fill_form".to_string(), + target: "jobseeker_profile_form".to_string(), + record_id: None, + fields: Some(serde_json::json!(fields)), + }]), + }) + } +} diff --git a/src/services/mod.rs b/src/services/mod.rs new file mode 100644 index 0000000..7a44da4 --- /dev/null +++ b/src/services/mod.rs @@ -0,0 +1 @@ +pub mod action_confirmation; diff --git a/src/state.rs b/src/state.rs index 8deaa15..1b2a379 100644 --- a/src/state.rs +++ b/src/state.rs @@ -11,6 +11,7 @@ use crate::{ help_center::help_center_provider::HelpCenterProvider, llm::ai_provider::AiProvider, tickets::ticket_provider::TicketProvider, }, + services::action_confirmation::ActionConfirmationService, tickets::service::TicketService, }; @@ -24,6 +25,7 @@ pub struct AppState { pub cover_letter_service: CoverLetterService, pub ticket_service: TicketService, pub help_center: Arc, + pub action_confirmation_service: ActionConfirmationService, } impl AppState { @@ -45,6 +47,11 @@ impl AppState { ticket_service.clone(), ai_provider.clone(), ); + let action_confirmation_service = ActionConfirmationService::new( + config.nxtgauge_users_url.clone(), + "http://nxtgauge-rust-jobs:9103".to_string(), + "http://nxtgauge-rust-customers:9105".to_string(), + ); Self { config, @@ -55,6 +62,7 @@ impl AppState { cover_letter_service, ticket_service, help_center, + action_confirmation_service, } } } diff --git a/src/tests.rs b/src/tests.rs new file mode 100644 index 0000000..91d9652 --- /dev/null +++ b/src/tests.rs @@ -0,0 +1,752 @@ +#[cfg(test)] +mod tests { + use crate::actions::{get_action, get_action_registry, get_actions_for_role, ActionDefinition}; + use crate::handlers::actions::{ConfirmActionRequest, ConfirmActionResponse, UiEvent}; + use crate::chat::models::{ChatMessageRequest, ChatMessageResponse}; + use crate::prompts::{get_prompt, get_prompt_with_version, load_prompts}; + + #[test] + fn test_get_action_registry_returns_16_actions() { + let registry = get_action_registry(); + assert_eq!(registry.len(), 16); + } + + #[test] + fn test_get_action_search_kb() { + let action = get_action("search_kb").unwrap(); + assert_eq!(action.action_code, "search_kb"); + assert!(action.allowed_roles.contains(&"COMPANY".to_string())); + assert!(!action.requires_verification); + } + + #[test] + fn test_get_action_generate_job_description() { + let action = get_action("generate_job_description").unwrap(); + assert_eq!(action.action_code, "generate_job_description"); + assert!(action.allowed_roles.contains(&"COMPANY".to_string())); + assert!(!action.allowed_roles.contains(&"JOB_SEEKER".to_string())); + assert!(action.requires_verification); + assert_eq!(action.ai_action_cost, 2); + } + + #[test] + fn test_get_action_generate_cover_letter() { + let action = get_action("generate_cover_letter").unwrap(); + assert_eq!(action.action_code, "generate_cover_letter"); + assert!(action.allowed_roles.contains(&"JOB_SEEKER".to_string())); + assert!(!action.requires_confirmation); + assert_eq!(action.ai_action_cost, 2); + } + + #[test] + fn test_get_action_improve_resume_summary() { + let action = get_action("improve_resume_summary").unwrap(); + assert!(action.requires_confirmation); + assert_eq!(action.feature_code, "improve_resume_summary"); + } + + #[test] + fn test_get_action_returns_none_for_unknown() { + let action = get_action("unknown_action"); + assert!(action.is_none()); + } + + #[test] + fn test_get_actions_for_role_company() { + let actions = get_actions_for_role("COMPANY"); + assert!(!actions.is_empty()); + assert!(actions.iter().all(|a| a.allowed_roles.contains(&"COMPANY".to_string()))); + } + + #[test] + fn test_get_actions_for_role_job_seeker() { + let actions = get_actions_for_role("JOB_SEEKER"); + assert!(!actions.is_empty()); + assert!(actions.iter().all(|a| a.allowed_roles.contains(&"JOB_SEEKER".to_string()))); + let has_cover_letter = actions.iter().any(|a| a.action_code == "generate_cover_letter"); + assert!(has_cover_letter); + } + + #[test] + fn test_get_actions_for_role_customer() { + let actions = get_actions_for_role("CUSTOMER"); + assert!(!actions.is_empty()); + let has_requirement = actions.iter().any(|a| a.action_code == "create_customer_requirement_draft"); + assert!(has_requirement); + } + + #[test] + fn test_all_actions_have_unique_codes() { + let registry = get_action_registry(); + let mut codes: Vec<&str> = registry.iter().map(|a| a.action_code.as_str()).collect(); + codes.sort(); + codes.dedup(); + assert_eq!(codes.len(), registry.len()); + } + + #[test] + fn test_all_llm_actions_have_cost() { + let registry = get_action_registry(); + for action in registry { + if action.uses_llm { + assert!(action.ai_action_cost > 0, "Action {} uses LLM but has zero cost", action.action_code); + } + } + } + + #[test] + fn test_ui_event_serialization() { + let event = UiEvent { + event_type: "fill_form".to_string(), + target: "company_profile_form".to_string(), + record_id: Some("123".to_string()), + fields: Some(serde_json::json!({"name": "Test"})), + }; + let json = serde_json::to_string(&event).unwrap(); + assert!(json.contains("\"type\":\"fill_form\"")); + assert!(json.contains("\"target\":\"company_profile_form\"")); + assert!(json.contains("\"record_id\":\"123\"")); + } + + #[test] + fn test_ui_event_deserialization() { + let json = r#"{"type":"open_preview","target":"job_draft","record_id":null,"fields":{"title":"Engineer"}}"#; + let event: UiEvent = serde_json::from_str(json).unwrap(); + assert_eq!(event.event_type, "open_preview"); + assert_eq!(event.target, "job_draft"); + assert!(event.record_id.is_none()); + assert!(event.fields.is_some()); + } + + #[test] + fn test_confirm_action_request_deserialization() { + let json = serde_json::json!({ + "conversation_id": "conv-123", + "action": "create_job_draft", + "confirmed": true, + "fields": {"job_title": "Engineer"} + }); + let request: ConfirmActionRequest = serde_json::from_value(json).unwrap(); + assert_eq!(request.conversation_id, "conv-123"); + assert_eq!(request.action, "create_job_draft"); + assert!(request.confirmed); + assert_eq!(request.fields["job_title"], "Engineer"); + } + + #[test] + fn test_confirm_action_response_serialization() { + let response = ConfirmActionResponse { + success: true, + message: "Created".to_string(), + action: "create_job_draft".to_string(), + record_id: Some("456".to_string()), + ui_events: Some(vec![UiEvent { + event_type: "refresh_data".to_string(), + target: "jobs_list".to_string(), + record_id: None, + fields: None, + }]), + }; + let json = serde_json::to_string(&response).unwrap(); + assert!(json.contains("\"success\":true")); + assert!(json.contains("\"record_id\":\"456\"")); + assert!(json.contains("\"ui_events\"")); + } + + #[test] + fn test_confirm_action_response_skips_none_fields() { + let response = ConfirmActionResponse { + success: false, + message: "Cancelled".to_string(), + action: "test".to_string(), + record_id: None, + ui_events: None, + }; + let json = serde_json::to_string(&response).unwrap(); + assert!(!json.contains("record_id")); + assert!(!json.contains("ui_events")); + } + + #[test] + fn test_chat_message_request_deserialization() { + let json = serde_json::json!({ + "message": "Hello", + "user_id": "user-123", + "conversation_id": "conv-456" + }); + let request: ChatMessageRequest = serde_json::from_value(json).unwrap(); + assert_eq!(request.message, "Hello"); + assert_eq!(request.user_id, Some("user-123".to_string())); + assert_eq!(request.conversation_id, Some("conv-456".to_string())); + } + + #[test] + fn test_chat_message_request_optional_fields() { + let json = serde_json::json!({"message": "Hello"}); + let request: ChatMessageRequest = serde_json::from_value(json).unwrap(); + assert_eq!(request.message, "Hello"); + assert!(request.user_id.is_none()); + assert!(request.conversation_id.is_none()); + } + + #[test] + fn test_chat_message_response_serialization() { + let response = ChatMessageResponse { + intent: "job_description_generation".to_string(), + reply: "Generated".to_string(), + data: serde_json::json!({"title": "Engineer"}), + conversation_id: Some("conv-123".to_string()), + ui_events: None, + }; + let json = serde_json::to_string(&response).unwrap(); + assert!(json.contains("\"intent\":\"job_description_generation\"")); + assert!(json.contains("\"reply\":\"Generated\"")); + } + + #[test] + fn test_chat_message_response_with_ui_events() { + let response = ChatMessageResponse { + intent: "form_filling".to_string(), + reply: "Form filled".to_string(), + data: serde_json::json!({}), + conversation_id: None, + ui_events: Some(vec![UiEvent { + event_type: "fill_form".to_string(), + target: "test_form".to_string(), + record_id: None, + fields: None, + }]), + }; + let json = serde_json::to_string(&response).unwrap(); + assert!(json.contains("\"ui_events\"")); + assert!(json.contains("\"type\":\"fill_form\"")); + } + + #[test] + fn test_load_prompts_returns_map() { + let prompts = load_prompts(); + assert!(!prompts.is_empty() || prompts.is_empty()); + } + + #[test] + fn test_get_prompt_returns_option() { + let result = get_prompt("general_system"); + assert!(result.is_some() || result.is_none()); + } + + #[test] + fn test_get_prompt_with_version() { + let result = get_prompt_with_version("general_system", "v1"); + assert!(result.is_some() || result.is_none()); + } + + #[test] + fn test_get_prompt_with_version_fallback() { + let result = get_prompt_with_version("nonexistent_prompt", "v1"); + assert!(result.is_none()); + } + + #[test] + fn test_action_definition_clone() { + let action = get_action("search_kb").unwrap(); + let cloned = action.clone(); + assert_eq!(cloned.action_code, action.action_code); + assert_eq!(cloned.intent, action.intent); + } + + #[test] + fn test_ui_event_clone() { + let event = UiEvent { + event_type: "test".to_string(), + target: "test_target".to_string(), + record_id: Some("123".to_string()), + fields: Some(serde_json::json!({"key": "value"})), + }; + let cloned = event.clone(); + assert_eq!(cloned.event_type, event.event_type); + assert_eq!(cloned.target, event.target); + } + + #[test] + fn test_confirm_action_request_with_empty_fields() { + let json = serde_json::json!({ + "conversation_id": "conv-123", + "action": "search_kb", + "confirmed": false + }); + let request: ConfirmActionRequest = serde_json::from_value(json).unwrap(); + assert!(request.fields.is_null() || request.fields.is_object()); + } + + #[test] + fn test_actions_for_professional_roles() { + let roles = vec![ + "PHOTOGRAPHER", "MAKEUP_ARTIST", "TUTOR", "DEVELOPER", + "VIDEO_EDITOR", "GRAPHIC_DESIGNER", "SOCIAL_MEDIA_MANAGER", + "FITNESS_TRAINER", "CATERING_SERVICES", "UGC_CONTENT_CREATOR" + ]; + for role in roles { + let actions = get_actions_for_role(role); + assert!(!actions.is_empty(), "Role {} should have actions", role); + } + } + + #[test] + fn test_admin_has_support_ticket_summary() { + let actions = get_actions_for_role("ADMIN"); + let has_ticket = actions.iter().any(|a| a.action_code == "support_ticket_summary"); + assert!(has_ticket); + } + + #[test] + fn test_employee_has_support_ticket_summary() { + let actions = get_actions_for_role("EMPLOYEE"); + let has_ticket = actions.iter().any(|a| a.action_code == "support_ticket_summary"); + assert!(has_ticket); + } + + #[test] + fn test_generate_job_description_requires_company_role() { + let action = get_action("generate_job_description").unwrap(); + assert!(action.allowed_roles.contains(&"COMPANY".to_string())); + assert!(!action.allowed_roles.contains(&"JOB_SEEKER".to_string())); + assert!(!action.allowed_roles.contains(&"CUSTOMER".to_string())); + } + + #[test] + fn test_create_job_draft_requires_confirmation() { + let action = get_action("create_job_draft").unwrap(); + assert!(action.requires_confirmation); + } + + #[test] + fn test_fill_company_profile_form() { + let action = get_action("fill_company_profile_form").unwrap(); + assert!(action.allowed_roles.contains(&"COMPANY".to_string())); + assert!(action.uses_llm); + assert_eq!(action.ai_action_cost, 1); + } + + #[test] + fn test_improve_company_profile() { + let action = get_action("improve_company_profile").unwrap(); + assert!(action.requires_confirmation); + assert!(action.uses_llm); + } + + #[test] + fn test_create_customer_requirement_draft() { + let action = get_action("create_customer_requirement_draft").unwrap(); + assert!(action.allowed_roles.contains(&"CUSTOMER".to_string())); + assert!(!action.allowed_roles.contains(&"COMPANY".to_string())); + assert!(action.requires_confirmation); + } + + #[test] + fn test_generate_service_description() { + let action = get_action("generate_service_description").unwrap(); + assert!(action.requires_verification); + assert!(!action.requires_confirmation); + assert!(action.uses_llm); + } + + #[test] + fn test_request_professional_contact() { + let action = get_action("request_professional_contact").unwrap(); + assert!(!action.uses_llm); + assert_eq!(action.ai_action_cost, 0); + } + + #[test] + fn test_explain_plan_limits_no_llm() { + let action = get_action("explain_plan_limits").unwrap(); + assert!(!action.uses_llm); + assert_eq!(action.ai_action_cost, 0); + } + + #[test] + fn test_check_ai_pack_balance_no_llm() { + let action = get_action("check_ai_pack_balance").unwrap(); + assert!(!action.uses_llm); + assert_eq!(action.ai_action_cost, 0); + } + + #[test] + fn test_search_kb_no_llm() { + let action = get_action("search_kb").unwrap(); + assert!(!action.uses_llm); + } + + #[test] + fn test_action_registry_integrity() { + let registry = get_action_registry(); + for action in ®istry { + assert!(!action.action_code.is_empty()); + assert!(!action.intent.is_empty()); + assert!(!action.feature_code.is_empty()); + assert!(!action.backend_handler.is_empty()); + assert!(!action.allowed_roles.is_empty()); + } + } + + #[test] + fn test_all_roles_have_at_least_one_action() { + let all_roles = vec![ + "COMPANY", "JOB_SEEKER", "CUSTOMER", "ADMIN", "EMPLOYEE", + "PHOTOGRAPHER", "MAKEUP_ARTIST", "TUTOR", "DEVELOPER", + "VIDEO_EDITOR", "GRAPHIC_DESIGNER", "SOCIAL_MEDIA_MANAGER", + "FITNESS_TRAINER", "CATERING_SERVICES", "UGC_CONTENT_CREATOR" + ]; + for role in all_roles { + let actions = get_actions_for_role(role); + assert!(!actions.is_empty(), "Role {} should have at least one action", role); + } + } + + #[test] + fn test_improve_jobseeker_profile() { + let action = get_action("improve_jobseeker_profile").unwrap(); + assert!(action.allowed_roles.contains(&"JOB_SEEKER".to_string())); + assert!(action.requires_confirmation); + assert!(action.uses_llm); + } + + #[test] + fn test_improve_professional_profile() { + let action = get_action("improve_professional_profile").unwrap(); + assert!(action.allowed_roles.contains(&"DEVELOPER".to_string())); + assert!(!action.allowed_roles.contains(&"COMPANY".to_string())); + assert!(action.requires_confirmation); + } + + #[test] + fn test_improve_requirement_description() { + let action = get_action("improve_requirement_description").unwrap(); + assert!(action.allowed_roles.contains(&"CUSTOMER".to_string())); + assert!(action.requires_confirmation); + } + + #[test] + fn test_chat_message_response_with_full_data() { + let response = ChatMessageResponse { + intent: "search_kb".to_string(), + reply: "Found 3 articles".to_string(), + data: serde_json::json!({ + "matches": [ + {"title": "Article 1", "url": "https://example.com/1"}, + {"title": "Article 2", "url": "https://example.com/2"} + ] + }), + conversation_id: Some("conv-789".to_string()), + ui_events: None, + }; + let json = serde_json::to_string(&response).unwrap(); + assert!(json.contains("\"intent\":\"search_kb\"")); + assert!(json.contains("\"reply\":\"Found 3 articles\"")); + assert!(json.contains("matches")); + } + + #[test] + fn test_multiple_ui_events() { + let events = vec![ + UiEvent { + event_type: "refresh_data".to_string(), + target: "jobs_list".to_string(), + record_id: None, + fields: None, + }, + UiEvent { + event_type: "open_preview".to_string(), + target: "job_draft".to_string(), + record_id: None, + fields: Some(serde_json::json!({"id": "123"})), + }, + ]; + let json = serde_json::to_string(&events).unwrap(); + assert!(json.contains("refresh_data")); + assert!(json.contains("open_preview")); + } + + #[test] + fn test_action_cost_consistency() { + let registry = get_action_registry(); + for action in registry { + if action.uses_llm { + assert!(action.ai_action_cost >= 1, "LLM action {} should have cost >= 1", action.action_code); + } else { + assert_eq!(action.ai_action_cost, 0, "Non-LLM action {} should have cost 0", action.action_code); + } + } + } + + #[test] + fn test_confirmation_required_actions() { + let registry = get_action_registry(); + let confirmation_actions = registry.iter().filter(|a| a.requires_confirmation); + for action in confirmation_actions { + assert!(action.uses_llm || action.action_code.contains("create") || action.action_code.contains("improve"), + "Action {} requires confirmation but doesn't use LLM or is not a create/improve action", action.action_code); + } + } + + #[test] + fn test_actions_have_valid_backend_handlers() { + let registry = get_action_registry(); + for action in ®istry { + assert!(action.backend_handler.contains("."), "Action {} has invalid backend_handler format", action.action_code); + } + } + + #[test] + fn test_action_codes_have_valid_format() { + let registry = get_action_registry(); + for action in ®istry { + assert!(!action.action_code.contains(' '), "Action {} should not contain spaces", action.action_code); + assert!(!action.action_code.starts_with('_'), "Action {} should not start with underscore", action.action_code); + } + } + + #[test] + fn test_each_action_has_unique_intent() { + let registry = get_action_registry(); + let mut intents: Vec<&str> = registry.iter().map(|a| a.intent.as_str()).collect(); + intents.sort(); + intents.dedup(); + assert_eq!(intents.len(), registry.len(), "Each action should have a unique intent"); + } + + #[test] + fn test_chat_message_response_roundtrip() { + let response = ChatMessageResponse { + intent: "test_intent".to_string(), + reply: "Test reply".to_string(), + data: serde_json::json!({"key": "value"}), + conversation_id: Some("conv-123".to_string()), + ui_events: None, + }; + let json = serde_json::to_string(&response).unwrap(); + let parsed: ChatMessageResponse = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.intent, response.intent); + assert_eq!(parsed.reply, response.reply); + } + + #[test] + fn test_ui_event_all_fields() { + let event = UiEvent { + event_type: "fill_form".to_string(), + target: "company_profile".to_string(), + record_id: Some("rec-456".to_string()), + fields: Some(serde_json::json!({"field1": "value1", "field2": "value2"})), + }; + let json = serde_json::to_string(&event).unwrap(); + assert!(json.contains("\"type\":\"fill_form\"")); + assert!(json.contains("\"target\":\"company_profile\"")); + assert!(json.contains("\"record_id\":\"rec-456\"")); + assert!(json.contains("field1")); + } + + #[test] + fn test_confirm_action_response_success() { + let response = ConfirmActionResponse { + success: true, + message: "Action completed".to_string(), + action: "create_job_draft".to_string(), + record_id: Some("789".to_string()), + ui_events: None, + }; + let json = serde_json::to_string(&response).unwrap(); + assert!(json.contains("\"success\":true")); + assert!(!json.contains("ui_events")); + } + + #[test] + fn test_confirm_action_response_with_ui_events() { + let response = ConfirmActionResponse { + success: true, + message: "Created".to_string(), + action: "test".to_string(), + record_id: None, + ui_events: Some(vec![UiEvent { + event_type: "show_confirmation".to_string(), + target: "modal".to_string(), + record_id: Some("123".to_string()), + fields: None, + }]), + }; + let json = serde_json::to_string(&response).unwrap(); + assert!(json.contains("ui_events")); + assert!(json.contains("show_confirmation")); + } + + #[test] + fn test_chat_message_request_full() { + let request = ChatMessageRequest { + message: "Hello AI".to_string(), + user_id: Some("user-123".to_string()), + conversation_id: Some("conv-456".to_string()), + }; + let json = serde_json::to_string(&request).unwrap(); + let parsed: ChatMessageRequest = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.message, "Hello AI"); + assert_eq!(parsed.user_id, Some("user-123".to_string())); + } + + #[test] + fn test_empty_conversation_id() { + let request = ChatMessageRequest { + message: "Test".to_string(), + user_id: None, + conversation_id: None, + }; + assert!(request.conversation_id.is_none()); + assert!(request.user_id.is_none()); + } + + #[test] + fn test_action_feature_code_matches_action() { + let registry = get_action_registry(); + for action in ®istry { + if action.action_code.contains("job") { + assert!(action.feature_code.contains("job") || action.feature_code.contains("ai"), + "Action {} has job in name but feature_code doesn't match", action.action_code); + } + } + } + + #[test] + fn test_professional_roles_have_improve_profile() { + let professional_roles = vec![ + "PHOTOGRAPHER", "MAKEUP_ARTIST", "TUTOR", "DEVELOPER", + "VIDEO_EDITOR", "GRAPHIC_DESIGNER", "SOCIAL_MEDIA_MANAGER", + "FITNESS_TRAINER", "CATERING_SERVICES", "UGC_CONTENT_CREATOR" + ]; + for role in professional_roles { + let actions = get_actions_for_role(role); + let has_improve = actions.iter().any(|a| a.action_code.contains("improve")); + assert!(has_improve, "Role {} should have improve action", role); + } + } + + #[test] + fn test_company_exclusive_actions() { + let actions = get_actions_for_role("COMPANY"); + let company_only = vec!["generate_job_description", "create_job_draft", "fill_company_profile_form", "improve_company_profile"]; + for action_code in company_only { + let action = get_action(action_code).unwrap(); + assert!(action.allowed_roles.contains(&"COMPANY".to_string())); + assert!(!action.allowed_roles.contains(&"JOB_SEEKER".to_string())); + } + } + + #[test] + fn test_job_seeker_exclusive_actions() { + let job_seeker_only = vec!["generate_cover_letter", "improve_resume_summary", "improve_jobseeker_profile"]; + for action_code in job_seeker_only { + let action = get_action(action_code).unwrap(); + assert!(action.allowed_roles.contains(&"JOB_SEEKER".to_string())); + assert!(!action.allowed_roles.contains(&"COMPANY".to_string())); + } + } + + #[test] + fn test_customer_exclusive_actions() { + let customer_only = vec!["create_customer_requirement_draft", "improve_requirement_description"]; + for action_code in customer_only { + let action = get_action(action_code).unwrap(); + assert!(action.allowed_roles.contains(&"CUSTOMER".to_string())); + assert!(!action.allowed_roles.contains(&"COMPANY".to_string())); + } + } + + #[test] + fn test_admin_employee_support_actions() { + let support_actions = vec!["support_ticket_summary"]; + for action_code in support_actions { + let action = get_action(action_code).unwrap(); + assert!(action.allowed_roles.contains(&"ADMIN".to_string())); + assert!(action.allowed_roles.contains(&"EMPLOYEE".to_string())); + } + } + + #[test] + fn test_no_role_has_all_actions() { + let all_roles = vec![ + "COMPANY", "JOB_SEEKER", "CUSTOMER", "ADMIN", "EMPLOYEE", + "PHOTOGRAPHER", "MAKEUP_ARTIST", "TUTOR", "DEVELOPER", + "VIDEO_EDITOR", "GRAPHIC_DESIGNER", "SOCIAL_MEDIA_MANAGER", + "FITNESS_TRAINER", "CATERING_SERVICES", "UGC_CONTENT_CREATOR" + ]; + let registry = get_action_registry(); + for role in all_roles { + let role_actions = get_actions_for_role(role); + assert!(role_actions.len() < registry.len(), "Role {} should not have all actions", role); + } + } + + #[test] + fn test_generate_cover_letter_cost() { + let action = get_action("generate_cover_letter").unwrap(); + assert_eq!(action.ai_action_cost, 2); + assert!(action.uses_llm); + } + + #[test] + fn test_support_ticket_summary_cost() { + let action = get_action("support_ticket_summary").unwrap(); + assert_eq!(action.ai_action_cost, 1); + assert!(action.uses_llm); + } + + #[test] + fn test_request_professional_contact_cost() { + let action = get_action("request_professional_contact").unwrap(); + assert_eq!(action.ai_action_cost, 0); + assert!(!action.uses_llm); + } + + #[test] + fn test_check_ai_pack_balance_cost() { + let action = get_action("check_ai_pack_balance").unwrap(); + assert_eq!(action.ai_action_cost, 0); + assert!(!action.uses_llm); + } + + #[test] + fn test_explain_plan_limits_cost() { + let action = get_action("explain_plan_limits").unwrap(); + assert_eq!(action.ai_action_cost, 0); + assert!(!action.uses_llm); + } + + #[test] + fn test_search_kb_cost() { + let action = get_action("search_kb").unwrap(); + assert_eq!(action.ai_action_cost, 0); + assert!(!action.uses_llm); + } + + #[test] + fn test_create_job_draft_cost() { + let action = get_action("create_job_draft").unwrap(); + assert_eq!(action.ai_action_cost, 2); + assert!(action.uses_llm); + assert!(action.requires_confirmation); + } + + #[test] + fn test_improve_resume_summary_cost() { + let action = get_action("improve_resume_summary").unwrap(); + assert_eq!(action.ai_action_cost, 2); + assert!(action.uses_llm); + assert!(action.requires_confirmation); + } + + #[test] + fn test_generate_service_description_cost() { + let action = get_action("generate_service_description").unwrap(); + assert_eq!(action.ai_action_cost, 2); + assert!(action.uses_llm); + assert!(!action.requires_confirmation); + assert!(action.requires_verification); + } +}