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 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()
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue