feat(ai): add missing intents, admin guards, and validation checks

- Add missing AI intents: generate_cover_letter, improve_resume, request_view_contact, auto_apply_job, unknown
- Add is_internal_admin helper to prevent admin/super_admin users from using user-facing AI flows
- Add admin guards to: ai_generate_job_field, ai_generate_cover_letter, ai_tailor_resume, ai_auto_apply, ai_auto_respond_to_lead
- Add professional approval check in ai_auto_respond_to_lead - must be APPROVED status
- Add tracecoin balance check before contact reveal (requires 30 tracecoins)
- Add KB escalation: when no articles found, suggest creating support ticket
- Add explicit unknown intent handler with helpful message
This commit is contained in:
Tracewebstudio Dev 2026-05-05 17:44:40 +02:00
parent 2aba45c9fa
commit f75a348fc7

View file

@ -95,7 +95,9 @@ async fn call_ollama(_state: &AppState, model: &str, prompt: &str) -> Result<Str
async fn classify_intent(message: &str, ollama_base: &str, model: &str) -> (String, f32) {
let prompt = format!(
"Classify this user message into one intent category. Categories: ticket_creation, form_filling, help_search, job_description_generation, general. \
"Classify this user message into one intent category. Categories: \
ticket_creation, form_filling, help_search, job_description_generation, \
generate_cover_letter, improve_resume, request_view_contact, auto_apply_job, unknown, general. \
Return ONLY the intent name, nothing else.\n\nMessage: {}",
message
);
@ -109,14 +111,27 @@ async fn classify_intent(message: &str, ollama_base: &str, model: &str) -> (Stri
"form_filling" => "form_filling",
"help_search" => "help_search",
"job_description_generation" => "job_description_generation",
"generate_cover_letter" => "generate_cover_letter",
"improve_resume" => "improve_resume",
"request_view_contact" => "request_view_contact",
"auto_apply_job" => "auto_apply_job",
"unknown" => "unknown",
_ => "general",
};
(intent.to_string(), confidence)
}
Err(_) => ("general".to_string(), 0.5),
Err(_) => ("unknown".to_string(), 0.0),
}
}
fn is_internal_admin(auth: &AuthUser) -> bool {
let active = auth.claims.active_role.as_str();
active == "ADMIN"
|| active == "SUPER_ADMIN"
|| auth.claims.roles.contains(&"ADMIN".to_string())
|| auth.claims.roles.contains(&"SUPER_ADMIN".to_string())
}
async fn call_ollama_inline(base_url: &str, model: &str, prompt: &str) -> Result<String, String> {
let url = format!("{}/api/generate", base_url);
let req = OllamaGenerateRequest {
@ -199,7 +214,8 @@ async fn ai_chat_message(
}
_ => {
"I couldn't find any help articles matching your question. \
Try rephrasing or contact support if you need further assistance."
If you need further assistance, I can help you create a support ticket instead. \
Just describe your issue and I'll guide you through the ticket creation process."
.to_string()
}
}
@ -247,6 +263,17 @@ async fn ai_chat_message(
}
}
}
"unknown" => {
"I'm not sure I understand your request. I can help you with:\n\n\
- Creating support tickets\n\
- Searching help articles\n\
- Generating job descriptions\n\
- Writing cover letters\n\
- Improving your resume\n\
- Applying to jobs\n\
- Requesting to view lead contacts\n\n\
Could you please rephrase your request?".to_string()
}
_ => {
let system_prompt = "You are a helpful AI assistant for Nxtgauge platform. Provide clear, concise responses. \
If the user needs support, guide them to create a ticket.";
@ -570,6 +597,10 @@ async fn ai_generate_job_field(
auth: AuthUser,
Json(body): Json<GenerateJobFieldBody>,
) -> impl IntoResponse {
if is_internal_admin(&auth) {
return (StatusCode::FORBIDDEN, Json(serde_json::json!({ "error": "Admin users cannot use AI job description generation. Use the admin panel to manage jobs." }))).into_response();
}
let company: Option<Uuid> = sqlx::query_scalar(
"SELECT id FROM company_profiles WHERE user_id = $1"
)
@ -667,6 +698,10 @@ async fn ai_generate_cover_letter(
auth: AuthUser,
Json(body): Json<CoverLetterBody>,
) -> impl IntoResponse {
if is_internal_admin(&auth) {
return (StatusCode::FORBIDDEN, Json(serde_json::json!({ "error": "Admin users cannot use AI cover letter generation." }))).into_response();
}
let seeker: Option<(Uuid, String, Option<String>, i32, Vec<String>)> = sqlx::query_as(
"SELECT id, full_name, summary, experience_years, skills FROM job_seeker_profiles WHERE user_id = $1"
)
@ -776,6 +811,10 @@ async fn ai_tailor_resume(
auth: AuthUser,
Json(body): Json<TailorResumeBody>,
) -> impl IntoResponse {
if is_internal_admin(&auth) {
return (StatusCode::FORBIDDEN, Json(serde_json::json!({ "error": "Admin users cannot use AI resume tailoring." }))).into_response();
}
let seeker: Option<(Uuid, String, Option<String>, i32, Vec<String>)> = sqlx::query_as(
"SELECT id, full_name, summary, experience_years, skills FROM job_seeker_profiles WHERE user_id = $1"
)
@ -892,6 +931,10 @@ async fn ai_auto_apply(
auth: AuthUser,
Json(body): Json<AutoApplyBody>,
) -> impl IntoResponse {
if is_internal_admin(&auth) {
return (StatusCode::FORBIDDEN, Json(serde_json::json!({ "error": "Admin users cannot use AI auto-apply." }))).into_response();
}
if body.job_ids.is_empty() || body.job_ids.len() > 10 {
return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "Select 1-10 jobs at a time" }))).into_response();
}
@ -1057,6 +1100,10 @@ async fn ai_auto_respond_to_lead(
auth: AuthUser,
Json(body): Json<AutoRespondToLeadBody>,
) -> impl IntoResponse {
if is_internal_admin(&auth) {
return (StatusCode::FORBIDDEN, Json(serde_json::json!({ "error": "Admin users cannot use AI contact reveal. Use the admin panel to manage leads." }))).into_response();
}
let leads_service_url = std::env::var("LEADS_SERVICE_URL")
.unwrap_or_else(|_| "http://localhost:9118".to_string());
@ -1074,6 +1121,42 @@ async fn ai_auto_respond_to_lead(
return (StatusCode::FORBIDDEN, Json(serde_json::json!({ "error": "Profile not found" }))).into_response();
};
let approval_status: Option<String> = sqlx::query_scalar(
"SELECT status FROM user_role_profiles WHERE id = $1"
)
.bind(profile_id)
.fetch_optional(&state.pool)
.await
.ok()
.flatten();
let Some(status) = approval_status else {
return (StatusCode::FORBIDDEN, Json(serde_json::json!({ "error": "Profile not found" }))).into_response();
};
if status != "APPROVED" {
return (StatusCode::FORBIDDEN, Json(serde_json::json!({ "error": "Your profile must be approved before you can request lead contact access. Please complete verification." }))).into_response();
}
let wallet: Option<(Uuid, i64)> = sqlx::query_as(
"SELECT id, balance FROM tracecoin_wallets WHERE user_id = $1"
)
.bind(auth.user_id)
.fetch_optional(&state.pool)
.await
.ok()
.flatten();
let (wallet_id, balance) = match wallet {
Some((id, bal)) => (id, bal),
None => return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "Wallet not found. Please contact support." }))).into_response(),
};
let tracecoins_cost = 30;
if balance < tracecoins_cost as i64 {
return (StatusCode::PAYMENT_REQUIRED, Json(serde_json::json!({ "error": format!("Insufficient balance. You need {} Tracecoins but have {}. Please top up your wallet.", tracecoins_cost, balance) }))).into_response();
}
let today = chrono::Utc::now().date_naive();
let used: Option<i32> = sqlx::query_scalar(
"SELECT generations_used FROM job_seeker_ai_usage WHERE job_seeker_id = $1 AND usage_date = $2"