fix(ai): align backend plans and clean warnings
This commit is contained in:
parent
09465824ad
commit
06e73eebb5
17 changed files with 514 additions and 261 deletions
|
|
@ -5,6 +5,7 @@ use sqlx::PgPool;
|
|||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
struct AutoApplyConfig {
|
||||
litellm_base_url: String,
|
||||
litellm_api_key: String,
|
||||
|
|
@ -139,6 +140,7 @@ struct JobSeekerWithAi {
|
|||
}
|
||||
|
||||
#[derive(Debug, sqlx::FromRow)]
|
||||
#[allow(dead_code)]
|
||||
struct NewJob {
|
||||
id: Uuid,
|
||||
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,
|
||||
jsp.summary,
|
||||
COALESCE((
|
||||
SELECT p.monthly_action_limit / 30
|
||||
FROM ai_entitlements e
|
||||
JOIN ai_plans p ON p.id = e.plan_id
|
||||
WHERE e.user_id = u.id AND e.role = 'JOB_SEEKER' AND e.status = 'active'
|
||||
ORDER BY e.valid_from DESC
|
||||
SELECT p.daily_action_limit
|
||||
FROM user_ai_subscriptions s
|
||||
JOIN ai_plans p ON p.id = s.plan_id
|
||||
WHERE s.user_id = u.id
|
||||
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
|
||||
), 10) as daily_limit,
|
||||
COALESCE((
|
||||
|
|
|
|||
|
|
@ -145,6 +145,7 @@ pub async fn charge_feature(
|
|||
|
||||
/// Validate that a user could afford a feature without charging. Useful for
|
||||
/// 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> {
|
||||
let cost = get_feature_cost(pool, feature_code).await?;
|
||||
let sub = UserAiSubscriptionRepository::get_by_user_id(pool, user_id)
|
||||
|
|
|
|||
|
|
@ -182,6 +182,7 @@ impl LiteLlmClient {
|
|||
}
|
||||
|
||||
/// 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> {
|
||||
let url = format!("{}/v1/chat/completions", self.base_url);
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ use axum::http::StatusCode;
|
|||
use axum::middleware::Next;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use contracts::auth_middleware::AuthUser;
|
||||
use sqlx::PgPool;
|
||||
use std::future::Future;
|
||||
use uuid::Uuid;
|
||||
|
||||
|
|
@ -13,6 +12,7 @@ use crate::AppState;
|
|||
/// 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.
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(dead_code)]
|
||||
pub struct AiAccess {
|
||||
pub user_id: Uuid,
|
||||
pub role_code: Option<String>,
|
||||
|
|
@ -20,6 +20,7 @@ pub struct AiAccess {
|
|||
pub plan: db::models::ai::AiPlan,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl AiAccess {
|
||||
/// Remaining credits from the user's subscription.
|
||||
pub fn remaining_credits(&self) -> i32 {
|
||||
|
|
@ -69,10 +70,10 @@ where
|
|||
)
|
||||
.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)?;
|
||||
|
||||
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
|
||||
// the subscription here. Use the Layer middleware below for full checks.
|
||||
|
|
@ -82,6 +83,7 @@ where
|
|||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
#[allow(dead_code)]
|
||||
pub enum AiAccessError {
|
||||
MissingToken,
|
||||
InvalidToken,
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ pub fn resolve_model(
|
|||
|
||||
/// Map a feature code to a suggested model tier for cases where the caller
|
||||
/// wants to force higher quality (e.g., long-form generation).
|
||||
#[allow(dead_code)]
|
||||
pub fn preferred_tier_for_feature(feature_code: &str) -> ModelTier {
|
||||
match feature_code {
|
||||
"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,
|
||||
/// preferring Main for complex features unless explicitly overridden.
|
||||
#[allow(dead_code)]
|
||||
pub fn resolve_best_model(
|
||||
feature: &AiFeatureCost,
|
||||
plan: &AiPlan,
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ impl IntoResponse for AiCallError {
|
|||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub struct AiCallResult {
|
||||
pub text: 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
|
||||
/// cost without charging. Useful for streaming pre-flights.
|
||||
#[allow(dead_code)]
|
||||
pub async fn check_feature_access(
|
||||
pool: &PgPool,
|
||||
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
|
||||
/// credits for the frontend.
|
||||
#[allow(dead_code)]
|
||||
pub fn success_json(
|
||||
result: &AiCallResult,
|
||||
extra: serde_json::Value,
|
||||
|
|
@ -277,6 +280,7 @@ pub fn success_json(
|
|||
}
|
||||
|
||||
/// Log a failed AI call without charging credits.
|
||||
#[allow(dead_code)]
|
||||
pub async fn log_failed_call(
|
||||
pool: &PgPool,
|
||||
user_id: Uuid,
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
if is_model_allowed(plan, model_alias) {
|
||||
Ok(())
|
||||
|
|
|
|||
|
|
@ -9,11 +9,10 @@ use axum::{
|
|||
};
|
||||
use contracts::auth_middleware::{require_admin, AuthUser};
|
||||
use db::models::ai::{
|
||||
AiCreditTransactionRepository, AiFeatureCost, AiFeatureCostRepository, AiPlanRepository,
|
||||
AiCreditTransactionRepository, AiFeatureCostRepository, AiPlanRepository,
|
||||
AiUsageLogRepository, UserAiSubscriptionRepository,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::PgPool;
|
||||
use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
|
|
|||
|
|
@ -536,6 +536,7 @@ async fn update_email_config(
|
|||
#[derive(Deserialize)]
|
||||
struct EmailTestRequest {
|
||||
to_email: String,
|
||||
#[allow(dead_code)]
|
||||
provider: Option<String>,
|
||||
config: Option<EmailTestConfig>,
|
||||
}
|
||||
|
|
@ -559,7 +560,7 @@ async fn test_email_connection(
|
|||
Json(req): Json<EmailTestRequest>,
|
||||
) -> impl IntoResponse {
|
||||
// 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
|
||||
state.mail.send_test_email(&req.to_email).await
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -9,12 +9,15 @@ use axum::{
|
|||
};
|
||||
use cache::ai as ai_cache;
|
||||
use contracts::auth_middleware::AuthUser;
|
||||
use db::models::ai::{
|
||||
AiCreditPackageRepository, AiCreditTransactionRepository, AiPlanRepository,
|
||||
AiUsageLogRepository, UserAiSubscriptionRepository,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::PgPool;
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
#[allow(dead_code)]
|
||||
struct KbArticleRow {
|
||||
id: Uuid,
|
||||
title: String,
|
||||
|
|
@ -51,6 +54,7 @@ struct OllamaGenerateResponse {
|
|||
response: String,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
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());
|
||||
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());
|
||||
if llm_provider == "litellm" {
|
||||
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 {
|
||||
std::env::var("OLLAMA_BASE_URL")
|
||||
.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,
|
||||
};
|
||||
|
||||
let (response_text, ollama_used) = match intent.as_str() {
|
||||
let (response_text, _ollama_used) = match intent.as_str() {
|
||||
"help_search" => {
|
||||
let q = body.message.to_lowercase();
|
||||
let rows = sqlx::query_as::<_, KbArticleRow>(
|
||||
|
|
@ -1043,6 +1047,7 @@ fn from_orchestrator_result(
|
|||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn fallback_response(
|
||||
generated_text: String,
|
||||
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();
|
||||
};
|
||||
|
||||
let (has_pack, daily_limit) = {
|
||||
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'"
|
||||
)
|
||||
|
|
@ -1099,7 +1104,7 @@ async fn ai_generate_job_field(
|
|||
};
|
||||
|
||||
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),
|
||||
Err(msg) => {
|
||||
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();
|
||||
};
|
||||
|
||||
let (has_pack, daily_limit) = {
|
||||
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'"
|
||||
)
|
||||
|
|
@ -1218,15 +1223,15 @@ async fn ai_generate_cover_letter(
|
|||
};
|
||||
|
||||
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),
|
||||
Err(msg) => {
|
||||
return (StatusCode::TOO_MANY_REQUESTS, Json(serde_json::json!({ "error": msg }))).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let ollama_base = get_llm_base_url();
|
||||
let model = get_llm_model();
|
||||
let _ollama_base = get_llm_base_url();
|
||||
let _model = get_llm_model();
|
||||
|
||||
let notes = body.additional_notes.as_deref().unwrap_or("");
|
||||
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();
|
||||
};
|
||||
|
||||
let (has_pack, daily_limit) = {
|
||||
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'"
|
||||
)
|
||||
|
|
@ -1336,15 +1341,15 @@ async fn ai_tailor_resume(
|
|||
};
|
||||
|
||||
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),
|
||||
Err(msg) => {
|
||||
return (StatusCode::TOO_MANY_REQUESTS, Json(serde_json::json!({ "error": msg }))).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let ollama_base = get_llm_base_url();
|
||||
let model = get_llm_model();
|
||||
let _ollama_base = get_llm_base_url();
|
||||
let _model = get_llm_model();
|
||||
|
||||
let existing_resume = body.resume_text.as_deref().unwrap_or("Not provided");
|
||||
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();
|
||||
}
|
||||
|
||||
let (has_pack, daily_limit) = {
|
||||
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'"
|
||||
)
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
||||
let ollama_base = get_llm_base_url();
|
||||
let model = get_llm_model();
|
||||
let _ollama_base = get_llm_base_url();
|
||||
let _model = get_llm_model();
|
||||
let skills_str = skills.join(", ");
|
||||
|
||||
// Pre-check credits for all requested applications before generating anything.
|
||||
|
|
@ -1670,7 +1675,7 @@ async fn ai_auto_respond_to_lead(
|
|||
.ok()
|
||||
.flatten();
|
||||
|
||||
let (wallet_id, balance) = match wallet {
|
||||
let (_wallet_id, balance) = match wallet {
|
||||
Some((id, bal)) => (id, bal),
|
||||
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 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")
|
||||
.bind(profile_id)
|
||||
.bind(today)
|
||||
|
|
@ -1804,7 +1809,7 @@ async fn ai_usage_status(
|
|||
};
|
||||
|
||||
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(
|
||||
"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 ──────────
|
||||
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
struct CreatedTicket {
|
||||
pub(crate) struct CreatedTicket {
|
||||
id: Uuid,
|
||||
subject: 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 ───────────────
|
||||
|
||||
#[allow(dead_code)]
|
||||
async fn ollama_generate_with_timeout(
|
||||
base_url: &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 (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(
|
||||
&state,
|
||||
&auth,
|
||||
|
|
@ -3169,8 +3175,10 @@ pub mod phase3 {
|
|||
/// high enough to skip Ollama entirely. The threshold is intentionally
|
||||
/// high (0.8) because we only want to short-circuit when the article
|
||||
/// is a clear answer.
|
||||
#[allow(dead_code)]
|
||||
const KB_DIRECT_ANSWER_THRESHOLD: f32 = 0.8;
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub async fn kb_rag_top(
|
||||
pool: &sqlx::PgPool,
|
||||
decision: &RoutingDecision,
|
||||
|
|
@ -3254,11 +3262,13 @@ pub mod phase3 {
|
|||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
#[allow(dead_code)]
|
||||
pub enum RateLimitOutcome {
|
||||
Allowed { remaining: i64 },
|
||||
Limited { retry_after: i64 },
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn rate_limit_response(retry_after: i64) -> axum::response::Response {
|
||||
use axum::http::header;
|
||||
let body = serde_json::json!({
|
||||
|
|
@ -3278,6 +3288,7 @@ pub mod phase3 {
|
|||
/// Tries models in order, falling back to the next on any error.
|
||||
/// Logs every attempt at warn level so we can graph primary-uptime
|
||||
/// in Grafana later.
|
||||
#[allow(dead_code)]
|
||||
pub async fn ollama_generate_with_fallback(
|
||||
base_url: &str,
|
||||
primary_model: &str,
|
||||
|
|
@ -3358,6 +3369,7 @@ pub mod phase3 {
|
|||
pub message: String,
|
||||
pub persona: Option<String>,
|
||||
pub pillar: Option<String>,
|
||||
#[allow(dead_code)]
|
||||
pub conversation_id: Option<String>,
|
||||
}
|
||||
|
||||
|
|
@ -3608,86 +3620,24 @@ pub mod phase3 {
|
|||
}
|
||||
}
|
||||
|
||||
/// GET /api/ai/usage — counts, limits, remaining quota.
|
||||
/// Per-user, per-minute rate-limit window + Redis daily counter + DB counter.
|
||||
/// Extended with plan info, addon_balance, and renewal_date from ai_entitlements.
|
||||
/// GET /api/ai/usage — credit-based usage, limits, and rate-limit windows.
|
||||
pub async fn ai_usage(
|
||||
State(state): State<AppState>,
|
||||
auth: contracts::auth_middleware::AuthUser,
|
||||
) -> impl axum::response::IntoResponse {
|
||||
let today = chrono::Utc::now().date_naive();
|
||||
let start_of_month = {
|
||||
let today_str = today.format("%Y-%m-%d").to_string();
|
||||
let year_month = &today_str[..7];
|
||||
let start_str = format!("{}-01", year_month);
|
||||
chrono::NaiveDate::parse_from_str(&start_str, "%Y-%m-%d").unwrap_or(today)
|
||||
let (sub, plan) = 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();
|
||||
}
|
||||
};
|
||||
|
||||
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 now_minute = chrono::Utc::now().timestamp() / 60;
|
||||
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 stream_minute: i64 = redis.get(&stream_key).await.unwrap_or(0);
|
||||
|
||||
let daily_used = company_used.or(seeker_used).unwrap_or(0);
|
||||
let monthly_used = company_monthly_used.unwrap_or(0) + seeker_monthly_used.unwrap_or(0);
|
||||
let daily_limit = super::BASE_AI_LIMIT;
|
||||
|
||||
// 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(),
|
||||
};
|
||||
let purchased_remaining =
|
||||
(sub.purchased_credits_total - sub.purchased_credits_used).max(0);
|
||||
let remaining_credits = crate::ai::credits::remaining_credits(&sub);
|
||||
let remaining_daily_actions = crate::ai::credits::remaining_daily_actions(&sub, &plan);
|
||||
|
||||
(
|
||||
axum::http::StatusCode::OK,
|
||||
axum::Json(serde_json::json!({
|
||||
"user_id": auth.user_id,
|
||||
"plan": plan_name,
|
||||
"monthly_limit": monthly_limit,
|
||||
"monthly_used": monthly_used,
|
||||
"monthly_remaining": (monthly_limit - monthly_used).max(0),
|
||||
"daily_limit": daily_limit,
|
||||
"daily_used": daily_used,
|
||||
"addon_balance": addon_balance,
|
||||
"renewal_date": renewal_date,
|
||||
"plan": plan.name,
|
||||
"plan_code": plan.code,
|
||||
"monthly_limit": sub.monthly_credits_total,
|
||||
"monthly_used": sub.monthly_credits_used,
|
||||
"monthly_remaining": remaining_credits,
|
||||
"daily_limit": plan.daily_action_limit,
|
||||
"daily_used": sub.daily_actions_used,
|
||||
"daily_remaining": remaining_daily_actions,
|
||||
"addon_balance": purchased_remaining,
|
||||
"renewal_date": sub.current_period_end.date_naive(),
|
||||
"rate_limits": {
|
||||
"chat_per_minute": {
|
||||
"used": chat_minute,
|
||||
|
|
@ -3749,9 +3678,22 @@ pub mod phase3 {
|
|||
"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.
|
||||
|
|
@ -3796,73 +3738,124 @@ pub mod phase3 {
|
|||
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(
|
||||
State(state): State<AppState>,
|
||||
auth: contracts::auth_middleware::AuthUser,
|
||||
Json(body): Json<AddonPurchaseBody>,
|
||||
) -> impl axum::response::IntoResponse {
|
||||
let addon_amount: i32 = match body.addon_code.as_str() {
|
||||
"STARTER" => 100,
|
||||
"PRO" => 500,
|
||||
"ENTERPRISE" => 2000,
|
||||
let code = body.addon_code.trim().to_uppercase();
|
||||
let addon_amount = match code.as_str() {
|
||||
"STARTER" => 50,
|
||||
"GROWTH" => 150,
|
||||
"POWER" => 500,
|
||||
"ENTERPRISE" => 1000,
|
||||
_ => {
|
||||
return (
|
||||
axum::http::StatusCode::BAD_REQUEST,
|
||||
axum::Json(serde_json::json!({
|
||||
"success": false,
|
||||
"message": format!("Unknown addon code: {}", body.addon_code),
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
match AiCreditPackageRepository::list_active(&state.pool).await {
|
||||
Ok(packages) => packages
|
||||
.into_iter()
|
||||
.find(|pkg| {
|
||||
pkg.name
|
||||
.to_uppercase()
|
||||
.replace([' ', '-'], "_")
|
||||
.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,)>(
|
||||
r#"
|
||||
UPDATE ai_entitlements
|
||||
SET addon_balance = addon_balance + $1, updated_at = NOW()
|
||||
WHERE user_id = $2 AND status = 'active'
|
||||
RETURNING addon_balance
|
||||
"#,
|
||||
)
|
||||
.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),
|
||||
}),
|
||||
if addon_amount <= 0 {
|
||||
return (
|
||||
axum::http::StatusCode::BAD_REQUEST,
|
||||
axum::Json(serde_json::json!({
|
||||
"success": false,
|
||||
"message": format!("Unknown addon code: {}", body.addon_code),
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
Ok(None) => (
|
||||
axum::http::StatusCode::NOT_FOUND,
|
||||
.into_response();
|
||||
}
|
||||
|
||||
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 {
|
||||
success: false,
|
||||
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(),
|
||||
Err(e) => {
|
||||
tracing::error!("ai_addon_purchase failed: {}", e);
|
||||
(
|
||||
.into_response();
|
||||
}
|
||||
|
||||
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::Json(AddonPurchaseResponse {
|
||||
success: false,
|
||||
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)]
|
||||
|
|
@ -3878,90 +3871,83 @@ pub mod phase3 {
|
|||
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(
|
||||
State(state): State<AppState>,
|
||||
auth: contracts::auth_middleware::AuthUser,
|
||||
Json(body): Json<PlanUpgradeBody>,
|
||||
) -> impl axum::response::IntoResponse {
|
||||
let (plan_id, plan_name, monthly_limit): (uuid::Uuid, String, i32) = sqlx::query_as(
|
||||
r#"
|
||||
SELECT id, name, monthly_action_limit
|
||||
FROM ai_plans
|
||||
WHERE code = $1 AND is_active = true
|
||||
"#,
|
||||
let plan = match AiPlanRepository::get_by_code(&state.pool, &body.plan_code).await {
|
||||
Ok(Some(plan)) => plan,
|
||||
Ok(None) => {
|
||||
return (
|
||||
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
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|(id, name, limit): (uuid::Uuid, String, i32)| (id, name, limit))
|
||||
.unwrap_or_else(|| (uuid::Uuid::nil(), "Free".to_string(), 50));
|
||||
{
|
||||
Ok(data) => data,
|
||||
Err(e) => {
|
||||
return (e.status_code(), axum::Json(e.error_body())).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if plan_id == uuid::Uuid::nil() {
|
||||
return (
|
||||
axum::http::StatusCode::BAD_REQUEST,
|
||||
axum::Json(PlanUpgradeResponse {
|
||||
success: false,
|
||||
plan: "Free".to_string(),
|
||||
monthly_limit: 50,
|
||||
message: format!("Unknown or inactive plan: {}", body.plan_code),
|
||||
}),
|
||||
)
|
||||
.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
|
||||
"#,
|
||||
let (period_start, period_end) = crate::ai::plans::current_monthly_period(chrono::Utc::now());
|
||||
match UserAiSubscriptionRepository::update_plan(
|
||||
&state.pool,
|
||||
auth.user_id,
|
||||
plan.id,
|
||||
plan.monthly_credits,
|
||||
period_start,
|
||||
period_end,
|
||||
)
|
||||
.bind(plan_id)
|
||||
.bind(monthly_limit)
|
||||
.bind(auth.user_id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(Some((name, limit))) => (
|
||||
.await
|
||||
{
|
||||
Ok(_) => (
|
||||
axum::http::StatusCode::OK,
|
||||
axum::Json(PlanUpgradeResponse {
|
||||
success: true,
|
||||
plan: name,
|
||||
monthly_limit: limit,
|
||||
message: format!("Successfully upgraded to {}", plan_name),
|
||||
plan: plan.name.clone(),
|
||||
monthly_limit: plan.monthly_credits,
|
||||
message: format!("Successfully upgraded to {}", plan.name),
|
||||
}),
|
||||
)
|
||||
.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) => {
|
||||
tracing::error!("ai_plan_upgrade failed: {}", e);
|
||||
(
|
||||
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
axum::Json(PlanUpgradeResponse {
|
||||
success: false,
|
||||
plan: plan_name,
|
||||
monthly_limit,
|
||||
plan: plan.name,
|
||||
monthly_limit: plan.monthly_credits,
|
||||
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 ───────────────────────────
|
||||
//
|
||||
// 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> {
|
||||
Router::new()
|
||||
.route("/chat/message", post(ai_chat_message))
|
||||
// ── Ask Ash: Phase 2 endpoints (personas + pillars) ─────────────────
|
||||
.route("/chat/ask", post(ai_chat_ask))
|
||||
.route("/help/ask", post(ai_help_ask))
|
||||
.route("/suggestions", get(ai_suggestions))
|
||||
.route("/context", post(ai_save_context))
|
||||
.route("/history", get(ai_history))
|
||||
.route("/tickets/create", post(ai_create_ticket))
|
||||
.route("/tickets/{id}", get(ai_get_ticket))
|
||||
.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-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))
|
||||
// ── 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("/feedback", post(phase3::ai_feedback))
|
||||
.route("/usage/v2", get(phase3::ai_usage))
|
||||
.route("/clear-history", axum::routing::post(phase3::ai_clear_history))
|
||||
// ── Addon purchase and plan upgrade ──────────────────────────────────
|
||||
.route("/addons/purchase", post(phase3::ai_addon_purchase))
|
||||
.route("/credits/buy", post(phase3::ai_addon_purchase))
|
||||
.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())
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
(),
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ use db::models::ai::{
|
|||
AiAutoApplyLogRepository, AiAutoApplySettingsRepository, AiAutoRequestLogRepository,
|
||||
AiAutoRequestSettingsRepository,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@ use contracts::auth_middleware::AuthUser;
|
|||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value as JsonValue;
|
||||
use sqlx::Row;
|
||||
use std::collections::HashMap;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::AppState;
|
||||
|
|
@ -330,6 +329,7 @@ pub struct AbTest {
|
|||
}
|
||||
|
||||
#[derive(Debug, Serialize, sqlx::FromRow)]
|
||||
#[allow(dead_code)]
|
||||
pub struct AbTestSummary {
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
|
|
@ -744,6 +744,7 @@ async fn analytics_personal(
|
|||
|
||||
/// Best-effort, idempotent aggregation of ai_conversations → ai_daily_stats.
|
||||
/// 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> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
|
|
@ -791,11 +792,13 @@ pub fn estimate_tokens(text: &str) -> i32 {
|
|||
|
||||
/// Hard cap for total history size in tokens. When exceeded, older
|
||||
/// messages get summarised via Ollama.
|
||||
#[allow(dead_code)]
|
||||
pub const HISTORY_TOKEN_CAP: i32 = 2_000;
|
||||
|
||||
/// If the total history exceeds the cap, summarise the oldest 3 messages
|
||||
/// into a single one-liner via Ollama and return (summary, trimmed_count).
|
||||
/// Returns `None` when no summarisation is needed or when Ollama is down.
|
||||
#[allow(dead_code)]
|
||||
pub async fn maybe_summarise_history(
|
||||
pool: &sqlx::PgPool,
|
||||
user_id: Uuid,
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ pub fn onboarding_router() -> Router<AppState> {
|
|||
.route("/submit", post(submit))
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn me_router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/profile-status", get(profile_status))
|
||||
|
|
@ -51,6 +52,7 @@ pub struct SubmitInput {
|
|||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct ProfileStatusResponse {
|
||||
pub onboarding_complete: bool,
|
||||
pub active_role: Option<String>,
|
||||
|
|
@ -225,6 +227,7 @@ async fn submit(
|
|||
}
|
||||
|
||||
/// GET /api/me/profile-status
|
||||
#[allow(dead_code)]
|
||||
async fn profile_status(
|
||||
auth: AuthUser,
|
||||
State(state): State<AppState>,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ use axum::{
|
|||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
routing::{get, post},
|
||||
routing::get,
|
||||
Json, Router,
|
||||
};
|
||||
use contracts::auth_middleware::AuthUser;
|
||||
|
|
@ -48,6 +48,7 @@ struct PublicReviewDto {
|
|||
|
||||
#[derive(Deserialize)]
|
||||
struct CreateReviewBody {
|
||||
#[allow(dead_code)]
|
||||
lead_request_id: Uuid,
|
||||
rating: i16,
|
||||
comment: Option<String>,
|
||||
|
|
@ -69,6 +70,7 @@ struct PublicListQuery {
|
|||
#[derive(sqlx::FromRow)]
|
||||
struct ReviewRow {
|
||||
id: Uuid,
|
||||
#[allow(dead_code)]
|
||||
lead_request_id: Uuid,
|
||||
customer_id: Uuid,
|
||||
professional_id: Uuid,
|
||||
|
|
|
|||
|
|
@ -134,5 +134,5 @@ async fn main() {
|
|||
tracing::info!("Users service listening on {}", addr);
|
||||
|
||||
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();
|
||||
}
|
||||
|
|
|
|||
1
crates/cache/src/ollama.rs
vendored
1
crates/cache/src/ollama.rs
vendored
|
|
@ -49,6 +49,7 @@ pub struct GenerateResponse {
|
|||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
struct OllamaErrorResponse {
|
||||
error: String,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ use axum::{
|
|||
Json, Router,
|
||||
};
|
||||
use bytes::BufMut;
|
||||
use chrono::Utc;
|
||||
use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
use db::models::lead_request::{CreateLeadRequestPayload, LeadRequestRepository};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue