feat(ai): improve ask ash direct responses

This commit is contained in:
Ashwin Kumar Sivakumar 2026-06-15 16:59:56 +05:30
parent 06e73eebb5
commit c1c9d246eb

View file

@ -2285,6 +2285,10 @@ pub struct AskAshResponse {
pub kb_matches: Vec<KbMatch>, pub kb_matches: Vec<KbMatch>,
pub ticket: Option<CreatedTicket>, pub ticket: Option<CreatedTicket>,
pub ollama_used: bool, pub ollama_used: bool,
pub status: Option<String>,
pub suggested_action: Option<String>,
pub remaining_credits: Option<i32>,
pub remaining_daily_actions: Option<i32>,
} }
pub(crate) fn parse_persona(s: Option<&str>) -> Option<Persona> { pub(crate) fn parse_persona(s: Option<&str>) -> Option<Persona> {
@ -2307,6 +2311,61 @@ pub(crate) fn parse_pillar(s: Option<&str>) -> Option<Pillar> {
} }
} }
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::<Vec<_>>()
.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 ───────────────────────────────────────────────────── // ── POST /api/ai/chat/ask ─────────────────────────────────────────────────────
async fn ai_chat_ask( async fn ai_chat_ask(
@ -2314,39 +2373,87 @@ async fn ai_chat_ask(
auth: AuthUser, auth: AuthUser,
Json(body): Json<AskAshRequest>, Json(body): Json<AskAshRequest>,
) -> impl IntoResponse { ) -> impl IntoResponse {
// Guard: same prompt-injection / abuse filter as /chat/message
if let Some((status, payload)) = llm_guard_check(&body.message) { if let Some((status, payload)) = llm_guard_check(&body.message) {
return (status, Json(payload)).into_response(); 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 let user_id = body
.user_id .user_id
.filter(|u| *u != Uuid::nil()) .filter(|u| *u != Uuid::nil())
.unwrap_or(auth.user_id); .unwrap_or(auth.user_id);
// Persona + pillar detection (explicit override wins, otherwise detect)
let persona = parse_persona(body.persona.as_deref()) let persona = parse_persona(body.persona.as_deref())
.or_else(|| Persona::detect(&body.message)); .or_else(|| Persona::detect(&body.message));
let pillar = parse_pillar(body.pillar.as_deref()) let pillar = parse_pillar(body.pillar.as_deref())
.or_else(|| Pillar::detect(&body.message)); .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 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 if is_ai_usage_question(&body.message) {
let (intent, confidence) = match classify_strict_keywords(&body.message) { match plans::ensure_free_subscription(&state.pool, auth.user_id, Some(&auth.claims.active_role)).await {
Some((kw_intent, kw_conf)) => (kw_intent.to_string(), kw_conf), Ok((sub, plan)) => {
None => { let remaining_credits = credits::remaining_credits(&sub);
let ollama_base = std::env::var("OLLAMA_BASE_URL") let remaining_daily_actions = credits::remaining_daily_actions(&sub, &plan);
.unwrap_or_else(|_| "http://ollama.nxtgauge-ai.svc.cluster.local:11434".to_string()); return (
let model = std::env::var("OLLAMA_CHAT_MODEL") StatusCode::OK,
.unwrap_or_else(|_| "gemma3:270m".to_string()); Json(AskAshResponse {
classify_intent(&body.message, &ollama_base, &model).await 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<CreatedTicket> = None; let mut ticket: Option<CreatedTicket> = None;
if kb_matches.is_empty() && is_support_intent(&body.message) && user_id != Uuid::nil() { 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 { 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 system_prompt = build_persona_pillar_system_prompt(persona, pillar);
let mut user_block = String::new(); let mut user_block = String::new();
if let Some(p) = persona { 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 { 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() { if !kb_matches.is_empty() {
let kb_ctx = kb_matches let kb_ctx = kb_matches
@ -2377,20 +2511,23 @@ async fn ai_chat_ask(
) )
}) })
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join("\n"); .join("
user_block.push_str(&format!("\nRelevant KB articles:\n{kb_ctx}\n")); ");
user_block.push_str(&format!("
Relevant KB articles:
{kb_ctx}
"));
} }
if let Some(t) = &ticket { user_block.push_str(&format!("
user_block.push_str(&format!( User: {}", body.message));
"\nA support ticket has been auto-created: #{} — {}\n",
t.id, t.subject
));
}
user_block.push_str(&format!("\nUser: {}", 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( match orchestrator::call_feature(
&state, &state,
&auth, &auth,
@ -2402,9 +2539,8 @@ async fn ai_chat_ask(
) )
.await .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) => { 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(_)) { if matches!(e, orchestrator::AiCallError::Plan(_) | orchestrator::AiCallError::Credit(_)) {
return e.into_response(); return e.into_response();
} }
@ -2412,23 +2548,21 @@ async fn ai_chat_ask(
( (
local_fallback_response(persona, pillar, &body.message), local_fallback_response(persona, pillar, &body.message),
None, None,
0,
0,
0,
None, None,
false, 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 response_text = if !kb_matches.is_empty() && !response_text.to_lowercase().contains("article") {
let hint = kb_matches let hint = kb_matches
.iter() .iter()
.take(2) .take(2)
.map(|m| { .map(|m| {
format!( format!(
"\n\n• {} — /help-center/article/{}", "
{} /help-center/article/{}",
m.title, m.slug m.title, m.slug
) )
}) })
@ -2438,10 +2572,6 @@ async fn ai_chat_ask(
response_text 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() { if user_id != Uuid::nil() {
let pool = state.pool.clone(); let pool = state.pool.clone();
let q = body.message.clone(); let q = body.message.clone();
@ -2482,8 +2612,12 @@ async fn ai_chat_ask(
confidence, confidence,
conversation_id, conversation_id,
kb_matches, kb_matches,
ticket, ticket: None,
ollama_used, ollama_used,
status: Some("answered".to_string()),
suggested_action: Some(routed.suggested_action.to_string()),
remaining_credits,
remaining_daily_actions,
}), }),
) )
.into_response() .into_response()