diff --git a/apps/users/src/handlers/ai.rs b/apps/users/src/handlers/ai.rs index 1b72187..6f3303d 100644 --- a/apps/users/src/handlers/ai.rs +++ b/apps/users/src/handlers/ai.rs @@ -2285,6 +2285,10 @@ pub struct AskAshResponse { pub kb_matches: Vec, pub ticket: Option, pub ollama_used: bool, + pub status: Option, + pub suggested_action: Option, + pub remaining_credits: Option, + pub remaining_daily_actions: Option, } pub(crate) fn parse_persona(s: Option<&str>) -> Option { @@ -2307,6 +2311,61 @@ pub(crate) fn parse_pillar(s: Option<&str>) -> Option { } } +fn is_ai_usage_question(message: &str) -> bool { + let m = message.to_lowercase(); + [ + "ai balance", + "credit balance", + "credits remaining", + "remaining credits", + "ai credits", + "ai usage", + "ai plan", + "my plan", + "plan limits", + "daily limit", + ] + .iter() + .any(|s| m.contains(s)) +} + +fn format_kb_answer(kb_matches: &[KbMatch]) -> String { + let intro = if kb_matches.len() == 1 { + "I found a help article that looks relevant:".to_string() + } else { + "I found a few help articles that should help:".to_string() + }; + let items = kb_matches + .iter() + .take(3) + .map(|m| format!("• {} — /help-center/article/{}", m.title, m.slug)) + .collect::>() + .join(" +"); + format!("{} + +{}", intro, items) +} + +fn format_usage_answer( + plan_name: &str, + remaining_credits: i32, + remaining_daily_actions: i32, + daily_action_limit: i32, + monthly_credits_total: i32, + monthly_credits_used: i32, +) -> String { + format!( + "You are on the {} plan. You have {} AI credits remaining this month, and {} of {} daily AI actions left today. Monthly usage: {} of {} credits used.", + plan_name, + remaining_credits, + remaining_daily_actions, + daily_action_limit, + monthly_credits_used, + monthly_credits_total, + ) +} + // ── POST /api/ai/chat/ask ───────────────────────────────────────────────────── async fn ai_chat_ask( @@ -2314,39 +2373,87 @@ async fn ai_chat_ask( auth: AuthUser, Json(body): Json, ) -> impl IntoResponse { - // Guard: same prompt-injection / abuse filter as /chat/message if let Some((status, payload)) = llm_guard_check(&body.message) { return (status, Json(payload)).into_response(); } - // Authenticated user_id wins; body.user_id only honored if it's a valid (non-nil) UUID let user_id = body .user_id .filter(|u| *u != Uuid::nil()) .unwrap_or(auth.user_id); - // Persona + pillar detection (explicit override wins, otherwise detect) let persona = parse_persona(body.persona.as_deref()) .or_else(|| Persona::detect(&body.message)); let pillar = parse_pillar(body.pillar.as_deref()) .or_else(|| Pillar::detect(&body.message)); - // KB lookup: does the user query match any published KB article? let kb_matches = kb_lookup(&state.pool, &body.message).await; + let routed = phase3::route_intent(&body.message); + let intent = routed.intent.as_str().to_string(); + let confidence = routed.confidence; + let conversation_id = body + .conversation_id + .clone() + .unwrap_or_else(|| Uuid::new_v4().to_string()); - // Intent classification - let (intent, confidence) = match classify_strict_keywords(&body.message) { - Some((kw_intent, kw_conf)) => (kw_intent.to_string(), kw_conf), - None => { - let ollama_base = std::env::var("OLLAMA_BASE_URL") - .unwrap_or_else(|_| "http://ollama.nxtgauge-ai.svc.cluster.local:11434".to_string()); - let model = std::env::var("OLLAMA_CHAT_MODEL") - .unwrap_or_else(|_| "gemma3:270m".to_string()); - classify_intent(&body.message, &ollama_base, &model).await + if is_ai_usage_question(&body.message) { + match plans::ensure_free_subscription(&state.pool, auth.user_id, Some(&auth.claims.active_role)).await { + Ok((sub, plan)) => { + let remaining_credits = credits::remaining_credits(&sub); + let remaining_daily_actions = credits::remaining_daily_actions(&sub, &plan); + return ( + StatusCode::OK, + Json(AskAshResponse { + message: format_usage_answer( + &plan.name, + remaining_credits, + remaining_daily_actions, + plan.daily_action_limit, + sub.monthly_credits_total, + sub.monthly_credits_used, + ), + persona: persona.map(|p| p.as_str().to_string()), + pillar: pillar.map(|p| p.as_str().to_string()), + intent: "ai_usage".to_string(), + confidence: 0.98, + conversation_id, + kb_matches: vec![], + ticket: None, + ollama_used: false, + status: Some("usage_summary".to_string()), + suggested_action: Some("show_usage_modal".to_string()), + remaining_credits: Some(remaining_credits), + remaining_daily_actions: Some(remaining_daily_actions), + }), + ) + .into_response(); + } + Err(e) => return (e.status_code(), Json(e.error_body())).into_response(), } - }; + } + + if matches!(routed.intent, phase3::Intent::HelpSearch) && !kb_matches.is_empty() { + return ( + StatusCode::OK, + Json(AskAshResponse { + message: format_kb_answer(&kb_matches), + persona: persona.map(|p| p.as_str().to_string()), + pillar: pillar.map(|p| p.as_str().to_string()), + intent, + confidence, + conversation_id, + kb_matches, + ticket: None, + ollama_used: false, + status: Some("kb_results".to_string()), + suggested_action: Some("open_help_search".to_string()), + remaining_credits: None, + remaining_daily_actions: None, + }), + ) + .into_response(); + } - // Support-ticket auto-creation: only if no KB match AND support intent AND we have a real user let mut ticket: Option = None; if kb_matches.is_empty() && is_support_intent(&body.message) && user_id != Uuid::nil() { match auto_create_support_ticket(&state.pool, user_id, &body.message).await { @@ -2355,14 +2462,41 @@ async fn ai_chat_ask( } } - // Build system prompt and call Ollama + if let Some(t) = &ticket { + let msg = format!( + "I created a support ticket for you: #{} - {}. Add any extra details, screenshots, or exact error text in your next message and I will help refine it.", + t.id, t.subject + ); + return ( + StatusCode::OK, + Json(AskAshResponse { + message: msg, + persona: persona.map(|p| p.as_str().to_string()), + pillar: pillar.map(|p| p.as_str().to_string()), + intent, + confidence: confidence.max(0.9), + conversation_id, + kb_matches, + ticket: Some(t.clone()), + ollama_used: false, + status: Some("ticket_created".to_string()), + suggested_action: Some("open_support_ticket".to_string()), + remaining_credits: None, + remaining_daily_actions: None, + }), + ) + .into_response(); + } + let system_prompt = build_persona_pillar_system_prompt(persona, pillar); let mut user_block = String::new(); if let Some(p) = persona { - user_block.push_str(&format!("(persona: {})\n", p.as_str())); + user_block.push_str(&format!("(persona: {}) +", p.as_str())); } if let Some(p) = pillar { - user_block.push_str(&format!("(pillar: {})\n", p.as_str())); + user_block.push_str(&format!("(pillar: {}) +", p.as_str())); } if !kb_matches.is_empty() { let kb_ctx = kb_matches @@ -2377,20 +2511,23 @@ async fn ai_chat_ask( ) }) .collect::>() - .join("\n"); - user_block.push_str(&format!("\nRelevant KB articles:\n{kb_ctx}\n")); + .join(" +"); + user_block.push_str(&format!(" +Relevant KB articles: +{kb_ctx} +")); } - if let Some(t) = &ticket { - user_block.push_str(&format!( - "\nA support ticket has been auto-created: #{} — {}\n", - t.id, t.subject - )); - } - user_block.push_str(&format!("\nUser: {}", body.message)); + user_block.push_str(&format!(" +User: {}", body.message)); - let full_prompt = format!("{system_prompt}\n\n{user_block}\n\nAssistant:"); + let full_prompt = format!("{system_prompt} - let (response_text, _model_used, _credits_charged, _remaining_credits, _remaining_daily, _request_id, ollama_used) = +{user_block} + +Assistant:"); + + let (response_text, remaining_credits, remaining_daily_actions, ollama_used) = match orchestrator::call_feature( &state, &auth, @@ -2402,9 +2539,8 @@ async fn ai_chat_ask( ) .await { - Ok(r) => (r.text, Some(r.model_alias), r.credits_charged, r.remaining_credits, r.remaining_daily_actions, r.request_id, true), + Ok(r) => (r.text, Some(r.remaining_credits), Some(r.remaining_daily_actions), true), Err(e) => { - // If the error is a plan/credit issue, return it directly instead of fallback. if matches!(e, orchestrator::AiCallError::Plan(_) | orchestrator::AiCallError::Credit(_)) { return e.into_response(); } @@ -2412,23 +2548,21 @@ async fn ai_chat_ask( ( local_fallback_response(persona, pillar, &body.message), None, - 0, - 0, - 0, None, false, ) } }; - // KB-injection: if we found KB matches and the model didn't reference them, append a hint let response_text = if !kb_matches.is_empty() && !response_text.to_lowercase().contains("article") { let hint = kb_matches .iter() .take(2) .map(|m| { format!( - "\n\n• {} — /help-center/article/{}", + " + +• {} — /help-center/article/{}", m.title, m.slug ) }) @@ -2438,10 +2572,6 @@ async fn ai_chat_ask( response_text }; - // Persist to ai_conversations (fire-and-forget; log on error) - let conversation_id = body - .conversation_id - .unwrap_or_else(|| Uuid::new_v4().to_string()); if user_id != Uuid::nil() { let pool = state.pool.clone(); let q = body.message.clone(); @@ -2482,8 +2612,12 @@ async fn ai_chat_ask( confidence, conversation_id, kb_matches, - ticket, + ticket: None, ollama_used, + status: Some("answered".to_string()), + suggested_action: Some(routed.suggested_action.to_string()), + remaining_credits, + remaining_daily_actions, }), ) .into_response()