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:
Tracewebstudio Dev 2026-06-14 20:27:52 +02:00
parent 510195ae24
commit 4505d8987e
16 changed files with 1259 additions and 12 deletions

View 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.

View 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.

View 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.

View 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.

View file

@ -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>>,
}

View file

@ -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
View 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>,
}

View 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))
}

View file

@ -1,4 +1,6 @@
pub mod actions;
pub mod chat;
pub mod confirm_action;
pub mod cover_letter;
pub mod forms;
pub mod health;

View file

@ -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
View 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());
}
}

View file

@ -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())

View 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
View file

@ -0,0 +1 @@
pub mod action_confirmation;

View file

@ -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
View 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 &registry {
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 &registry {
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 &registry {
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 &registry {
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);
}
}