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
This commit is contained in:
parent
510195ae24
commit
4505d8987e
16 changed files with 1259 additions and 12 deletions
8
prompts/v1/cover_letter_system.txt
Normal file
8
prompts/v1/cover_letter_system.txt
Normal file
|
|
@ -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.
|
||||
9
prompts/v1/form_extraction_system.txt
Normal file
9
prompts/v1/form_extraction_system.txt
Normal file
|
|
@ -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.
|
||||
1
prompts/v1/general_system.txt
Normal file
1
prompts/v1/general_system.txt
Normal file
|
|
@ -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.
|
||||
9
prompts/v1/job_description_system.txt
Normal file
9
prompts/v1/job_description_system.txt
Normal file
|
|
@ -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.
|
||||
|
|
@ -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<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ui_events: Option<Vec<UiEvent>>,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
32
src/handlers/actions.rs
Normal file
32
src/handlers/actions.rs
Normal file
|
|
@ -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<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ui_events: Option<Vec<UiEvent>>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub fields: Option<serde_json::Value>,
|
||||
}
|
||||
30
src/handlers/confirm_action.rs
Normal file
30
src/handlers/confirm_action.rs
Normal file
|
|
@ -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<AppState>,
|
||||
Json(request): Json<ConfirmActionRequest>,
|
||||
) -> Result<Json<ConfirmActionResponse>, 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))
|
||||
}
|
||||
|
|
@ -1,4 +1,6 @@
|
|||
pub mod actions;
|
||||
pub mod chat;
|
||||
pub mod confirm_action;
|
||||
pub mod cover_letter;
|
||||
pub mod forms;
|
||||
pub mod health;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
68
src/prompts.rs
Normal file
68
src/prompts.rs
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
static PROMPTS: OnceLock<HashMap<String, String>> = OnceLock::new();
|
||||
|
||||
pub fn load_prompts() -> &'static HashMap<String, String> {
|
||||
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<String, String>) {
|
||||
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<String> {
|
||||
load_prompts().get(key).cloned()
|
||||
}
|
||||
|
||||
pub fn get_prompt_with_version(key: &str, version: &str) -> Option<String> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
|
|
|
|||
263
src/services/action_confirmation.rs
Normal file
263
src/services/action_confirmation.rs
Normal file
|
|
@ -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<ConfirmActionResponse, AppError> {
|
||||
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<ConfirmActionResponse, AppError> {
|
||||
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<ConfirmActionResponse, AppError> {
|
||||
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<ConfirmActionResponse, AppError> {
|
||||
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<ConfirmActionResponse, AppError> {
|
||||
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<ConfirmActionResponse, AppError> {
|
||||
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<ConfirmActionResponse, AppError> {
|
||||
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<ConfirmActionResponse, AppError> {
|
||||
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)),
|
||||
}]),
|
||||
})
|
||||
}
|
||||
}
|
||||
1
src/services/mod.rs
Normal file
1
src/services/mod.rs
Normal file
|
|
@ -0,0 +1 @@
|
|||
pub mod action_confirmation;
|
||||
|
|
@ -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<dyn HelpCenterProvider>,
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
752
src/tests.rs
Normal file
752
src/tests.rs
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue