Delete legacy code that used old company_ai_usage/job_seeker_ai_usage tables: - Remove has_active_ai_pack() - old AI_PACK pricing package check - Remove check_and_increment_usage() - legacy daily quota tracking - Remove BASE_AI_LIMIT, get_ai_limit_for_package constants/functions - Remove legacy queries from ai_auto_apply() and ai_usage_status() - Update auto_apply.rs to use user_ai_subscriptions.daily_actions_used instead of job_seeker_ai_usage table - Inline apply_scheduled_downgrades() and expire_trials() in cron tasks to remove dependency on users crate internal modules The new system uses user_ai_subscriptions with: - daily_actions_used / daily_credits_used counters - monthly_credits_total / monthly_credits_used - purchased_credits_total / purchased_credits_used All AI billing now flows through the wallet/ledger system with LiteLLM integration (Tasks 1-10).
414 lines
13 KiB
Rust
414 lines
13 KiB
Rust
use chrono::{Duration, Utc};
|
|
use reqwest::Client;
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::Value;
|
|
use sqlx::PgPool;
|
|
use uuid::Uuid;
|
|
|
|
#[derive(Debug)]
|
|
struct AutoApplyConfig {
|
|
litellm_base_url: String,
|
|
litellm_api_key: String,
|
|
litellm_model: String,
|
|
max_applications_per_run: usize,
|
|
}
|
|
|
|
impl AutoApplyConfig {
|
|
fn from_env() -> Self {
|
|
Self {
|
|
litellm_base_url: std::env::var("LITELLM_BASE_URL")
|
|
.unwrap_or_else(|_| "https://llm.nxtgauge.com/v1".to_string()),
|
|
litellm_api_key: std::env::var("LITELLM_API_KEY").unwrap_or_default(),
|
|
litellm_model: std::env::var("LITELLM_MODEL")
|
|
.unwrap_or_else(|_| "askash-main".to_string()),
|
|
max_applications_per_run: std::env::var("AUTO_APPLY_MAX_PER_RUN")
|
|
.unwrap_or_else(|_| "5".to_string())
|
|
.parse()
|
|
.unwrap_or(5),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
struct ChatMessage {
|
|
role: String,
|
|
content: String,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
struct ChatCompletionRequest {
|
|
model: String,
|
|
messages: Vec<ChatMessage>,
|
|
temperature: f32,
|
|
max_tokens: i32,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct ChatCompletionResponse {
|
|
choices: Vec<Choice>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct Choice {
|
|
message: Message,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct Message {
|
|
content: String,
|
|
}
|
|
|
|
async fn generate_cover_letter(
|
|
client: &Client,
|
|
config: &AutoApplyConfig,
|
|
seeker_name: &str,
|
|
experience: i32,
|
|
skills: &[String],
|
|
summary: Option<&str>,
|
|
job_title: &str,
|
|
job_desc: &str,
|
|
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
|
|
let url = format!("{}/chat/completions", config.litellm_base_url.trim_end_matches('/'));
|
|
|
|
let desc_excerpt = &job_desc[..job_desc.len().min(500)];
|
|
|
|
let prompt = format!(
|
|
"Write a brief, professional cover letter (max 200 words).\n\n\
|
|
IMPORTANT: Do NOT include phone number, email, or any contact information.\n\
|
|
Only use the information provided below.\n\n\
|
|
CANDIDATE: Name: {seeker_name}, Experience: {experience} years, \
|
|
Skills: {skills}, Summary: {summary}\n\
|
|
JOB: Title: {job_title}, Description: {desc_excerpt}\n\n\
|
|
Cover Letter:",
|
|
skills = skills.join(", "),
|
|
summary = summary.unwrap_or(""),
|
|
);
|
|
|
|
let payload = ChatCompletionRequest {
|
|
model: config.litellm_model.clone(),
|
|
messages: vec![
|
|
ChatMessage {
|
|
role: "system".to_string(),
|
|
content: "You are a professional cover letter writer.".to_string(),
|
|
},
|
|
ChatMessage {
|
|
role: "user".to_string(),
|
|
content: prompt,
|
|
},
|
|
],
|
|
temperature: 0.2,
|
|
max_tokens: 500,
|
|
};
|
|
|
|
let res = client
|
|
.post(&url)
|
|
.header("Authorization", format!("Bearer {}", config.litellm_api_key))
|
|
.header("Content-Type", "application/json")
|
|
.json(&payload)
|
|
.send()
|
|
.await?;
|
|
|
|
if !res.status().is_success() {
|
|
return Ok("I am excited to apply for this position.".to_string());
|
|
}
|
|
|
|
let body: ChatCompletionResponse = res.json().await?;
|
|
|
|
Ok(body
|
|
.choices
|
|
.into_iter()
|
|
.next()
|
|
.map(|c| c.message.content.trim().to_string())
|
|
.unwrap_or_else(|| "I am excited to apply for this position.".to_string()))
|
|
}
|
|
|
|
#[derive(Debug, sqlx::FromRow)]
|
|
struct EligibleSeeker {
|
|
user_id: Uuid,
|
|
profile_id: Uuid,
|
|
full_name: String,
|
|
experience_years: i32,
|
|
custom_data: Value,
|
|
daily_limit: i32,
|
|
used_today: i32,
|
|
available_credits: i32,
|
|
}
|
|
|
|
#[derive(Debug, sqlx::FromRow)]
|
|
struct MatchingJob {
|
|
id: Uuid,
|
|
title: String,
|
|
description: String,
|
|
}
|
|
|
|
const AUTO_APPLY_CREDIT_COST: i32 = 5;
|
|
|
|
pub async fn run_auto_apply(pool: &PgPool) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|
let config = AutoApplyConfig::from_env();
|
|
|
|
if config.litellm_api_key.is_empty() {
|
|
tracing::warn!("Auto-apply skipped: LITELLM_API_KEY not configured");
|
|
return Ok(());
|
|
}
|
|
|
|
tracing::info!("Starting auto-apply job...");
|
|
|
|
let client = Client::new();
|
|
let cutoff_time = Utc::now() - Duration::hours(24);
|
|
|
|
// Fetch job seekers who have auto-apply enabled and sufficient AI credits
|
|
let seekers: Vec<EligibleSeeker> = sqlx::query_as(
|
|
r#"
|
|
SELECT
|
|
u.id AS user_id,
|
|
js.id AS profile_id,
|
|
COALESCE(CONCAT(u.first_name, ' ', u.last_name), 'Candidate') AS full_name,
|
|
COALESCE(js.experience_years, 0) AS experience_years,
|
|
COALESCE(js.custom_data, '{}'::jsonb) AS custom_data,
|
|
COALESCE(uas.daily_action_limit, 3) AS daily_limit,
|
|
COALESCE(uas.daily_actions_used, 0) AS used_today,
|
|
COALESCE((
|
|
monthly_credits_total - monthly_credits_used
|
|
+ purchased_credits_total - purchased_credits_used
|
|
), 0) AS available_credits
|
|
FROM users u
|
|
INNER JOIN job_seeker_profiles js ON js.user_id = u.id
|
|
INNER JOIN ai_auto_apply_settings aas ON aas.user_id = u.id
|
|
INNER JOIN user_ai_subscriptions uas ON uas.user_id = u.id
|
|
WHERE u.status = 'ACTIVE'
|
|
AND aas.is_enabled = true
|
|
AND uas.status = 'active'
|
|
AND NOW() >= uas.current_period_start
|
|
AND NOW() < uas.current_period_end
|
|
"#,
|
|
)
|
|
.fetch_all(pool)
|
|
.await?;
|
|
|
|
if seekers.is_empty() {
|
|
tracing::info!("No eligible job seekers found for auto-apply");
|
|
return Ok(());
|
|
}
|
|
|
|
tracing::info!("{} job seekers eligible for auto-apply", seekers.len());
|
|
|
|
let mut total_applications = 0;
|
|
|
|
for seeker in &seekers {
|
|
let remaining_today = seeker.daily_limit - seeker.used_today;
|
|
if remaining_today <= 0 {
|
|
tracing::debug!("User {} hit daily auto-apply limit", seeker.user_id);
|
|
continue;
|
|
}
|
|
|
|
if seeker.available_credits < AUTO_APPLY_CREDIT_COST {
|
|
tracing::debug!("User {} has insufficient AI credits", seeker.user_id);
|
|
continue;
|
|
}
|
|
|
|
// Extract skills from custom_data -> job_seeker_portfolio -> skills
|
|
let portfolio = seeker
|
|
.custom_data
|
|
.get("job_seeker_portfolio")
|
|
.cloned()
|
|
.unwrap_or(Value::Null);
|
|
|
|
let skills: Vec<String> = portfolio
|
|
.get("skills")
|
|
.and_then(|v| v.as_array())
|
|
.map(|arr| {
|
|
arr.iter()
|
|
.filter_map(|s| s.as_str().map(|s| s.to_string()))
|
|
.collect()
|
|
})
|
|
.unwrap_or_default();
|
|
|
|
if skills.is_empty() {
|
|
tracing::debug!("User {} has no skills listed, skipping", seeker.user_id);
|
|
continue;
|
|
}
|
|
|
|
let summary = portfolio
|
|
.get("summary")
|
|
.and_then(|v| v.as_str())
|
|
.map(|s| s.to_string());
|
|
|
|
let max_to_apply = remaining_today
|
|
.min(config.max_applications_per_run as i32)
|
|
.min(seeker.available_credits / AUTO_APPLY_CREDIT_COST);
|
|
|
|
// Find LIVE jobs posted in last 24h matching seeker skills, not already applied to
|
|
let matching_jobs: Vec<MatchingJob> = sqlx::query_as(
|
|
r#"
|
|
SELECT j.id, j.title, j.description
|
|
FROM jobs j
|
|
INNER JOIN company_profiles c ON c.id = j.company_id
|
|
WHERE j.status = 'LIVE'
|
|
AND j.created_at > $1
|
|
AND c.status = 'ACTIVE'
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM job_applications ja
|
|
WHERE ja.job_id = j.id AND ja.applicant_user_id = $2
|
|
)
|
|
AND j.skills && $3::text[]
|
|
ORDER BY j.created_at DESC
|
|
LIMIT $4
|
|
"#,
|
|
)
|
|
.bind(cutoff_time)
|
|
.bind(seeker.user_id)
|
|
.bind(&skills)
|
|
.bind(max_to_apply)
|
|
.fetch_all(pool)
|
|
.await?;
|
|
|
|
if matching_jobs.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
tracing::info!(
|
|
"User {} matched {} new jobs",
|
|
seeker.user_id,
|
|
matching_jobs.len()
|
|
);
|
|
|
|
let mut credits_remaining = seeker.available_credits;
|
|
|
|
for job in &matching_jobs {
|
|
if credits_remaining < AUTO_APPLY_CREDIT_COST {
|
|
break;
|
|
}
|
|
|
|
let cover_letter = match generate_cover_letter(
|
|
&client,
|
|
&config,
|
|
&seeker.full_name,
|
|
seeker.experience_years,
|
|
&skills,
|
|
summary.as_deref(),
|
|
&job.title,
|
|
&job.description,
|
|
)
|
|
.await
|
|
{
|
|
Ok(cl) => cl,
|
|
Err(e) => {
|
|
tracing::warn!(
|
|
"Cover letter generation failed for user {} / job {}: {}",
|
|
seeker.user_id,
|
|
job.id,
|
|
e
|
|
);
|
|
"I am excited to apply for this position.".to_string()
|
|
}
|
|
};
|
|
|
|
// Insert application; ON CONFLICT DO NOTHING guards against race conditions
|
|
let applied = match sqlx::query(
|
|
r#"
|
|
INSERT INTO job_applications (job_id, applicant_user_id, cover_note, applied_via_ai)
|
|
VALUES ($1, $2, $3, true)
|
|
ON CONFLICT DO NOTHING
|
|
"#,
|
|
)
|
|
.bind(job.id)
|
|
.bind(seeker.user_id)
|
|
.bind(&cover_letter)
|
|
.execute(pool)
|
|
.await
|
|
{
|
|
Ok(r) => r.rows_affected() > 0,
|
|
Err(e) => {
|
|
tracing::error!(
|
|
"Failed to insert application for user {} / job {}: {}",
|
|
seeker.user_id,
|
|
job.id,
|
|
e
|
|
);
|
|
false
|
|
}
|
|
};
|
|
|
|
if !applied {
|
|
continue;
|
|
}
|
|
|
|
total_applications += 1;
|
|
credits_remaining -= AUTO_APPLY_CREDIT_COST;
|
|
|
|
// Log to ai_auto_apply_logs
|
|
sqlx::query(
|
|
r#"
|
|
INSERT INTO ai_auto_apply_logs
|
|
(user_id, job_id, match_score, status, credits_charged,
|
|
generated_cover_letter, applied_at)
|
|
VALUES ($1, $2, $3, 'applied', $4, $5, NOW())
|
|
"#,
|
|
)
|
|
.bind(seeker.user_id)
|
|
.bind(job.id)
|
|
.bind(75i32) // placeholder score; can be replaced with real ranking later
|
|
.bind(AUTO_APPLY_CREDIT_COST)
|
|
.bind(&cover_letter)
|
|
.execute(pool)
|
|
.await
|
|
.ok();
|
|
|
|
// Deduct credits (monthly pool first, then purchased)
|
|
sqlx::query(
|
|
r#"
|
|
UPDATE user_ai_subscriptions SET
|
|
monthly_credits_used = LEAST(
|
|
monthly_credits_used + $1,
|
|
monthly_credits_total
|
|
),
|
|
purchased_credits_used = purchased_credits_used + GREATEST(
|
|
0,
|
|
$1 - (monthly_credits_total - monthly_credits_used)
|
|
),
|
|
daily_actions_used = daily_actions_used + 1,
|
|
updated_at = NOW()
|
|
WHERE user_id = $2
|
|
AND status = 'active'
|
|
AND NOW() >= current_period_start
|
|
AND NOW() < current_period_end
|
|
"#,
|
|
)
|
|
.bind(AUTO_APPLY_CREDIT_COST)
|
|
.bind(seeker.user_id)
|
|
.execute(pool)
|
|
.await
|
|
.ok();
|
|
|
|
// Increment daily_actions_used on subscription
|
|
sqlx::query(
|
|
r#"
|
|
UPDATE user_ai_subscriptions
|
|
SET daily_actions_used = daily_actions_used + 1,
|
|
updated_at = NOW()
|
|
WHERE user_id = $1
|
|
AND status = 'active'
|
|
AND NOW() >= current_period_start
|
|
AND NOW() < current_period_end
|
|
"#,
|
|
)
|
|
.bind(seeker.user_id)
|
|
.execute(pool)
|
|
.await
|
|
.ok();
|
|
|
|
tracing::info!(
|
|
"Auto-applied user {} to job '{}' ({})",
|
|
seeker.user_id,
|
|
job.title,
|
|
job.id
|
|
);
|
|
}
|
|
}
|
|
|
|
tracing::info!(
|
|
"Auto-apply run complete. {} applications submitted.",
|
|
total_applications
|
|
);
|
|
Ok(())
|
|
}
|