Fix /api/ai/* returning 500 on every request, close credit-minting bugs, add grounding guardrail

Critical: ai_access_middleware was wired via from_fn_with_state((), ...)
- passing the unit type as state - and pulled AppState from request
extensions, which nothing ever populated. Every request through
/api/ai/* and /api/ai/auto/* returned 500 INCOMPLETE_CONTEXT. Fixed by
extracting State<AppState> properly and passing the real state at both
call sites; removed the redundant, identically-broken inner middleware
layer inside ai_router().

Security: ai_addon_purchase (/api/ai/addons/purchase, /api/ai/credits/buy)
and ai_plan_upgrade (/api/ai/plans/upgrade) granted AI credits / plan
upgrades (including enterprise) with zero payment verification - any
authenticated user could mint unlimited free credits, and the frontend
already called this directly. Disabled both until wired to a real
payment flow.

Quality: added a grounding/anti-hallucination system prompt applied to
every AI feature call (orchestrator::call_feature /
call_feature_with_plan, plus the handful of call sites that bypass the
orchestrator). Verified against the live model that it reduces but does
not eliminate fabrication on harder reasoning tasks - even the larger
model invents facts not present in the input on some prompts. This is a
real limitation of the two locally-hosted models, not something a
system prompt alone fully solves; flagged for follow-up (e.g. a
verification pass or deterministic checks for high-stakes decisions
like auto-apply).

Also fixed Persona/Pillar keyword detection using naive substring
matching (e.g. "team" matching inside "esteemed", "lead" matching
inside "leadership") - added a word-boundary-aware contains_word()
helper and applied it to all keyword classifiers in this file.
This commit is contained in:
Ashwin Kumar Sivakumar 2026-07-03 04:05:14 +05:30
parent 64089e4da3
commit eaae3d470f
6 changed files with 134 additions and 155 deletions

View file

@ -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<AppState>,
auth: AuthUser,
request: Request,
next: Next,
) -> Result<Response, AiAccessError> {
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::<AppState>()
.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,

View file

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

View file

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

View file

@ -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<u8>| 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<AppState>,
auth: contracts::auth_middleware::AuthUser,
Json(body): Json<AddonPurchaseBody>,
State(_state): State<AppState>,
_auth: contracts::auth_middleware::AuthUser,
Json(_body): Json<AddonPurchaseBody>,
) -> 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<AppState>,
auth: contracts::auth_middleware::AuthUser,
Json(body): Json<PlanUpgradeBody>,
) -> 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<AppState> {
.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)]

View file

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

View file

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