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:
Tracewebstudio Dev 2026-05-01 02:54:42 +02:00
parent 8b87b3bb53
commit aa71ccdf36
8 changed files with 1313 additions and 23 deletions

1
Cargo.lock generated
View file

@ -2144,6 +2144,7 @@ dependencies = [
"anyhow",
"axum",
"chrono",
"reqwest",
"serde",
"serde_json",
"sqlx",

View file

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

View file

@ -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"

View file

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

View file

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

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

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