feat(ai): improve ask ash direct responses
This commit is contained in:
parent
06e73eebb5
commit
c1c9d246eb
1 changed files with 175 additions and 41 deletions
|
|
@ -2285,6 +2285,10 @@ pub struct AskAshResponse {
|
|||
pub kb_matches: Vec<KbMatch>,
|
||||
pub ticket: Option<CreatedTicket>,
|
||||
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> {
|
||||
|
|
@ -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 ─────────────────────────────────────────────────────
|
||||
|
||||
async fn ai_chat_ask(
|
||||
|
|
@ -2314,39 +2373,87 @@ async fn ai_chat_ask(
|
|||
auth: AuthUser,
|
||||
Json(body): Json<AskAshRequest>,
|
||||
) -> 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<CreatedTicket> = 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::<Vec<_>>()
|
||||
.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()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue