fix(ai): align backend plans and clean warnings

This commit is contained in:
Ashwin Kumar Sivakumar 2026-06-15 09:23:44 +05:30
parent 09465824ad
commit 06e73eebb5
17 changed files with 514 additions and 261 deletions

View file

@ -5,6 +5,7 @@ use sqlx::PgPool;
use uuid::Uuid; use uuid::Uuid;
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[allow(dead_code)]
struct AutoApplyConfig { struct AutoApplyConfig {
litellm_base_url: String, litellm_base_url: String,
litellm_api_key: String, litellm_api_key: String,
@ -139,6 +140,7 @@ struct JobSeekerWithAi {
} }
#[derive(Debug, sqlx::FromRow)] #[derive(Debug, sqlx::FromRow)]
#[allow(dead_code)]
struct NewJob { struct NewJob {
id: Uuid, id: Uuid,
title: String, title: String,
@ -169,11 +171,15 @@ pub async fn run_auto_apply(pool: &PgPool) -> Result<(), Box<dyn std::error::Err
COALESCE(jsp.experience_years, 0) as experience_years, COALESCE(jsp.experience_years, 0) as experience_years,
jsp.summary, jsp.summary,
COALESCE(( COALESCE((
SELECT p.monthly_action_limit / 30 SELECT p.daily_action_limit
FROM ai_entitlements e FROM user_ai_subscriptions s
JOIN ai_plans p ON p.id = e.plan_id JOIN ai_plans p ON p.id = s.plan_id
WHERE e.user_id = u.id AND e.role = 'JOB_SEEKER' AND e.status = 'active' WHERE s.user_id = u.id
ORDER BY e.valid_from DESC AND COALESCE(s.role_code, 'JOB_SEEKER') = 'JOB_SEEKER'
AND s.status = 'active'
AND NOW() >= s.current_period_start
AND NOW() < s.current_period_end
ORDER BY s.updated_at DESC
LIMIT 1 LIMIT 1
), 10) as daily_limit, ), 10) as daily_limit,
COALESCE(( COALESCE((

View file

@ -145,6 +145,7 @@ pub async fn charge_feature(
/// Validate that a user could afford a feature without charging. Useful for /// Validate that a user could afford a feature without charging. Useful for
/// pre-flight checks in streaming endpoints. /// pre-flight checks in streaming endpoints.
#[allow(dead_code)]
pub async fn can_afford(pool: &PgPool, user_id: Uuid, feature_code: &str) -> Result<bool, CreditError> { pub async fn can_afford(pool: &PgPool, user_id: Uuid, feature_code: &str) -> Result<bool, CreditError> {
let cost = get_feature_cost(pool, feature_code).await?; let cost = get_feature_cost(pool, feature_code).await?;
let sub = UserAiSubscriptionRepository::get_by_user_id(pool, user_id) let sub = UserAiSubscriptionRepository::get_by_user_id(pool, user_id)

View file

@ -182,6 +182,7 @@ impl LiteLlmClient {
} }
/// Direct pass-through for callers that want the raw JSON response. /// Direct pass-through for callers that want the raw JSON response.
#[allow(dead_code)]
pub async fn raw_chat_completion(&self, body: Value) -> Result<Value, LiteLlmError> { pub async fn raw_chat_completion(&self, body: Value) -> Result<Value, LiteLlmError> {
let url = format!("{}/v1/chat/completions", self.base_url); let url = format!("{}/v1/chat/completions", self.base_url);

View file

@ -3,7 +3,6 @@ use axum::http::StatusCode;
use axum::middleware::Next; use axum::middleware::Next;
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use contracts::auth_middleware::AuthUser; use contracts::auth_middleware::AuthUser;
use sqlx::PgPool;
use std::future::Future; use std::future::Future;
use uuid::Uuid; use uuid::Uuid;
@ -13,6 +12,7 @@ use crate::AppState;
/// Extractor that ensures the user has an active AI subscription and is not a /// Extractor that ensures the user has an active AI subscription and is not a
/// customer. It can be combined with `AuthUser` in handlers that need it. /// customer. It can be combined with `AuthUser` in handlers that need it.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct AiAccess { pub struct AiAccess {
pub user_id: Uuid, pub user_id: Uuid,
pub role_code: Option<String>, pub role_code: Option<String>,
@ -20,6 +20,7 @@ pub struct AiAccess {
pub plan: db::models::ai::AiPlan, pub plan: db::models::ai::AiPlan,
} }
#[allow(dead_code)]
impl AiAccess { impl AiAccess {
/// Remaining credits from the user's subscription. /// Remaining credits from the user's subscription.
pub fn remaining_credits(&self) -> i32 { pub fn remaining_credits(&self) -> i32 {
@ -69,10 +70,10 @@ where
) )
.map_err(|_| AiAccessError::InvalidToken)?; .map_err(|_| AiAccessError::InvalidToken)?;
let user_id = Uuid::parse_str(&token_data.claims.sub) let _user_id = Uuid::parse_str(&token_data.claims.sub)
.map_err(|_| AiAccessError::InvalidToken)?; .map_err(|_| AiAccessError::InvalidToken)?;
let role_code = token_data.claims.active_role.clone(); let _role_code = token_data.claims.active_role.clone();
// State is not available via FromRequestParts, so we cannot load // State is not available via FromRequestParts, so we cannot load
// the subscription here. Use the Layer middleware below for full checks. // the subscription here. Use the Layer middleware below for full checks.
@ -82,6 +83,7 @@ where
} }
#[derive(Debug)] #[derive(Debug)]
#[allow(dead_code)]
pub enum AiAccessError { pub enum AiAccessError {
MissingToken, MissingToken,
InvalidToken, InvalidToken,

View file

@ -59,6 +59,7 @@ pub fn resolve_model(
/// Map a feature code to a suggested model tier for cases where the caller /// Map a feature code to a suggested model tier for cases where the caller
/// wants to force higher quality (e.g., long-form generation). /// wants to force higher quality (e.g., long-form generation).
#[allow(dead_code)]
pub fn preferred_tier_for_feature(feature_code: &str) -> ModelTier { pub fn preferred_tier_for_feature(feature_code: &str) -> ModelTier {
match feature_code { match feature_code {
"jd_generate" "jd_generate"
@ -72,6 +73,7 @@ pub fn preferred_tier_for_feature(feature_code: &str) -> ModelTier {
/// Convenience resolver that selects the best model alias for a feature, /// Convenience resolver that selects the best model alias for a feature,
/// preferring Main for complex features unless explicitly overridden. /// preferring Main for complex features unless explicitly overridden.
#[allow(dead_code)]
pub fn resolve_best_model( pub fn resolve_best_model(
feature: &AiFeatureCost, feature: &AiFeatureCost,
plan: &AiPlan, plan: &AiPlan,

View file

@ -46,6 +46,7 @@ impl IntoResponse for AiCallError {
} }
} }
#[allow(dead_code)]
pub struct AiCallResult { pub struct AiCallResult {
pub text: String, pub text: String,
pub model_alias: String, pub model_alias: String,
@ -230,6 +231,7 @@ pub async fn call_feature_with_plan(
/// Validate that a user can use a feature. Returns the resolved model and /// Validate that a user can use a feature. Returns the resolved model and
/// cost without charging. Useful for streaming pre-flights. /// cost without charging. Useful for streaming pre-flights.
#[allow(dead_code)]
pub async fn check_feature_access( pub async fn check_feature_access(
pool: &PgPool, pool: &PgPool,
user_id: Uuid, user_id: Uuid,
@ -252,6 +254,7 @@ pub async fn check_feature_access(
/// Helper used by handlers to respond with a JSON body that includes remaining /// Helper used by handlers to respond with a JSON body that includes remaining
/// credits for the frontend. /// credits for the frontend.
#[allow(dead_code)]
pub fn success_json( pub fn success_json(
result: &AiCallResult, result: &AiCallResult,
extra: serde_json::Value, extra: serde_json::Value,
@ -277,6 +280,7 @@ pub fn success_json(
} }
/// Log a failed AI call without charging credits. /// Log a failed AI call without charging credits.
#[allow(dead_code)]
pub async fn log_failed_call( pub async fn log_failed_call(
pool: &PgPool, pool: &PgPool,
user_id: Uuid, user_id: Uuid,

View file

@ -103,6 +103,7 @@ pub fn require_feature(plan: &AiPlan, feature_code: &str) -> Result<(), PlanErro
} }
} }
#[allow(dead_code)]
pub fn require_model(plan: &AiPlan, model_alias: &str) -> Result<(), PlanError> { pub fn require_model(plan: &AiPlan, model_alias: &str) -> Result<(), PlanError> {
if is_model_allowed(plan, model_alias) { if is_model_allowed(plan, model_alias) {
Ok(()) Ok(())

View file

@ -9,11 +9,10 @@ use axum::{
}; };
use contracts::auth_middleware::{require_admin, AuthUser}; use contracts::auth_middleware::{require_admin, AuthUser};
use db::models::ai::{ use db::models::ai::{
AiCreditTransactionRepository, AiFeatureCost, AiFeatureCostRepository, AiPlanRepository, AiCreditTransactionRepository, AiFeatureCostRepository, AiPlanRepository,
AiUsageLogRepository, UserAiSubscriptionRepository, AiUsageLogRepository, UserAiSubscriptionRepository,
}; };
use serde::{Deserialize, Serialize}; use serde::Deserialize;
use sqlx::PgPool;
use uuid::Uuid; use uuid::Uuid;
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]

View file

@ -536,6 +536,7 @@ async fn update_email_config(
#[derive(Deserialize)] #[derive(Deserialize)]
struct EmailTestRequest { struct EmailTestRequest {
to_email: String, to_email: String,
#[allow(dead_code)]
provider: Option<String>, provider: Option<String>,
config: Option<EmailTestConfig>, config: Option<EmailTestConfig>,
} }
@ -559,7 +560,7 @@ async fn test_email_connection(
Json(req): Json<EmailTestRequest>, Json(req): Json<EmailTestRequest>,
) -> impl IntoResponse { ) -> impl IntoResponse {
// Send a test email using current or provided config // Send a test email using current or provided config
let result = if let Some(test_config) = req.config { let result = if let Some(_test_config) = req.config {
// For now, just use the existing mailer - test config would require recreating mailer // For now, just use the existing mailer - test config would require recreating mailer
state.mail.send_test_email(&req.to_email).await state.mail.send_test_email(&req.to_email).await
} else { } else {

View file

@ -9,12 +9,15 @@ use axum::{
}; };
use cache::ai as ai_cache; use cache::ai as ai_cache;
use contracts::auth_middleware::AuthUser; use contracts::auth_middleware::AuthUser;
use db::models::ai::{
AiCreditPackageRepository, AiCreditTransactionRepository, AiPlanRepository,
AiUsageLogRepository, UserAiSubscriptionRepository,
};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use std::sync::Arc;
use uuid::Uuid; use uuid::Uuid;
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
#[allow(dead_code)]
struct KbArticleRow { struct KbArticleRow {
id: Uuid, id: Uuid,
title: String, title: String,
@ -51,6 +54,7 @@ struct OllamaGenerateResponse {
response: String, response: String,
} }
#[allow(dead_code)]
async fn call_ollama(_state: &AppState, model: &str, prompt: &str) -> Result<String, 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 base_url = std::env::var("OLLAMA_BASE_URL").unwrap_or_else(|_| "http://ollama.nxtgauge-ai.svc.cluster.local:11434".to_string());
call_ollama_inline(&base_url, model, prompt).await call_ollama_inline(&base_url, model, prompt).await
@ -299,7 +303,7 @@ fn get_llm_base_url() -> String {
let llm_provider = std::env::var("LLM_PROVIDER").unwrap_or_else(|_| "ollama".to_string()); let llm_provider = std::env::var("LLM_PROVIDER").unwrap_or_else(|_| "ollama".to_string());
if llm_provider == "litellm" { if llm_provider == "litellm" {
std::env::var("LITELLM_BASE_URL") std::env::var("LITELLM_BASE_URL")
.unwrap_or_else(|_| "https://llm.nxtgauge.com/v1".to_string()) .unwrap_or_else(|_| "http://litellm.nxtgauge-ai.svc.cluster.local:4000/v1".to_string())
} else { } else {
std::env::var("OLLAMA_BASE_URL") std::env::var("OLLAMA_BASE_URL")
.unwrap_or_else(|_| "http://ollama.nxtgauge-ai.svc.cluster.local:11434".to_string()) .unwrap_or_else(|_| "http://ollama.nxtgauge-ai.svc.cluster.local:11434".to_string())
@ -447,7 +451,7 @@ async fn ai_chat_message(
None => classify_intent(&body.message, &ollama_base, &model).await, None => classify_intent(&body.message, &ollama_base, &model).await,
}; };
let (response_text, ollama_used) = match intent.as_str() { let (response_text, _ollama_used) = match intent.as_str() {
"help_search" => { "help_search" => {
let q = body.message.to_lowercase(); let q = body.message.to_lowercase();
let rows = sqlx::query_as::<_, KbArticleRow>( let rows = sqlx::query_as::<_, KbArticleRow>(
@ -1043,6 +1047,7 @@ fn from_orchestrator_result(
} }
} }
#[allow(dead_code)]
fn fallback_response( fn fallback_response(
generated_text: String, generated_text: String,
remaining_today: i32, remaining_today: i32,
@ -1082,7 +1087,7 @@ async fn ai_generate_job_field(
return (StatusCode::FORBIDDEN, Json(serde_json::json!({ "error": "No company profile found" }))).into_response(); return (StatusCode::FORBIDDEN, Json(serde_json::json!({ "error": "No company profile found" }))).into_response();
}; };
let (has_pack, daily_limit) = { let (_has_pack, daily_limit) = {
let profile_id: Option<Uuid> = sqlx::query_scalar( let profile_id: Option<Uuid> = sqlx::query_scalar(
"SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = 'COMPANY'" "SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = 'COMPANY'"
) )
@ -1099,7 +1104,7 @@ async fn ai_generate_job_field(
}; };
let mut redis = state.redis.clone(); let mut redis = state.redis.clone();
let (used, limit) = match check_and_increment_usage(&state.pool, &mut redis, company_id, true, daily_limit).await { let (_used, _limit) = match check_and_increment_usage(&state.pool, &mut redis, company_id, true, daily_limit).await {
Ok((u, l)) => (u, l), Ok((u, l)) => (u, l),
Err(msg) => { Err(msg) => {
return (StatusCode::TOO_MANY_REQUESTS, Json(serde_json::json!({ "error": msg }))).into_response(); return (StatusCode::TOO_MANY_REQUESTS, Json(serde_json::json!({ "error": msg }))).into_response();
@ -1201,7 +1206,7 @@ async fn ai_generate_cover_letter(
return (StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "Job not found" }))).into_response(); return (StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "Job not found" }))).into_response();
}; };
let (has_pack, daily_limit) = { let (_has_pack, daily_limit) = {
let profile_id: Option<Uuid> = sqlx::query_scalar( let profile_id: Option<Uuid> = sqlx::query_scalar(
"SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = 'JOB_SEEKER'" "SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = 'JOB_SEEKER'"
) )
@ -1218,15 +1223,15 @@ async fn ai_generate_cover_letter(
}; };
let mut redis = state.redis.clone(); let mut redis = state.redis.clone();
let (used, limit) = match check_and_increment_usage(&state.pool, &mut redis, seeker_id, false, daily_limit).await { let (_used, _limit) = match check_and_increment_usage(&state.pool, &mut redis, seeker_id, false, daily_limit).await {
Ok((u, l)) => (u, l), Ok((u, l)) => (u, l),
Err(msg) => { Err(msg) => {
return (StatusCode::TOO_MANY_REQUESTS, Json(serde_json::json!({ "error": msg }))).into_response(); return (StatusCode::TOO_MANY_REQUESTS, Json(serde_json::json!({ "error": msg }))).into_response();
} }
}; };
let ollama_base = get_llm_base_url(); let _ollama_base = get_llm_base_url();
let model = get_llm_model(); let _model = get_llm_model();
let notes = body.additional_notes.as_deref().unwrap_or(""); let notes = body.additional_notes.as_deref().unwrap_or("");
let skills_str = skills.join(", "); let skills_str = skills.join(", ");
@ -1319,7 +1324,7 @@ async fn ai_tailor_resume(
return (StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "Job not found" }))).into_response(); return (StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "Job not found" }))).into_response();
}; };
let (has_pack, daily_limit) = { let (_has_pack, daily_limit) = {
let profile_id: Option<Uuid> = sqlx::query_scalar( let profile_id: Option<Uuid> = sqlx::query_scalar(
"SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = 'JOB_SEEKER'" "SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = 'JOB_SEEKER'"
) )
@ -1336,15 +1341,15 @@ async fn ai_tailor_resume(
}; };
let mut redis = state.redis.clone(); let mut redis = state.redis.clone();
let (used, limit) = match check_and_increment_usage(&state.pool, &mut redis, seeker_id, false, daily_limit).await { let (_used, _limit) = match check_and_increment_usage(&state.pool, &mut redis, seeker_id, false, daily_limit).await {
Ok((u, l)) => (u, l), Ok((u, l)) => (u, l),
Err(msg) => { Err(msg) => {
return (StatusCode::TOO_MANY_REQUESTS, Json(serde_json::json!({ "error": msg }))).into_response(); return (StatusCode::TOO_MANY_REQUESTS, Json(serde_json::json!({ "error": msg }))).into_response();
} }
}; };
let ollama_base = get_llm_base_url(); let _ollama_base = get_llm_base_url();
let model = get_llm_model(); let _model = get_llm_model();
let existing_resume = body.resume_text.as_deref().unwrap_or("Not provided"); let existing_resume = body.resume_text.as_deref().unwrap_or("Not provided");
let skills_str = skills.join(", "); let skills_str = skills.join(", ");
@ -1438,7 +1443,7 @@ async fn ai_auto_apply(
return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "Complete your profile (name and skills required) before auto-applying" }))).into_response(); 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 (_has_pack, daily_limit) = {
let profile_id: Option<Uuid> = sqlx::query_scalar( let profile_id: Option<Uuid> = sqlx::query_scalar(
"SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = 'JOB_SEEKER'" "SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = 'JOB_SEEKER'"
) )
@ -1472,8 +1477,8 @@ async fn ai_auto_apply(
return (StatusCode::TOO_MANY_REQUESTS, Json(serde_json::json!({ "error": format!("Only {} generations left today", remaining) }))).into_response(); return (StatusCode::TOO_MANY_REQUESTS, Json(serde_json::json!({ "error": format!("Only {} generations left today", remaining) }))).into_response();
} }
let ollama_base = get_llm_base_url(); let _ollama_base = get_llm_base_url();
let model = get_llm_model(); let _model = get_llm_model();
let skills_str = skills.join(", "); let skills_str = skills.join(", ");
// Pre-check credits for all requested applications before generating anything. // Pre-check credits for all requested applications before generating anything.
@ -1670,7 +1675,7 @@ async fn ai_auto_respond_to_lead(
.ok() .ok()
.flatten(); .flatten();
let (wallet_id, balance) = match wallet { let (_wallet_id, balance) = match wallet {
Some((id, bal)) => (id, bal), Some((id, bal)) => (id, bal),
None => return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "Wallet not found. Please contact support." }))).into_response(), None => return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "Wallet not found. Please contact support." }))).into_response(),
}; };
@ -1785,7 +1790,7 @@ async fn ai_usage_status(
}; };
let today = chrono::Utc::now().date_naive(); let today = chrono::Utc::now().date_naive();
let used: Option<i32> = if is_company { 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") sqlx::query_scalar("SELECT generations_used FROM company_ai_usage WHERE company_id = $1 AND usage_date = $2")
.bind(profile_id) .bind(profile_id)
.bind(today) .bind(today)
@ -1804,7 +1809,7 @@ async fn ai_usage_status(
}; };
let role_key = if is_company { "COMPANY" } else { "JOB_SEEKER" }; let role_key = if is_company { "COMPANY" } else { "JOB_SEEKER" };
let (has_pack, daily_limit) = { let (_has_pack, _daily_limit) = {
let urp_id: Option<Uuid> = sqlx::query_scalar( let urp_id: Option<Uuid> = sqlx::query_scalar(
"SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = $2" "SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = $2"
) )
@ -2123,7 +2128,7 @@ fn is_support_intent(message: &str) -> bool {
// ── Phase 2: auto-create a support ticket for support-intent queries ────────── // ── Phase 2: auto-create a support ticket for support-intent queries ──────────
#[derive(Debug, Serialize, Clone)] #[derive(Debug, Serialize, Clone)]
struct CreatedTicket { pub(crate) struct CreatedTicket {
id: Uuid, id: Uuid,
subject: String, subject: String,
status: String, status: String,
@ -2197,6 +2202,7 @@ fn build_persona_pillar_system_prompt(persona: Option<Persona>, pillar: Option<P
// ── Phase 2: HTTP client to Ollama with 30s timeout + fallback ─────────────── // ── Phase 2: HTTP client to Ollama with 30s timeout + fallback ───────────────
#[allow(dead_code)]
async fn ollama_generate_with_timeout( async fn ollama_generate_with_timeout(
base_url: &str, base_url: &str,
model: &str, model: &str,
@ -2384,7 +2390,7 @@ async fn ai_chat_ask(
let full_prompt = format!("{system_prompt}\n\n{user_block}\n\nAssistant:"); let full_prompt = format!("{system_prompt}\n\n{user_block}\n\nAssistant:");
let (response_text, model_used, credits_charged, remaining_credits, remaining_daily, request_id, ollama_used) = let (response_text, _model_used, _credits_charged, _remaining_credits, _remaining_daily, _request_id, ollama_used) =
match orchestrator::call_feature( match orchestrator::call_feature(
&state, &state,
&auth, &auth,
@ -3169,8 +3175,10 @@ pub mod phase3 {
/// high enough to skip Ollama entirely. The threshold is intentionally /// high enough to skip Ollama entirely. The threshold is intentionally
/// high (0.8) because we only want to short-circuit when the article /// high (0.8) because we only want to short-circuit when the article
/// is a clear answer. /// is a clear answer.
#[allow(dead_code)]
const KB_DIRECT_ANSWER_THRESHOLD: f32 = 0.8; const KB_DIRECT_ANSWER_THRESHOLD: f32 = 0.8;
#[allow(dead_code)]
pub async fn kb_rag_top( pub async fn kb_rag_top(
pool: &sqlx::PgPool, pool: &sqlx::PgPool,
decision: &RoutingDecision, decision: &RoutingDecision,
@ -3254,11 +3262,13 @@ pub mod phase3 {
} }
#[derive(Debug)] #[derive(Debug)]
#[allow(dead_code)]
pub enum RateLimitOutcome { pub enum RateLimitOutcome {
Allowed { remaining: i64 }, Allowed { remaining: i64 },
Limited { retry_after: i64 }, Limited { retry_after: i64 },
} }
#[allow(dead_code)]
pub fn rate_limit_response(retry_after: i64) -> axum::response::Response { pub fn rate_limit_response(retry_after: i64) -> axum::response::Response {
use axum::http::header; use axum::http::header;
let body = serde_json::json!({ let body = serde_json::json!({
@ -3278,6 +3288,7 @@ pub mod phase3 {
/// Tries models in order, falling back to the next on any error. /// Tries models in order, falling back to the next on any error.
/// Logs every attempt at warn level so we can graph primary-uptime /// Logs every attempt at warn level so we can graph primary-uptime
/// in Grafana later. /// in Grafana later.
#[allow(dead_code)]
pub async fn ollama_generate_with_fallback( pub async fn ollama_generate_with_fallback(
base_url: &str, base_url: &str,
primary_model: &str, primary_model: &str,
@ -3358,6 +3369,7 @@ pub mod phase3 {
pub message: String, pub message: String,
pub persona: Option<String>, pub persona: Option<String>,
pub pillar: Option<String>, pub pillar: Option<String>,
#[allow(dead_code)]
pub conversation_id: Option<String>, pub conversation_id: Option<String>,
} }
@ -3608,86 +3620,24 @@ pub mod phase3 {
} }
} }
/// GET /api/ai/usage — counts, limits, remaining quota. /// GET /api/ai/usage — credit-based usage, limits, and rate-limit windows.
/// Per-user, per-minute rate-limit window + Redis daily counter + DB counter.
/// Extended with plan info, addon_balance, and renewal_date from ai_entitlements.
pub async fn ai_usage( pub async fn ai_usage(
State(state): State<AppState>, State(state): State<AppState>,
auth: contracts::auth_middleware::AuthUser, auth: contracts::auth_middleware::AuthUser,
) -> impl axum::response::IntoResponse { ) -> impl axum::response::IntoResponse {
let today = chrono::Utc::now().date_naive(); let (sub, plan) = match crate::ai::plans::ensure_free_subscription(
let start_of_month = { &state.pool,
let today_str = today.format("%Y-%m-%d").to_string(); auth.user_id,
let year_month = &today_str[..7]; Some(&auth.claims.active_role),
let start_str = format!("{}-01", year_month); )
chrono::NaiveDate::parse_from_str(&start_str, "%Y-%m-%d").unwrap_or(today) .await
{
Ok(data) => data,
Err(e) => {
return (e.status_code(), axum::Json(e.error_body())).into_response();
}
}; };
let company_used: Option<i32> = sqlx::query_scalar(
"SELECT generations_used FROM company_ai_usage WHERE company_id = \
(SELECT id FROM company_profiles WHERE user_id = $1) AND usage_date = $2",
)
.bind(auth.user_id)
.bind(today)
.fetch_optional(&state.pool)
.await
.ok()
.flatten()
.flatten();
let seeker_used: Option<i32> = sqlx::query_scalar(
"SELECT generations_used FROM job_seeker_ai_usage WHERE job_seeker_id = \
(SELECT id FROM job_seeker_profiles WHERE user_id = $1) AND usage_date = $2",
)
.bind(auth.user_id)
.bind(today)
.fetch_optional(&state.pool)
.await
.ok()
.flatten()
.flatten();
let company_monthly_used: Option<i32> = sqlx::query_scalar(
"SELECT COALESCE(SUM(generations_used), 0) FROM company_ai_usage WHERE company_id = \
(SELECT id FROM company_profiles WHERE user_id = $1) AND usage_date >= $2",
)
.bind(auth.user_id)
.bind(start_of_month)
.fetch_optional(&state.pool)
.await
.ok()
.flatten()
.flatten();
let seeker_monthly_used: Option<i32> = sqlx::query_scalar(
"SELECT COALESCE(SUM(generations_used), 0) FROM job_seeker_ai_usage WHERE job_seeker_id = \
(SELECT id FROM job_seeker_profiles WHERE user_id = $1) AND usage_date >= $2",
)
.bind(auth.user_id)
.bind(start_of_month)
.fetch_optional(&state.pool)
.await
.ok()
.flatten()
.flatten();
let (plan_name, monthly_limit, addon_balance, renewal_date): (String, i32, i32, Option<chrono::NaiveDate>) = sqlx::query_as::<_, (String, i32, i32, Option<chrono::NaiveDate>)>(
r#"
SELECT p.name, e.monthly_limit, e.addon_balance, e.renewal_date
FROM ai_entitlements e
JOIN ai_plans p ON p.id = e.plan_id
WHERE e.user_id = $1
ORDER BY e.created_at DESC
LIMIT 1
"#,
)
.bind(auth.user_id)
.fetch_optional(&state.pool)
.await
.ok()
.flatten()
.unwrap_or_else(|| ("Free".to_string(), 50, 0, None));
let mut redis = state.redis.clone(); let mut redis = state.redis.clone();
let now_minute = chrono::Utc::now().timestamp() / 60; let now_minute = chrono::Utc::now().timestamp() / 60;
let chat_key = format!("rl:ai_chat:{}:{}", auth.user_id, now_minute); let chat_key = format!("rl:ai_chat:{}:{}", auth.user_id, now_minute);
@ -3697,46 +3647,25 @@ pub mod phase3 {
let chat_minute: i64 = redis.get(&chat_key).await.unwrap_or(0); let chat_minute: i64 = redis.get(&chat_key).await.unwrap_or(0);
let stream_minute: i64 = redis.get(&stream_key).await.unwrap_or(0); let stream_minute: i64 = redis.get(&stream_key).await.unwrap_or(0);
let daily_used = company_used.or(seeker_used).unwrap_or(0); let purchased_remaining =
let monthly_used = company_monthly_used.unwrap_or(0) + seeker_monthly_used.unwrap_or(0); (sub.purchased_credits_total - sub.purchased_credits_used).max(0);
let daily_limit = super::BASE_AI_LIMIT; let remaining_credits = crate::ai::credits::remaining_credits(&sub);
let remaining_daily_actions = crate::ai::credits::remaining_daily_actions(&sub, &plan);
// New plan-aware usage status.
let plan_status = match crate::ai::plans::ensure_free_subscription(
&state.pool,
auth.user_id,
Some(&auth.claims.active_role),
)
.await
{
Ok((sub, plan)) => serde_json::json!({
"plan_code": plan.code,
"plan_name": plan.name,
"remaining_daily_actions": crate::ai::credits::remaining_daily_actions(&sub, &plan),
"daily_action_limit": plan.daily_action_limit,
"remaining_credits": crate::ai::credits::remaining_credits(&sub),
"monthly_credits_total": sub.monthly_credits_total,
"monthly_credits_used": sub.monthly_credits_used,
"purchased_credits_total": sub.purchased_credits_total,
"purchased_credits_used": sub.purchased_credits_used,
"period_start": sub.current_period_start,
"period_end": sub.current_period_end,
}),
Err(e) => e.error_body(),
};
( (
axum::http::StatusCode::OK, axum::http::StatusCode::OK,
axum::Json(serde_json::json!({ axum::Json(serde_json::json!({
"user_id": auth.user_id, "user_id": auth.user_id,
"plan": plan_name, "plan": plan.name,
"monthly_limit": monthly_limit, "plan_code": plan.code,
"monthly_used": monthly_used, "monthly_limit": sub.monthly_credits_total,
"monthly_remaining": (monthly_limit - monthly_used).max(0), "monthly_used": sub.monthly_credits_used,
"daily_limit": daily_limit, "monthly_remaining": remaining_credits,
"daily_used": daily_used, "daily_limit": plan.daily_action_limit,
"addon_balance": addon_balance, "daily_used": sub.daily_actions_used,
"renewal_date": renewal_date, "daily_remaining": remaining_daily_actions,
"addon_balance": purchased_remaining,
"renewal_date": sub.current_period_end.date_naive(),
"rate_limits": { "rate_limits": {
"chat_per_minute": { "chat_per_minute": {
"used": chat_minute, "used": chat_minute,
@ -3749,9 +3678,22 @@ pub mod phase3 {
"remaining": (30 - stream_minute).max(0), "remaining": (30 - stream_minute).max(0),
}, },
}, },
"plan": plan_status, "plan_details": {
"plan_code": plan.code,
"plan_name": plan.name,
"remaining_daily_actions": remaining_daily_actions,
"daily_action_limit": plan.daily_action_limit,
"remaining_credits": remaining_credits,
"monthly_credits_total": sub.monthly_credits_total,
"monthly_credits_used": sub.monthly_credits_used,
"purchased_credits_total": sub.purchased_credits_total,
"purchased_credits_used": sub.purchased_credits_used,
"period_start": sub.current_period_start,
"period_end": sub.current_period_end,
},
})), })),
) )
.into_response()
} }
/// POST /api/ai/clear-history — GDPR right-to-erasure for AI history. /// POST /api/ai/clear-history — GDPR right-to-erasure for AI history.
@ -3796,73 +3738,124 @@ pub mod phase3 {
pub message: String, pub message: String,
} }
/// POST /api/ai/addons/purchase — purchase an addon pack to add generations /// POST /api/ai/addons/purchase — purchase a credit pack.
pub async fn ai_addon_purchase( pub async fn ai_addon_purchase(
State(state): State<AppState>, State(state): State<AppState>,
auth: contracts::auth_middleware::AuthUser, auth: contracts::auth_middleware::AuthUser,
Json(body): Json<AddonPurchaseBody>, Json(body): Json<AddonPurchaseBody>,
) -> impl axum::response::IntoResponse { ) -> impl axum::response::IntoResponse {
let addon_amount: i32 = match body.addon_code.as_str() { let code = body.addon_code.trim().to_uppercase();
"STARTER" => 100, let addon_amount = match code.as_str() {
"PRO" => 500, "STARTER" => 50,
"ENTERPRISE" => 2000, "GROWTH" => 150,
"POWER" => 500,
"ENTERPRISE" => 1000,
_ => { _ => {
return ( match AiCreditPackageRepository::list_active(&state.pool).await {
axum::http::StatusCode::BAD_REQUEST, Ok(packages) => packages
axum::Json(serde_json::json!({ .into_iter()
"success": false, .find(|pkg| {
"message": format!("Unknown addon code: {}", body.addon_code), pkg.name
})), .to_uppercase()
) .replace([' ', '-'], "_")
.into_response() .contains(&code)
})
.map(|pkg| pkg.credits),
Err(e) => {
tracing::error!("Failed to list AI credit packages: {}", e);
None
}
}
.unwrap_or(0)
} }
}; };
let result = sqlx::query_as::<_, (i32,)>( if addon_amount <= 0 {
r#" return (
UPDATE ai_entitlements axum::http::StatusCode::BAD_REQUEST,
SET addon_balance = addon_balance + $1, updated_at = NOW() axum::Json(serde_json::json!({
WHERE user_id = $2 AND status = 'active' "success": false,
RETURNING addon_balance "message": format!("Unknown addon code: {}", body.addon_code),
"#, })),
)
.bind(addon_amount)
.bind(auth.user_id)
.fetch_optional(&state.pool)
.await;
match result {
Ok(Some((new_balance,))) => (
axum::http::StatusCode::OK,
axum::Json(AddonPurchaseResponse {
success: true,
addon_balance: new_balance,
message: format!("Successfully purchased {} generations", addon_amount),
}),
) )
.into_response(), .into_response();
Ok(None) => ( }
axum::http::StatusCode::NOT_FOUND,
let _ = match crate::ai::plans::ensure_free_subscription(
&state.pool,
auth.user_id,
Some(&auth.claims.active_role),
)
.await
{
Ok(data) => data,
Err(e) => {
return (e.status_code(), axum::Json(e.error_body())).into_response();
}
};
if let Err(e) = UserAiSubscriptionRepository::add_purchased_credits(
&state.pool,
auth.user_id,
addon_amount,
)
.await
{
tracing::error!("ai_addon_purchase update failed: {}", e);
return (
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
axum::Json(AddonPurchaseResponse { axum::Json(AddonPurchaseResponse {
success: false, success: false,
addon_balance: 0, addon_balance: 0,
message: "No active AI entitlement found. Please upgrade your plan first.".to_string(), message: "Failed to process purchase".to_string(),
}), }),
) )
.into_response(), .into_response();
Err(e) => { }
tracing::error!("ai_addon_purchase failed: {}", e);
( let Some(updated_sub) = UserAiSubscriptionRepository::get_by_user_id(&state.pool, auth.user_id)
.await
.ok()
.flatten() else {
return (
axum::http::StatusCode::INTERNAL_SERVER_ERROR, axum::http::StatusCode::INTERNAL_SERVER_ERROR,
axum::Json(AddonPurchaseResponse { axum::Json(AddonPurchaseResponse {
success: false, success: false,
addon_balance: 0, addon_balance: 0,
message: "Failed to process purchase".to_string(), message: "Subscription not found after purchase".to_string(),
}), }),
) )
.into_response() .into_response();
} };
let purchased_remaining =
(updated_sub.purchased_credits_total - updated_sub.purchased_credits_used).max(0);
let remaining_credits = crate::ai::credits::remaining_credits(&updated_sub);
if let Err(e) = AiCreditTransactionRepository::create(
&state.pool,
auth.user_id,
"credit",
"purchase",
addon_amount,
remaining_credits,
None,
Some(&format!("addon_code={}", body.addon_code)),
)
.await
{
tracing::error!("Failed to record AI credit purchase transaction: {}", e);
} }
(
axum::http::StatusCode::OK,
axum::Json(AddonPurchaseResponse {
success: true,
addon_balance: purchased_remaining,
message: format!("Successfully added {} AI credits", addon_amount),
}),
)
.into_response()
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@ -3878,90 +3871,83 @@ pub mod phase3 {
pub message: String, pub message: String,
} }
/// POST /api/ai/plans/upgrade — upgrade the user's AI plan /// POST /api/ai/plans/upgrade — upgrade the user's AI plan.
pub async fn ai_plan_upgrade( pub async fn ai_plan_upgrade(
State(state): State<AppState>, State(state): State<AppState>,
auth: contracts::auth_middleware::AuthUser, auth: contracts::auth_middleware::AuthUser,
Json(body): Json<PlanUpgradeBody>, Json(body): Json<PlanUpgradeBody>,
) -> impl axum::response::IntoResponse { ) -> impl axum::response::IntoResponse {
let (plan_id, plan_name, monthly_limit): (uuid::Uuid, String, i32) = sqlx::query_as( let plan = match AiPlanRepository::get_by_code(&state.pool, &body.plan_code).await {
r#" Ok(Some(plan)) => plan,
SELECT id, name, monthly_action_limit Ok(None) => {
FROM ai_plans return (
WHERE code = $1 AND is_active = true axum::http::StatusCode::BAD_REQUEST,
"#, axum::Json(PlanUpgradeResponse {
success: false,
plan: "Free".to_string(),
monthly_limit: 10,
message: format!("Unknown or inactive plan: {}", body.plan_code),
}),
)
.into_response();
}
Err(e) => {
tracing::error!("Failed to resolve AI plan '{}': {}", body.plan_code, e);
return (
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
axum::Json(PlanUpgradeResponse {
success: false,
plan: "Unknown".to_string(),
monthly_limit: 0,
message: "Failed to upgrade plan".to_string(),
}),
)
.into_response();
}
};
let _ = match crate::ai::plans::ensure_free_subscription(
&state.pool,
auth.user_id,
Some(&auth.claims.active_role),
) )
.bind(&body.plan_code)
.fetch_optional(&state.pool)
.await .await
.ok() {
.flatten() Ok(data) => data,
.map(|(id, name, limit): (uuid::Uuid, String, i32)| (id, name, limit)) Err(e) => {
.unwrap_or_else(|| (uuid::Uuid::nil(), "Free".to_string(), 50)); return (e.status_code(), axum::Json(e.error_body())).into_response();
}
};
if plan_id == uuid::Uuid::nil() { let (period_start, period_end) = crate::ai::plans::current_monthly_period(chrono::Utc::now());
return ( match UserAiSubscriptionRepository::update_plan(
axum::http::StatusCode::BAD_REQUEST, &state.pool,
axum::Json(PlanUpgradeResponse { auth.user_id,
success: false, plan.id,
plan: "Free".to_string(), plan.monthly_credits,
monthly_limit: 50, period_start,
message: format!("Unknown or inactive plan: {}", body.plan_code), period_end,
}),
)
.into_response();
}
let result = sqlx::query_as::<_, (String, i32)>(
r#"
UPDATE ai_entitlements
SET plan_id = $1,
monthly_action_limit = $2,
valid_from = NOW(),
updated_at = NOW()
WHERE user_id = $3 AND status = 'active'
RETURNING p.name, e.monthly_action_limit
FROM ai_plans p
WHERE p.id = e.plan_id
"#,
) )
.bind(plan_id) .await
.bind(monthly_limit) {
.bind(auth.user_id) Ok(_) => (
.fetch_optional(&state.pool)
.await;
match result {
Ok(Some((name, limit))) => (
axum::http::StatusCode::OK, axum::http::StatusCode::OK,
axum::Json(PlanUpgradeResponse { axum::Json(PlanUpgradeResponse {
success: true, success: true,
plan: name, plan: plan.name.clone(),
monthly_limit: limit, monthly_limit: plan.monthly_credits,
message: format!("Successfully upgraded to {}", plan_name), message: format!("Successfully upgraded to {}", plan.name),
}), }),
) )
.into_response(), .into_response(),
Ok(None) => {
(
axum::http::StatusCode::NOT_FOUND,
axum::Json(PlanUpgradeResponse {
success: false,
plan: plan_name,
monthly_limit,
message: "No active AI entitlement found. Please purchase a plan first.".to_string(),
}),
)
.into_response()
}
Err(e) => { Err(e) => {
tracing::error!("ai_plan_upgrade failed: {}", e); tracing::error!("ai_plan_upgrade failed: {}", e);
( (
axum::http::StatusCode::INTERNAL_SERVER_ERROR, axum::http::StatusCode::INTERNAL_SERVER_ERROR,
axum::Json(PlanUpgradeResponse { axum::Json(PlanUpgradeResponse {
success: false, success: false,
plan: plan_name, plan: plan.name,
monthly_limit, monthly_limit: plan.monthly_credits,
message: "Failed to upgrade plan".to_string(), message: "Failed to upgrade plan".to_string(),
}), }),
) )
@ -4039,6 +4025,240 @@ pub mod phase3 {
} }
} }
#[derive(Debug, Deserialize)]
struct CompanyJobDescriptionBody {
context: String,
#[serde(default)]
model: Option<String>,
}
#[derive(Debug, Serialize)]
struct AiCreditBalanceResponse {
remaining_credits: i32,
monthly_credits_total: i32,
monthly_credits_used: i32,
purchased_credits_total: i32,
purchased_credits_used: i32,
daily_limit: i32,
daily_used: i32,
plan_code: String,
plan_name: String,
}
#[derive(Debug, Deserialize)]
struct UsageLogsQuery {
#[serde(default = "default_usage_limit")]
limit: i64,
#[serde(default)]
offset: i64,
}
fn default_usage_limit() -> i64 {
25
}
async fn ai_form_fill(
state: State<AppState>,
body: Json<FormExtractBody>,
) -> impl IntoResponse {
ai_extract_form(state, body).await
}
async fn ai_form_validate(
state: State<AppState>,
body: Json<FormExtractBody>,
) -> impl IntoResponse {
ai_extract_form(state, body).await
}
async fn ai_help_ask(
state: State<AppState>,
auth: AuthUser,
body: Json<AskAshRequest>,
) -> impl IntoResponse {
ai_chat_ask(state, auth, body).await
}
async fn ai_company_generate_description(
State(state): State<AppState>,
auth: AuthUser,
Json(body): Json<CompanyJobDescriptionBody>,
) -> impl IntoResponse {
match orchestrator::call_feature(
&state,
&auth,
"jd_generate",
None,
&format!(
"Generate a professional job description with sections: Summary, Responsibilities, Requirements, Skills, Experience, Location. Do not invent salary or company details. Context: {}",
body.context
),
body.model.as_deref(),
None,
)
.await
{
Ok(r) => (
StatusCode::OK,
Json(serde_json::json!({
"message": r.text,
"generated_text": r.text,
"credits_charged": r.credits_charged,
"remaining_credits": r.remaining_credits,
"remaining_today": r.remaining_daily_actions,
"model": r.model_alias,
"feature_code": "jd_generate"
})),
)
.into_response(),
Err(e) => e.into_response(),
}
}
async fn ai_company_improve_description(
State(state): State<AppState>,
auth: AuthUser,
Json(body): Json<CompanyJobDescriptionBody>,
) -> impl IntoResponse {
match orchestrator::call_feature(
&state,
&auth,
"jd_improve",
None,
&format!(
"Improve the following job description. Keep it concise, professional, and structured. Do not invent salary or company details. Description: {}",
body.context
),
body.model.as_deref(),
None,
)
.await
{
Ok(r) => (
StatusCode::OK,
Json(serde_json::json!({
"message": r.text,
"generated_text": r.text,
"credits_charged": r.credits_charged,
"remaining_credits": r.remaining_credits,
"remaining_today": r.remaining_daily_actions,
"model": r.model_alias,
"feature_code": "jd_improve"
})),
)
.into_response(),
Err(e) => e.into_response(),
}
}
async fn ai_company_extract_skills(
State(state): State<AppState>,
auth: AuthUser,
Json(body): Json<CompanyJobDescriptionBody>,
) -> impl IntoResponse {
match orchestrator::call_feature(
&state,
&auth,
"skills_extract",
None,
&format!(
"Extract only the relevant job skills from this text. Return a comma-separated list and nothing else. Text: {}",
body.context
),
body.model.as_deref(),
None,
)
.await
{
Ok(r) => (
StatusCode::OK,
Json(serde_json::json!({
"message": r.text,
"skills": r.text,
"credits_charged": r.credits_charged,
"remaining_credits": r.remaining_credits,
"remaining_today": r.remaining_daily_actions,
"model": r.model_alias,
"feature_code": "skills_extract"
})),
)
.into_response(),
Err(e) => e.into_response(),
}
}
async fn ai_credits_balance(
State(state): State<AppState>,
auth: AuthUser,
) -> impl IntoResponse {
match plans::ensure_free_subscription(&state.pool, auth.user_id, Some(&auth.claims.active_role)).await {
Ok((sub, plan)) => (
StatusCode::OK,
Json(AiCreditBalanceResponse {
remaining_credits: credits::remaining_credits(&sub),
monthly_credits_total: sub.monthly_credits_total,
monthly_credits_used: sub.monthly_credits_used,
purchased_credits_total: sub.purchased_credits_total,
purchased_credits_used: sub.purchased_credits_used,
daily_limit: plan.daily_action_limit,
daily_used: sub.daily_actions_used,
plan_code: plan.code,
plan_name: plan.name,
}),
)
.into_response(),
Err(e) => (e.status_code(), Json(e.error_body())).into_response(),
}
}
async fn ai_plans_list(
State(state): State<AppState>,
_auth: AuthUser,
) -> impl IntoResponse {
match AiPlanRepository::list_active(&state.pool).await {
Ok(rows) => (StatusCode::OK, Json(serde_json::json!({ "plans": rows }))).into_response(),
Err(e) => {
tracing::error!("Failed to list AI plans: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "Failed to list AI plans" })),
)
.into_response()
}
}
}
async fn ai_usage_logs(
State(state): State<AppState>,
auth: AuthUser,
axum::extract::Query(query): axum::extract::Query<UsageLogsQuery>,
) -> impl IntoResponse {
let limit = query.limit.clamp(1, 100);
let offset = query.offset.max(0);
match AiUsageLogRepository::list_by_user(&state.pool, auth.user_id, limit, offset).await {
Ok(rows) => (
StatusCode::OK,
Json(serde_json::json!({
"logs": rows,
"limit": limit,
"offset": offset,
})),
)
.into_response(),
Err(e) => {
tracing::error!("Failed to list AI usage logs: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "Failed to list AI usage logs" })),
)
.into_response()
}
}
}
// ── Wire the new Phase 3 endpoints into the router ─────────────────────────── // ── Wire the new Phase 3 endpoints into the router ───────────────────────────
// //
// We wrap the Phase 3 handlers in a private `phase3_router()` so the existing // We wrap the Phase 3 handlers in a private `phase3_router()` so the existing
@ -4057,29 +4277,37 @@ async fn phase3_chat_stream(
pub fn ai_router() -> Router<AppState> { pub fn ai_router() -> Router<AppState> {
Router::new() Router::new()
.route("/chat/message", post(ai_chat_message)) .route("/chat/message", post(ai_chat_message))
// ── Ask Ash: Phase 2 endpoints (personas + pillars) ─────────────────
.route("/chat/ask", post(ai_chat_ask)) .route("/chat/ask", post(ai_chat_ask))
.route("/help/ask", post(ai_help_ask))
.route("/suggestions", get(ai_suggestions)) .route("/suggestions", get(ai_suggestions))
.route("/context", post(ai_save_context)) .route("/context", post(ai_save_context))
.route("/history", get(ai_history)) .route("/history", get(ai_history))
.route("/tickets/create", post(ai_create_ticket)) .route("/tickets/create", post(ai_create_ticket))
.route("/tickets/{id}", get(ai_get_ticket)) .route("/tickets/{id}", get(ai_get_ticket))
.route("/forms/extract", post(ai_extract_form)) .route("/forms/extract", post(ai_extract_form))
.route("/forms/fill", post(ai_form_fill))
.route("/forms/validate", post(ai_form_validate))
.route("/generate-job-field", post(ai_generate_job_field)) .route("/generate-job-field", post(ai_generate_job_field))
.route("/generate-cover-letter", post(ai_generate_cover_letter)) .route("/generate-cover-letter", post(ai_generate_cover_letter))
.route("/tailor-resume", post(ai_tailor_resume)) .route("/tailor-resume", post(ai_tailor_resume))
.route("/auto-apply", post(ai_auto_apply)) .route("/auto-apply", post(ai_auto_apply))
.route("/auto-respond-to-lead", post(ai_auto_respond_to_lead)) .route("/auto-respond-to-lead", post(ai_auto_respond_to_lead))
.route("/usage", get(ai_usage_status)) .route("/usage", get(ai_usage_status))
// ── Phase 3: streaming, feedback, usage, GDPR clear ─────────────── .route("/usage/summary", get(phase3::ai_usage))
.route("/usage/logs", get(ai_usage_logs))
.route("/credits/balance", get(ai_credits_balance))
.route("/plans", get(ai_plans_list))
.route("/company/jobs/generate-description", post(ai_company_generate_description))
.route("/company/jobs/improve-description", post(ai_company_improve_description))
.route("/company/jobs/extract-skills", post(ai_company_extract_skills))
.route("/chat/stream", post(phase3_chat_stream)) .route("/chat/stream", post(phase3_chat_stream))
.route("/feedback", post(phase3::ai_feedback)) .route("/feedback", post(phase3::ai_feedback))
.route("/usage/v2", get(phase3::ai_usage)) .route("/usage/v2", get(phase3::ai_usage))
.route("/clear-history", axum::routing::post(phase3::ai_clear_history)) .route("/clear-history", axum::routing::post(phase3::ai_clear_history))
// ── Addon purchase and plan upgrade ──────────────────────────────────
.route("/addons/purchase", post(phase3::ai_addon_purchase)) .route("/addons/purchase", post(phase3::ai_addon_purchase))
.route("/credits/buy", post(phase3::ai_addon_purchase))
.route("/plans/upgrade", post(phase3::ai_plan_upgrade)) .route("/plans/upgrade", post(phase3::ai_plan_upgrade))
// ── Phase 4: multi-lang, voice, A/B, analytics, model swap, KB+ ─── .merge(crate::handlers::ai_auto::ai_auto_router())
.merge(crate::handlers::ai_phase4::phase4_router()) .merge(crate::handlers::ai_phase4::phase4_router())
.layer(axum::middleware::from_fn_with_state( .layer(axum::middleware::from_fn_with_state(
(), (),

View file

@ -12,7 +12,7 @@ use db::models::ai::{
AiAutoApplyLogRepository, AiAutoApplySettingsRepository, AiAutoRequestLogRepository, AiAutoApplyLogRepository, AiAutoApplySettingsRepository, AiAutoRequestLogRepository,
AiAutoRequestSettingsRepository, AiAutoRequestSettingsRepository,
}; };
use serde::{Deserialize, Serialize}; use serde::Deserialize;
use serde_json::Value; use serde_json::Value;
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]

View file

@ -21,7 +21,6 @@ use contracts::auth_middleware::AuthUser;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue; use serde_json::Value as JsonValue;
use sqlx::Row; use sqlx::Row;
use std::collections::HashMap;
use uuid::Uuid; use uuid::Uuid;
use crate::AppState; use crate::AppState;
@ -330,6 +329,7 @@ pub struct AbTest {
} }
#[derive(Debug, Serialize, sqlx::FromRow)] #[derive(Debug, Serialize, sqlx::FromRow)]
#[allow(dead_code)]
pub struct AbTestSummary { pub struct AbTestSummary {
pub name: String, pub name: String,
pub description: Option<String>, pub description: Option<String>,
@ -744,6 +744,7 @@ async fn analytics_personal(
/// Best-effort, idempotent aggregation of ai_conversations → ai_daily_stats. /// Best-effort, idempotent aggregation of ai_conversations → ai_daily_stats.
/// Called every 5 minutes by the background task in main.rs. /// Called every 5 minutes by the background task in main.rs.
#[allow(dead_code)]
pub async fn aggregate_daily_stats(pool: &sqlx::PgPool) -> Result<(), String> { pub async fn aggregate_daily_stats(pool: &sqlx::PgPool) -> Result<(), String> {
sqlx::query( sqlx::query(
r#" r#"
@ -791,11 +792,13 @@ pub fn estimate_tokens(text: &str) -> i32 {
/// Hard cap for total history size in tokens. When exceeded, older /// Hard cap for total history size in tokens. When exceeded, older
/// messages get summarised via Ollama. /// messages get summarised via Ollama.
#[allow(dead_code)]
pub const HISTORY_TOKEN_CAP: i32 = 2_000; pub const HISTORY_TOKEN_CAP: i32 = 2_000;
/// If the total history exceeds the cap, summarise the oldest 3 messages /// If the total history exceeds the cap, summarise the oldest 3 messages
/// into a single one-liner via Ollama and return (summary, trimmed_count). /// into a single one-liner via Ollama and return (summary, trimmed_count).
/// Returns `None` when no summarisation is needed or when Ollama is down. /// Returns `None` when no summarisation is needed or when Ollama is down.
#[allow(dead_code)]
pub async fn maybe_summarise_history( pub async fn maybe_summarise_history(
pool: &sqlx::PgPool, pool: &sqlx::PgPool,
user_id: Uuid, user_id: Uuid,

View file

@ -22,6 +22,7 @@ pub fn onboarding_router() -> Router<AppState> {
.route("/submit", post(submit)) .route("/submit", post(submit))
} }
#[allow(dead_code)]
pub fn me_router() -> Router<AppState> { pub fn me_router() -> Router<AppState> {
Router::new() Router::new()
.route("/profile-status", get(profile_status)) .route("/profile-status", get(profile_status))
@ -51,6 +52,7 @@ pub struct SubmitInput {
} }
#[derive(Serialize)] #[derive(Serialize)]
#[allow(dead_code)]
pub struct ProfileStatusResponse { pub struct ProfileStatusResponse {
pub onboarding_complete: bool, pub onboarding_complete: bool,
pub active_role: Option<String>, pub active_role: Option<String>,
@ -225,6 +227,7 @@ async fn submit(
} }
/// GET /api/me/profile-status /// GET /api/me/profile-status
#[allow(dead_code)]
async fn profile_status( async fn profile_status(
auth: AuthUser, auth: AuthUser,
State(state): State<AppState>, State(state): State<AppState>,

View file

@ -3,7 +3,7 @@ use axum::{
extract::{Path, Query, State}, extract::{Path, Query, State},
http::StatusCode, http::StatusCode,
response::IntoResponse, response::IntoResponse,
routing::{get, post}, routing::get,
Json, Router, Json, Router,
}; };
use contracts::auth_middleware::AuthUser; use contracts::auth_middleware::AuthUser;
@ -48,6 +48,7 @@ struct PublicReviewDto {
#[derive(Deserialize)] #[derive(Deserialize)]
struct CreateReviewBody { struct CreateReviewBody {
#[allow(dead_code)]
lead_request_id: Uuid, lead_request_id: Uuid,
rating: i16, rating: i16,
comment: Option<String>, comment: Option<String>,
@ -69,6 +70,7 @@ struct PublicListQuery {
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
struct ReviewRow { struct ReviewRow {
id: Uuid, id: Uuid,
#[allow(dead_code)]
lead_request_id: Uuid, lead_request_id: Uuid,
customer_id: Uuid, customer_id: Uuid,
professional_id: Uuid, professional_id: Uuid,

View file

@ -134,5 +134,5 @@ async fn main() {
tracing::info!("Users service listening on {}", addr); tracing::info!("Users service listening on {}", addr);
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap(); let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
let app = axum::serve(listener, app).await.unwrap(); let _app = axum::serve(listener, app).await.unwrap();
} }

View file

@ -49,6 +49,7 @@ pub struct GenerateResponse {
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[allow(dead_code)]
struct OllamaErrorResponse { struct OllamaErrorResponse {
error: String, error: String,
} }

View file

@ -6,7 +6,6 @@ use axum::{
Json, Router, Json, Router,
}; };
use bytes::BufMut; use bytes::BufMut;
use chrono::Utc;
use serde::Deserialize; use serde::Deserialize;
use uuid::Uuid; use uuid::Uuid;
use db::models::lead_request::{CreateLeadRequestPayload, LeadRequestRepository}; use db::models::lead_request::{CreateLeadRequestPayload, LeadRequestRepository};