Add AI endpoints and gateway route fix
- 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
This commit is contained in:
parent
8b87b3bb53
commit
aa71ccdf36
8 changed files with 1313 additions and 23 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -2144,6 +2144,7 @@ dependencies = [
|
|||
"anyhow",
|
||||
"axum",
|
||||
"chrono",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
|
|
|
|||
|
|
@ -198,6 +198,10 @@ impl Services {
|
|||
else if path.starts_with("/api/credits") {
|
||||
Some(self.payments_url.clone())
|
||||
}
|
||||
// ── AI Chat (routes to users service, which calls Ollama directly) ───
|
||||
else if path.starts_with("/api/ai") {
|
||||
Some(self.users_url.clone())
|
||||
}
|
||||
// Admin runtime config management defaults to users service
|
||||
else if path.starts_with("/api/admin/runtime-configs") {
|
||||
Some(self.users_url.clone())
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ anyhow = { workspace = true }
|
|||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
tower-http = { version = "0.6", features = ["cors", "trace"] }
|
||||
reqwest = { workspace = true }
|
||||
|
||||
[[bin]]
|
||||
name = "leads"
|
||||
|
|
|
|||
|
|
@ -24,6 +24,13 @@ pub struct SendLeadRequestPayload {
|
|||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SendLeadRequestAiPayload {
|
||||
pub lead_id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub profession_key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, FromRow)]
|
||||
pub struct LeadRequestRow {
|
||||
pub id: Uuid,
|
||||
|
|
@ -64,6 +71,7 @@ pub fn router() -> Router<Arc<AppState>> {
|
|||
Router::new()
|
||||
.route("/", get(list_lead_requests))
|
||||
.route("/send", post(send_lead_request))
|
||||
.route("/send-ai", post(send_lead_request_ai))
|
||||
.route("/{id}/accept", post(accept_lead_request))
|
||||
.route("/{id}/reject", post(reject_lead_request))
|
||||
.route("/my-requests", get(my_requests))
|
||||
|
|
@ -272,6 +280,204 @@ async fn send_lead_request(
|
|||
}
|
||||
}
|
||||
|
||||
async fn send_lead_request_ai(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(payload): Json<SendLeadRequestAiPayload>,
|
||||
) -> impl IntoResponse {
|
||||
let user_id = payload.user_id;
|
||||
|
||||
let lead = match sqlx::query_as::<_, (Uuid, String, String, String, String, Option<i32>, Option<i32>)>(
|
||||
"SELECT id, title, description, location, profession_key, budget_min, budget_max FROM leads WHERE id = $1"
|
||||
)
|
||||
.bind(payload.lead_id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await
|
||||
{
|
||||
Ok(Some(l)) => l,
|
||||
Ok(None) => return (StatusCode::NOT_FOUND, "Lead not found").into_response(),
|
||||
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
};
|
||||
|
||||
if lead.4 != payload.profession_key {
|
||||
return (StatusCode::BAD_REQUEST, "Lead profession does not match your profile").into_response();
|
||||
}
|
||||
|
||||
let user_role_profile_id: Uuid = match sqlx::query_scalar::<_, Uuid>(
|
||||
"SELECT id FROM user_role_profiles WHERE user_id = $1 LIMIT 1"
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await
|
||||
{
|
||||
Ok(Some(id)) => id,
|
||||
Ok(None) => return (StatusCode::NOT_FOUND, "Professional profile not found").into_response(),
|
||||
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
};
|
||||
|
||||
let existing = match sqlx::query_scalar::<_, Uuid>(
|
||||
"SELECT id FROM lead_requests WHERE lead_id = $1 AND user_role_profile_id = $2 AND status IN ('PENDING', 'ACCEPTED')"
|
||||
)
|
||||
.bind(payload.lead_id)
|
||||
.bind(user_role_profile_id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await
|
||||
{
|
||||
Ok(Some(_)) => true,
|
||||
Ok(None) => false,
|
||||
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
};
|
||||
if existing {
|
||||
return (StatusCode::CONFLICT, "You have already sent a request for this lead").into_response();
|
||||
}
|
||||
|
||||
let wallet = match sqlx::query_as::<_, (Uuid, i64)>(
|
||||
"SELECT id, balance FROM tracecoin_wallets WHERE user_id = $1"
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await
|
||||
{
|
||||
Ok(Some(w)) => w,
|
||||
Ok(None) => return (StatusCode::BAD_REQUEST, "Wallet not found").into_response(),
|
||||
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
};
|
||||
|
||||
let tracecoins_cost = 30;
|
||||
if wallet.1 < tracecoins_cost as i64 {
|
||||
return (StatusCode::PAYMENT_REQUIRED, format!("Insufficient balance. You need {} Tracecoins.", tracecoins_cost)).into_response();
|
||||
}
|
||||
|
||||
let budget = match (lead.5, lead.6) {
|
||||
(Some(min), Some(max)) => format!("Budget: ₹{}-₹{}", min, max),
|
||||
(Some(min), None) => format!("Budget: ₹{} onwards", min),
|
||||
_ => "Budget: Not specified".to_string(),
|
||||
};
|
||||
|
||||
let prompt = format!(
|
||||
"You are a professional {} responding to a potential client's lead/request.\n\n\
|
||||
IMPORTANT: Do NOT include phone number, email, or any contact information in your response. \
|
||||
Clients pay to view contact details through the platform.\n\n\
|
||||
LEAD DETAILS:\n\
|
||||
Title: {}\n\
|
||||
Description: {}\n\
|
||||
Location: {}\n\
|
||||
{}\n\n\
|
||||
Write a professional, friendly message (max 150 words) expressing your interest and qualifications. \
|
||||
Mention relevant experience and ask any clarifying questions. Be concise and compelling.",
|
||||
payload.profession_key.replace("_", " "),
|
||||
lead.1,
|
||||
lead.2,
|
||||
lead.3,
|
||||
budget
|
||||
);
|
||||
|
||||
let ai_message = match generate_ai_message(&state.http_client, &state.ollama_base_url, &state.ollama_model, &prompt).await {
|
||||
Ok(msg) => msg,
|
||||
Err(e) => {
|
||||
tracing::error!("AI message generation failed: {}", e);
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, "AI generation failed").into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let expires_at = chrono::Utc::now() + chrono::Duration::hours(24);
|
||||
|
||||
let result = sqlx::query_as::<_, LeadRequestRow>(
|
||||
r#"
|
||||
INSERT INTO lead_requests (lead_id, user_role_profile_id, customer_user_id, status, tracecoins_reserved, message, expires_at)
|
||||
VALUES ($1, $2, $3, 'PENDING', $4, $5, $6)
|
||||
RETURNING *
|
||||
"#
|
||||
)
|
||||
.bind(payload.lead_id)
|
||||
.bind(user_role_profile_id)
|
||||
.bind(user_id)
|
||||
.bind(tracecoins_cost)
|
||||
.bind(&ai_message)
|
||||
.bind(expires_at)
|
||||
.fetch_one(&state.pool)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(req) => {
|
||||
let _ = sqlx::query(
|
||||
r#"
|
||||
UPDATE tracecoin_wallets SET
|
||||
balance = balance - $1,
|
||||
reserved = COALESCE(reserved, 0) + $1,
|
||||
updated_at = NOW()
|
||||
WHERE user_id = $2
|
||||
"#
|
||||
)
|
||||
.bind(tracecoins_cost as i64)
|
||||
.bind(user_id)
|
||||
.execute(&state.pool)
|
||||
.await;
|
||||
|
||||
let _ = sqlx::query(
|
||||
r#"
|
||||
INSERT INTO notifications (user_id, title, body, notification_type, reference_id)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
"#
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind("AI Auto-Respond Sent")
|
||||
.bind("Your AI-assisted response has been sent to the customer.")
|
||||
.bind("LEAD_REQUEST")
|
||||
.bind(req.id)
|
||||
.execute(&state.pool)
|
||||
.await;
|
||||
|
||||
let response = lead_request_to_response(req);
|
||||
(StatusCode::CREATED, Json(response)).into_response()
|
||||
}
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn generate_ai_message(
|
||||
client: &reqwest::Client,
|
||||
base_url: &str,
|
||||
model: &str,
|
||||
prompt: &str,
|
||||
) -> Result<String, String> {
|
||||
#[derive(Serialize)]
|
||||
struct GenerateRequest<'a> {
|
||||
model: &'a str,
|
||||
prompt: String,
|
||||
stream: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct GenerateResponse {
|
||||
response: String,
|
||||
}
|
||||
|
||||
let url = format!("{}/api/generate", base_url.trim_end_matches('/'));
|
||||
let req = GenerateRequest {
|
||||
model,
|
||||
prompt: prompt.to_string(),
|
||||
stream: false,
|
||||
};
|
||||
|
||||
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: GenerateResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("failed to parse ollama response: {}", e))?;
|
||||
|
||||
Ok(result.response.trim().to_string())
|
||||
}
|
||||
|
||||
async fn accept_lead_request(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<Uuid>,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ use axum::{
|
|||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::PgPool;
|
||||
use std::net::SocketAddr;
|
||||
|
|
@ -16,6 +17,9 @@ pub mod lead_requests;
|
|||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub pool: PgPool,
|
||||
pub http_client: reqwest::Client,
|
||||
pub ollama_base_url: String,
|
||||
pub ollama_model: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
|
||||
|
|
@ -110,7 +114,14 @@ async fn main() {
|
|||
|
||||
tracing::info!("Connected to database");
|
||||
|
||||
let state = Arc::new(AppState { pool });
|
||||
let state = Arc::new(AppState {
|
||||
pool,
|
||||
http_client: Client::new(),
|
||||
ollama_base_url: std::env::var("OLLAMA_BASE_URL")
|
||||
.unwrap_or_else(|_| "http://ollama.nxtgauge-ai.svc.cluster.local:11434".to_string()),
|
||||
ollama_model: std::env::var("OLLAMA_CHAT_MODEL")
|
||||
.unwrap_or_else(|_| "gemma3:270m".to_string()),
|
||||
});
|
||||
|
||||
let cors = CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
9
crates/db/migrations/20260425000000_ai_usage.down.sql
Normal file
9
crates/db/migrations/20260425000000_ai_usage.down.sql
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
BEGIN;
|
||||
|
||||
ALTER TABLE job_applications DROP COLUMN IF EXISTS applied_via_ai;
|
||||
ALTER TABLE job_seeker_profiles DROP COLUMN IF EXISTS has_ai_pack;
|
||||
|
||||
DROP TABLE IF EXISTS company_ai_usage;
|
||||
DROP TABLE IF EXISTS job_seeker_ai_usage;
|
||||
|
||||
COMMIT;
|
||||
38
crates/db/migrations/20260425000000_ai_usage.up.sql
Normal file
38
crates/db/migrations/20260425000000_ai_usage.up.sql
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
-- AI usage tracking for companies and job seekers
|
||||
-- Supports per-day rate limiting for AI generation features
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- Track AI usage per company per day
|
||||
CREATE TABLE IF NOT EXISTS company_ai_usage (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
company_id UUID NOT NULL REFERENCES company_profiles(id) ON DELETE CASCADE,
|
||||
usage_date DATE NOT NULL DEFAULT CURRENT_DATE,
|
||||
generations_used INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(company_id, usage_date)
|
||||
);
|
||||
|
||||
-- Track AI usage per job seeker per day
|
||||
CREATE TABLE IF NOT EXISTS job_seeker_ai_usage (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
job_seeker_id UUID NOT NULL REFERENCES job_seeker_profiles(id) ON DELETE CASCADE,
|
||||
usage_date DATE NOT NULL DEFAULT CURRENT_DATE,
|
||||
generations_used INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(job_seeker_id, usage_date)
|
||||
);
|
||||
|
||||
-- Indexes for fast lookups
|
||||
CREATE INDEX IF NOT EXISTS idx_company_ai_usage_company_date ON company_ai_usage(company_id, usage_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_job_seeker_ai_usage_seeker_date ON job_seeker_ai_usage(job_seeker_id, usage_date);
|
||||
|
||||
-- Add applied_via_ai flag to job_applications for AI auto-apply tracking
|
||||
ALTER TABLE job_applications ADD COLUMN IF NOT EXISTS applied_via_ai BOOLEAN DEFAULT false;
|
||||
|
||||
-- Add ai_pack field to job_seeker_profiles for quick lookup (cached from pricing_packages)
|
||||
ALTER TABLE job_seeker_profiles ADD COLUMN IF NOT EXISTS has_ai_pack BOOLEAN DEFAULT false;
|
||||
|
||||
COMMIT;
|
||||
Loading…
Add table
Reference in a new issue