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;
|
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((
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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(())
|
||||||
|
|
|
||||||
|
|
@ -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)]
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
(),
|
(),
|
||||||
|
|
|
||||||
|
|
@ -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)]
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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>,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1
crates/cache/src/ollama.rs
vendored
1
crates/cache/src/ollama.rs
vendored
|
|
@ -49,6 +49,7 @@ pub struct GenerateResponse {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[allow(dead_code)]
|
||||||
struct OllamaErrorResponse {
|
struct OllamaErrorResponse {
|
||||||
error: String,
|
error: String,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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};
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue