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