diff --git a/apps/users/src/ai/middleware.rs b/apps/users/src/ai/middleware.rs index 4ba12b9..6d6f7fe 100644 --- a/apps/users/src/ai/middleware.rs +++ b/apps/users/src/ai/middleware.rs @@ -133,24 +133,13 @@ impl IntoResponse for AiAccessError { /// AI access. Applies to all routes under `/api/ai/*`. Non-customer roles that /// have no explicit subscription will automatically get a Free plan. pub async fn ai_access_middleware( - State(_state): State<()>, + State(state): State, auth: AuthUser, request: Request, next: Next, ) -> Result { let role_code = auth.claims.active_role.clone(); - // State is not available directly in axum middleware; access the pool - // via the request extensions where AppState was installed by `with_state`. - let state = request - .extensions() - .get::() - .cloned() - .ok_or_else(|| { - tracing::error!("AppState not found in request extensions"); - AiAccessError::IncompleteContext - })?; - let (_sub, _plan) = plans::ensure_free_subscription( &state.pool, auth.user_id, diff --git a/apps/users/src/ai/orchestrator.rs b/apps/users/src/ai/orchestrator.rs index 7e9ee6a..ee21a49 100644 --- a/apps/users/src/ai/orchestrator.rs +++ b/apps/users/src/ai/orchestrator.rs @@ -8,6 +8,30 @@ use crate::ai::{credits, litellm, model_router, plans, usage}; use db::models::ai::AiFeatureCost; use crate::AppState; +/// Baseline anti-hallucination / grounding instructions applied to every AI +/// feature call, regardless of what feature-specific system prompt (if any) +/// the caller supplies. Added after confirming the model will otherwise +/// confidently fabricate skills/qualifications not present in the input +/// (e.g. asserting a candidate has AWS/Kubernetes experience when only +/// Python/SQL were listed) and use that fabrication to justify its answer. +pub const GROUNDING_GUARDRAIL: &str = "You are an AI assistant for the Nxtgauge platform. Follow these rules strictly: \ +(1) Only use facts, skills, experience, or qualifications explicitly stated in the input below - never invent, \ +assume, or infer skills, experience, credentials, or requirements that were not explicitly mentioned. \ +(2) If information needed to fully answer is missing, say so explicitly rather than filling the gap with an assumption. \ +(3) When evaluating fit, gaps, matches, or recommendations, be honest about mismatches and missing requirements - \ +do not default to an encouraging or positive tone if the input doesn't support it. \ +(4) Do not fabricate specific facts, numbers, dates, or names not present in the input. \ +(5) Keep responses concise and directly relevant to the request."; + +/// Combine the baseline grounding guardrail with an optional feature-specific +/// system prompt. +pub fn effective_system_prompt(feature_prompt: Option<&str>) -> String { + match feature_prompt { + Some(p) if !p.trim().is_empty() => format!("{}\n\n{}", GROUNDING_GUARDRAIL, p), + _ => GROUNDING_GUARDRAIL.to_string(), + } +} + #[derive(Debug, thiserror::Error)] pub enum AiCallError { #[error("Plan error: {0}")] @@ -98,7 +122,7 @@ pub async fn call_feature( let (text, usage, request_id) = client .chat_completion_text( &model_alias, - system_prompt, + Some(&effective_system_prompt(system_prompt)), user_message, max_tokens, ) @@ -182,7 +206,7 @@ pub async fn call_feature_with_plan( let (text, usage, request_id) = client .chat_completion_text( &model_alias, - system_prompt, + Some(&effective_system_prompt(system_prompt)), user_message, max_tokens, ) diff --git a/apps/users/src/handlers/admin_ai.rs b/apps/users/src/handlers/admin_ai.rs index 09d214c..5b7e8f7 100644 --- a/apps/users/src/handlers/admin_ai.rs +++ b/apps/users/src/handlers/admin_ai.rs @@ -1,4 +1,4 @@ -use crate::ai::{credits, litellm, model_router, plans, usage}; +use crate::ai::{credits, litellm, model_router, orchestrator, plans, usage}; use crate::AppState; use axum::{ extract::{Path, Query, State}, @@ -229,7 +229,7 @@ async fn admin_ai_call( }; let (text, usage, request_id) = match client - .chat_completion_text(&model_alias, None, prompt, None) + .chat_completion_text(&model_alias, Some(&orchestrator::effective_system_prompt(None)), prompt, None) .await { Ok(r) => r, diff --git a/apps/users/src/handlers/ai.rs b/apps/users/src/handlers/ai.rs index 6f3303d..ad4bdee 100644 --- a/apps/users/src/handlers/ai.rs +++ b/apps/users/src/handlers/ai.rs @@ -78,7 +78,7 @@ fn classify_strict_keywords(message: &str) -> Option<(&'static str, f32)> { "what is ", "what are ", "where do i find", "where can i find", "search for", "find article", "look up", ]; - if HELP_KW.iter().any(|k| m.contains(k)) { + if HELP_KW.iter().any(|k| contains_word(&m, k)) { return Some(("help_search", 0.95)); } @@ -90,7 +90,7 @@ fn classify_strict_keywords(message: &str) -> Option<(&'static str, f32)> { "having trouble with", "issue with", "problem with", "complaint", "refund request", "cancel my account", "billing issue", "billing problem", ]; - if TICKET_KW.iter().any(|k| m.contains(k)) { + if TICKET_KW.iter().any(|k| contains_word(&m, k)) { return Some(("ticket_creation", 0.95)); } @@ -100,7 +100,7 @@ fn classify_strict_keywords(message: &str) -> Option<(&'static str, f32)> { "extract from", "extract fields", "extract info", "extract information", "autofill", "auto-fill", "parse this form", "from this text", ]; - if FORM_KW.iter().any(|k| m.contains(k)) { + if FORM_KW.iter().any(|k| contains_word(&m, k)) { return Some(("form_filling", 0.95)); } @@ -110,7 +110,7 @@ fn classify_strict_keywords(message: &str) -> Option<(&'static str, f32)> { "draft a job description", "job description for", "jd for", "job posting for", "write job description", "generate job description", ]; - if JD_KW.iter().any(|k| m.contains(k)) { + if JD_KW.iter().any(|k| contains_word(&m, k)) { return Some(("job_description_generation", 0.95)); } @@ -119,7 +119,7 @@ fn classify_strict_keywords(message: &str) -> Option<(&'static str, f32)> { "cover letter", "coverletter", "write a letter", "application letter", "letter of interest", "motivation letter", ]; - if CL_KW.iter().any(|k| m.contains(k)) { + if CL_KW.iter().any(|k| contains_word(&m, k)) { return Some(("generate_cover_letter", 0.95)); } @@ -130,7 +130,7 @@ fn classify_strict_keywords(message: &str) -> Option<(&'static str, f32)> { "update my resume", "update resume", "fix my resume", "optimize my resume", "customize my resume", "adjust my resume", "polish my resume", ]; - if RESUME_KW.iter().any(|k| m.contains(k)) { + if RESUME_KW.iter().any(|k| contains_word(&m, k)) { return Some(("improve_resume", 0.95)); } @@ -141,7 +141,7 @@ fn classify_strict_keywords(message: &str) -> Option<(&'static str, f32)> { "unlock lead", "unlock contact", "lead contact", "view lead", "request to view", ]; - if CONTACT_KW.iter().any(|k| m.contains(k)) { + if CONTACT_KW.iter().any(|k| contains_word(&m, k)) { return Some(("request_view_contact", 0.95)); } @@ -150,7 +150,7 @@ fn classify_strict_keywords(message: &str) -> Option<(&'static str, f32)> { "auto apply", "auto-apply", "apply to all", "apply for me", "apply on my behalf", "apply automatically", "bulk apply", "mass apply", ]; - if APPLY_KW.iter().any(|k| m.contains(k)) { + if APPLY_KW.iter().any(|k| contains_word(&m, k)) { return Some(("auto_apply_job", 0.95)); } @@ -614,7 +614,7 @@ async fn ai_chat_generate( } }; return match client - .chat_completion_text("askash-fast", None, prompt, None) + .chat_completion_text("askash-fast", Some(&orchestrator::effective_system_prompt(None)), prompt, None) .await { Ok((text, _, _)) => (text, true), @@ -689,7 +689,7 @@ async fn ai_chat_generate( let (text, usage, request_id) = match client .chat_completion_text(&model_alias, - None, + Some(&orchestrator::effective_system_prompt(None)), prompt, None, ) @@ -1871,6 +1871,57 @@ async fn ai_usage_status( // - DISCOVER : help find things (search, recommendations) // - IMPROVE : optimize existing (analytics, suggestions) +/// Word-boundary aware substring check, so a keyword like "team" doesn't +/// match inside unrelated words like "steam" or "esteemed", and "lead" +/// doesn't match inside "leadership". Keywords with a trailing/leading +/// space (e.g. "make a") are treated literally; the check still applies +/// boundary rules to the overall match span. +fn contains_word(haystack: &str, needle: &str) -> bool { + let bytes = haystack.as_bytes(); + let needle_bytes = needle.as_bytes(); + if needle_bytes.is_empty() { + return false; + } + + let is_boundary = |c: Option| match c { + None => true, + Some(b) => !(b as char).is_alphanumeric(), + }; + + // Only enforce a boundary on an edge if the needle itself is alphanumeric + // there — a needle like "how do i " already carries its own boundary via + // the trailing space, so requiring another non-alphanumeric char after + // that would over-constrain it. + let needle_starts_alnum = (needle_bytes[0] as char).is_alphanumeric(); + let needle_ends_alnum = (needle_bytes[needle_bytes.len() - 1] as char).is_alphanumeric(); + + let mut start = 0; + while let Some(pos) = haystack[start..].find(needle) { + let match_start = start + pos; + let match_end = match_start + needle_bytes.len(); + + let before_ok = !needle_starts_alnum || { + let before = if match_start == 0 { None } else { Some(bytes[match_start - 1]) }; + is_boundary(before) + }; + let after_ok = !needle_ends_alnum || { + let after = bytes.get(match_end).copied(); + is_boundary(after) + }; + + if before_ok && after_ok { + return true; + } + + start = match_start + 1; + if start >= haystack.len() { + break; + } + } + + false +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum Persona { @@ -1901,7 +1952,7 @@ impl Persona { "recruitment", "team", "employer", "organization", "org ", "staff", "headcount", "workforce", "b2b", "enterprise", ]; - if COMPANIES.iter().any(|k| m.contains(k)) { + if COMPANIES.iter().any(|k| contains_word(&m, k)) { return Some(Persona::Companies); } @@ -1911,7 +1962,7 @@ impl Persona { "resume", "cv ", "interview", "hiring me", "salary", "offer letter", "job board", "job listing", "vacancy", "position", "candidate", ]; - if JOB_SEEKERS.iter().any(|k| m.contains(k)) { + if JOB_SEEKERS.iter().any(|k| contains_word(&m, k)) { return Some(Persona::JobSeekers); } @@ -1921,7 +1972,7 @@ impl Persona { "quote", "quotation", "order", "checkout", "payment", "invoice me", "subscription", "plan", "package", ]; - if CUSTOMERS.iter().any(|k| m.contains(k)) { + if CUSTOMERS.iter().any(|k| contains_word(&m, k)) { return Some(Persona::Customers); } @@ -1931,7 +1982,7 @@ impl Persona { "freelancer", "consultant", "contractor", "side hustle", "service provider", "lead", "leads", "client", "project", "deliverable", ]; - if PROFESSIONALS.iter().any(|k| m.contains(k)) { + if PROFESSIONALS.iter().any(|k| contains_word(&m, k)) { return Some(Persona::Professionals); } @@ -1968,7 +2019,7 @@ impl Pillar { "generate", "new ", "add a", "set up", "setup ", "post a", "publish", "start a", "begin a", "launch", ]; - if CREATE.iter().any(|k| m.contains(k)) { + if CREATE.iter().any(|k| contains_word(&m, k)) { return Some(Pillar::Create); } @@ -1978,7 +2029,7 @@ impl Pillar { "verification", "onboard", "onboarding", "fill in", "fill out", "resume setup", "complete my", "finish my", "pick up where", ]; - if COMPLETE.iter().any(|k| m.contains(k)) { + if COMPLETE.iter().any(|k| contains_word(&m, k)) { return Some(Pillar::Complete); } @@ -1988,7 +2039,7 @@ impl Pillar { "suggest", "show me", "browse", "discover", "explore", "best", "top ", "near me", "nearby", "available", ]; - if DISCOVER.iter().any(|k| m.contains(k)) { + if DISCOVER.iter().any(|k| contains_word(&m, k)) { return Some(Pillar::Discover); } @@ -1998,7 +2049,7 @@ impl Pillar { "performance", "metrics", "stats", "statistics", "better", "enhance", "upgrade", "polish", "refine", "tweak", "fix my", ]; - if IMPROVE.iter().any(|k| m.contains(k)) { + if IMPROVE.iter().any(|k| contains_word(&m, k)) { return Some(Pillar::Improve); } @@ -2122,7 +2173,7 @@ fn is_support_intent(message: &str) -> bool { "can't", "cant ", "cannot", "unable to", "issue", "problem", "help me fix", "stuck", "blocked", ]; - SUPPORT_KW.iter().any(|k| m.contains(k)) + SUPPORT_KW.iter().any(|k| contains_word(&m, k)) } // ── Phase 2: auto-create a support ticket for support-intent queries ────────── @@ -3873,121 +3924,23 @@ pub mod phase3 { } /// POST /api/ai/addons/purchase — purchase a credit pack. + /// + /// This previously granted credits with no payment verification at all - + /// any authenticated user could mint unlimited free AI credits. Disabled + /// until it's wired to a real payment flow (see the PayU-verified + /// nxtgauge-rust-payments ai_credits flow for the pattern to follow). pub async fn ai_addon_purchase( - State(state): State, - auth: contracts::auth_middleware::AuthUser, - Json(body): Json, + State(_state): State, + _auth: contracts::auth_middleware::AuthUser, + Json(_body): Json, ) -> impl axum::response::IntoResponse { - let code = body.addon_code.trim().to_uppercase(); - let addon_amount = match code.as_str() { - "STARTER" => 50, - "GROWTH" => 150, - "POWER" => 500, - "ENTERPRISE" => 1000, - _ => { - 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) - } - }; - - 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(); - } - - 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: "Failed to process purchase".to_string(), - }), - ) - .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: "Subscription not found after purchase".to_string(), - }), - ) - .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), - }), + axum::http::StatusCode::PAYMENT_REQUIRED, + axum::Json(serde_json::json!({ + "success": false, + "message": "Direct credit purchase is temporarily unavailable. Please use the AI credits checkout flow.", + "code": "PAYMENT_VERIFICATION_REQUIRED", + })), ) .into_response() } @@ -4006,11 +3959,28 @@ pub mod phase3 { } /// POST /api/ai/plans/upgrade — upgrade the user's AI plan. + /// + /// This previously upgraded to ANY plan (including enterprise) with no + /// payment verification at all - only "free" is allowed here now, since + /// there is no pricing/payment linkage for paid plans in this table yet. pub async fn ai_plan_upgrade( State(state): State, auth: contracts::auth_middleware::AuthUser, Json(body): Json, ) -> impl axum::response::IntoResponse { + if !body.plan_code.trim().eq_ignore_ascii_case("free") { + return ( + axum::http::StatusCode::PAYMENT_REQUIRED, + axum::Json(PlanUpgradeResponse { + success: false, + plan: "Free".to_string(), + monthly_limit: 10, + message: "Paid plan upgrades require a verified payment and are not yet available through this endpoint.".to_string(), + }), + ) + .into_response(); + } + let plan = match AiPlanRepository::get_by_code(&state.pool, &body.plan_code).await { Ok(Some(plan)) => plan, Ok(None) => { @@ -4443,10 +4413,6 @@ pub fn ai_router() -> Router { .route("/plans/upgrade", post(phase3::ai_plan_upgrade)) .merge(crate::handlers::ai_auto::ai_auto_router()) .merge(crate::handlers::ai_phase4::phase4_router()) - .layer(axum::middleware::from_fn_with_state( - (), - crate::ai::middleware::ai_access_middleware, - )) } #[cfg(test)] diff --git a/apps/users/src/handlers/ai_auto.rs b/apps/users/src/handlers/ai_auto.rs index 699063b..154cc13 100644 --- a/apps/users/src/handlers/ai_auto.rs +++ b/apps/users/src/handlers/ai_auto.rs @@ -1,4 +1,4 @@ -use crate::ai::{credits, litellm, model_router, plans, usage}; +use crate::ai::{credits, litellm, model_router, orchestrator, plans, usage}; use crate::AppState; use axum::{ extract::{Query, State}, @@ -306,7 +306,7 @@ async fn ai_suggest( let full_prompt = format!("{}\n\nPreferences/Context: {}\n\nSuggestions:", system_prompt, text); let (response_text, usage, request_id) = match client - .chat_completion_text(&model_alias, None, &full_prompt, None) + .chat_completion_text(&model_alias, Some(&orchestrator::effective_system_prompt(None)), &full_prompt, None) .await { Ok(r) => r, diff --git a/apps/users/src/main.rs b/apps/users/src/main.rs index 5dffd14..713bbfe 100644 --- a/apps/users/src/main.rs +++ b/apps/users/src/main.rs @@ -119,11 +119,11 @@ async fn main() { .nest("/api/admin/ai", handlers::admin_ai::admin_ai_router()) // ── AI Assistant ────────────────────────────────────────────────── .nest("/api/ai", handlers::ai::ai_router().layer(axum::middleware::from_fn_with_state( - (), + state.clone(), crate::ai::middleware::ai_access_middleware, ))) .nest("/api/ai/auto", handlers::ai_auto::ai_auto_router().layer(axum::middleware::from_fn_with_state( - (), + state.clone(), crate::ai::middleware::ai_access_middleware, ))) .route("/health", get(|| async { "Users OK" }))