diff --git a/src/chat/orchestrator.rs b/src/chat/orchestrator.rs index 576cc32..322365d 100644 --- a/src/chat/orchestrator.rs +++ b/src/chat/orchestrator.rs @@ -13,6 +13,7 @@ use crate::{ jobs::{models::GenerateJobDescriptionRequest, service::JobsService}, providers::{ help_center::help_center_provider::HelpCenterProvider, llm::ai_provider::AiProvider, + reference::reference_lookup_provider::ReferenceLookupProvider, }, tickets::service::TicketService, }; @@ -24,6 +25,7 @@ pub struct ChatOrchestrator { help_center: Arc, content_tools_service: ContentToolsService, ai_provider: Arc, + reference_lookup_provider: Arc, } impl ChatOrchestrator { @@ -34,6 +36,7 @@ impl ChatOrchestrator { _ticket_service: TicketService, content_tools_service: ContentToolsService, ai_provider: Arc, + reference_lookup_provider: Arc, ) -> Self { Self { jobs_service, @@ -41,6 +44,7 @@ impl ChatOrchestrator { help_center, content_tools_service, ai_provider, + reference_lookup_provider, } } @@ -189,39 +193,56 @@ impl ChatOrchestrator { ui_events: None, }) } - "support_ticket_creation" => { - let fields = serde_json::json!({ - "subject": summarize_subject(&request.message), - "description": request.message, - "priority": "medium", - "category": "general", - }); + "reference_lookup" => { + let reference_number = extract_reference_number(&request.message); - Ok(ChatMessageResponse { - intent: intent.clone(), - reply: "I can prepare a support ticket. Confirm this action in your product backend to create it.".to_string(), - data: fields.clone(), - status: "needs_confirmation".to_string(), - conversation_id, - confidence: Some(confidence), - fields: Some(fields.clone()), - missing_fields: None, - requires_confirmation: Some(true), - suggested_action: Some(SuggestedAction { - action: "create_support_ticket".to_string(), - backend_handler: "support.create_ticket".to_string(), - requires_confirmation: true, - feature_code: Some("support_ticket_creation".to_string()), - fields: Some(fields), - missing_fields: None, - }), - ui_events: Some(vec![UiEvent { - event_type: "show_confirmation".to_string(), - target: "support_ticket".to_string(), - record_id: None, - fields: None, - }]), - }) + let looked_up = match (&reference_number, request.user_id.as_deref()) { + (Some(reference_number), Some(user_id)) if !user_id.is_empty() => { + self.reference_lookup_provider + .lookup(reference_number, user_id) + .await? + } + _ => None, + }; + + match looked_up { + Some(result) => { + let reply = format!( + "Your {} {} is currently {}. {}", + record_type_label(&result.record_type), + result.reference_number, + result.status, + result.summary + ); + + Ok(ChatMessageResponse { + intent: intent.clone(), + reply, + data: serde_json::to_value(&result).unwrap_or(serde_json::Value::Null), + status: "completed".to_string(), + conversation_id, + confidence: Some(confidence), + fields: Some(serde_json::json!({ + "type": result.record_type, + "reference_number": result.reference_number, + "status": result.status, + })), + missing_fields: None, + requires_confirmation: Some(false), + suggested_action: build_suggested_action("reference_lookup", None, None), + ui_events: None, + }) + } + // Not found (or not owned by this user), or we don't have a user_id to + // scope the lookup to - fall back to regular support ticket creation + // handling for this message rather than a dead-end error. + None => { + self.handle_support_ticket_creation(&request, intent, confidence, conversation_id) + } + } + } + "support_ticket_creation" => { + self.handle_support_ticket_creation(&request, intent, confidence, conversation_id) } "support_ticket_summary" => { let extracted = self @@ -610,6 +631,47 @@ impl ChatOrchestrator { } } + fn handle_support_ticket_creation( + &self, + request: &ChatMessageRequest, + intent: String, + confidence: f32, + conversation_id: Option, + ) -> Result { + let fields = serde_json::json!({ + "subject": summarize_subject(&request.message), + "description": request.message, + "priority": "medium", + "category": "general", + }); + + Ok(ChatMessageResponse { + intent: intent.clone(), + reply: "I can prepare a support ticket. Confirm this action in your product backend to create it.".to_string(), + data: fields.clone(), + status: "needs_confirmation".to_string(), + conversation_id, + confidence: Some(confidence), + fields: Some(fields.clone()), + missing_fields: None, + requires_confirmation: Some(true), + suggested_action: Some(SuggestedAction { + action: "create_support_ticket".to_string(), + backend_handler: "support.create_ticket".to_string(), + requires_confirmation: true, + feature_code: Some("support_ticket_creation".to_string()), + fields: Some(fields), + missing_fields: None, + }), + ui_events: Some(vec![UiEvent { + event_type: "show_confirmation".to_string(), + target: "support_ticket".to_string(), + record_id: None, + fields: None, + }]), + }) + } + async fn improve_text_intent( &self, request: &ChatMessageRequest, @@ -763,6 +825,17 @@ fn build_suggested_action( }) } +fn record_type_label(record_type: &str) -> &str { + match record_type { + "VERIFICATION" => "verification", + "TICKET" => "support ticket", + "PAYMENT" => "payment", + "JOB_APPLICATION" => "job application", + "LEAD_REQUEST" => "lead request", + _ => "record", + } +} + fn summarize_subject(message: &str) -> String { let trimmed = message.trim(); if trimmed.len() <= 80 { @@ -771,9 +844,67 @@ fn summarize_subject(message: &str) -> String { trimmed.chars().take(77).collect::() + "..." } +/// Scans `message` for a human-readable reference number of the form +/// `NXT-{TYPE}-{YY}-{000001}` (e.g. `NXT-VER-26-000042`), case-insensitively, +/// and returns it normalized to uppercase. +fn extract_reference_number(message: &str) -> Option { + let upper = message.to_uppercase(); + let bytes = upper.as_bytes(); + let prefix = b"NXT-"; + + let mut i = 0; + while i + prefix.len() <= bytes.len() { + if &bytes[i..i + prefix.len()] == prefix { + if let Some(candidate) = match_reference_at(&upper, i) { + return Some(candidate); + } + } + i += 1; + } + + None +} + +/// Attempts to match the reference number pattern starting at byte offset +/// `start` in `s` (which must already be uppercase). `start` is guaranteed to +/// be a valid char boundary because it was located via an ASCII `NXT-` match. +fn match_reference_at(s: &str, start: usize) -> Option { + let chars: Vec = s[start..].chars().collect(); + // NXT- LLL - DD - DDDDDD + // 4 + 3 + 1 + 2 + 1 + 6 = 17 chars + const LEN: usize = 17; + if chars.len() < LEN { + return None; + } + if chars[0..4] != ['N', 'X', 'T', '-'] { + return None; + } + if !chars[4..7].iter().all(|c| c.is_ascii_uppercase()) { + return None; + } + if chars[7] != '-' { + return None; + } + if !chars[8..10].iter().all(|c| c.is_ascii_digit()) { + return None; + } + if chars[10] != '-' { + return None; + } + if !chars[11..17].iter().all(|c| c.is_ascii_digit()) { + return None; + } + + Some(chars[0..LEN].iter().collect()) +} + fn classify_intent(message: &str) -> (String, f32) { let text = message.to_lowercase(); + if extract_reference_number(message).is_some() { + return ("reference_lookup".to_string(), 0.95); + } + if text.contains("job post") && (text.contains("improve") || text.contains("edit") || text.contains("rewrite")) { return ("improve_job_post".to_string(), 0.88); } diff --git a/src/main.rs b/src/main.rs index 0dc8386..9381830 100644 --- a/src/main.rs +++ b/src/main.rs @@ -29,6 +29,7 @@ use providers::llm::fake_provider::FakeAiProvider; use providers::llm::litellm_provider::LiteLLMProvider; use providers::llm::ollama_provider::OllamaAiProvider; use providers::llm::user_key_client::UserKeyClient; +use providers::reference::nxtgauge_reference_provider::NxtgaugeReferenceProvider; use providers::tickets::nxtgauge_ticket_provider::NxtgaugeTicketProvider; use providers::llm::ai_provider::AiProvider; use retrieval::embeddings::ollama_embedding_provider::OllamaEmbeddingProvider; @@ -91,6 +92,12 @@ async fn main() { cfg.nxtgauge_users_url.clone(), )); + let reference_lookup_provider = Arc::new(NxtgaugeReferenceProvider::new( + http_client.clone(), + cfg.nxtgauge_users_url.clone(), + cfg.ai_service_key.clone(), + )); + let ticket_provider = Arc::new(NxtgaugeTicketProvider::new( http_client, cfg.nxtgauge_users_url.clone(), @@ -104,6 +111,7 @@ async fn main() { ai_provider, help_center_provider, ticket_provider, + reference_lookup_provider, ); let app = routes::build_router(state); diff --git a/src/providers/mod.rs b/src/providers/mod.rs index 7eec6bc..e4fbf78 100644 --- a/src/providers/mod.rs +++ b/src/providers/mod.rs @@ -1,3 +1,4 @@ pub mod help_center; pub mod llm; +pub mod reference; pub mod tickets; diff --git a/src/providers/reference/mod.rs b/src/providers/reference/mod.rs new file mode 100644 index 0000000..f0d9234 --- /dev/null +++ b/src/providers/reference/mod.rs @@ -0,0 +1,2 @@ +pub mod nxtgauge_reference_provider; +pub mod reference_lookup_provider; diff --git a/src/providers/reference/nxtgauge_reference_provider.rs b/src/providers/reference/nxtgauge_reference_provider.rs new file mode 100644 index 0000000..1469bb9 --- /dev/null +++ b/src/providers/reference/nxtgauge_reference_provider.rs @@ -0,0 +1,76 @@ +use async_trait::async_trait; +use reqwest::Client; + +use crate::{ + error::AppError, + providers::reference::reference_lookup_provider::{ + ReferenceLookupProvider, ReferenceLookupResult, + }, +}; + +#[derive(Clone)] +pub struct NxtgaugeReferenceProvider { + client: Client, + base_url: String, + ai_service_key: String, +} + +impl NxtgaugeReferenceProvider { + pub fn new(client: Client, base_url: String, ai_service_key: String) -> Self { + Self { + client, + base_url, + ai_service_key, + } + } +} + +#[async_trait] +impl ReferenceLookupProvider for NxtgaugeReferenceProvider { + async fn lookup( + &self, + reference_number: &str, + user_id: &str, + ) -> Result, AppError> { + let url = format!( + "{}/api/support/reference/{}", + self.base_url, reference_number + ); + + if self.ai_service_key.is_empty() { + return Err(AppError::ExternalService( + "AI_SERVICE_KEY is not configured".to_string(), + )); + } + + let response = self + .client + .get(&url) + .header("X-AI-Service-Key", &self.ai_service_key) + .query(&[("user_id", user_id)]) + .send() + .await + .map_err(|e| { + AppError::ExternalService(format!("failed to call nxtgauge reference API: {}", e)) + })?; + + if response.status() == reqwest::StatusCode::NOT_FOUND { + return Ok(None); + } + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(AppError::ExternalService(format!( + "nxtgauge reference API returned {}: {}", + status, body + ))); + } + + let result: ReferenceLookupResult = response.json().await.map_err(|e| { + AppError::ExternalService(format!("failed to parse reference response: {}", e)) + })?; + + Ok(Some(result)) + } +} diff --git a/src/providers/reference/reference_lookup_provider.rs b/src/providers/reference/reference_lookup_provider.rs new file mode 100644 index 0000000..5bdcbea --- /dev/null +++ b/src/providers/reference/reference_lookup_provider.rs @@ -0,0 +1,23 @@ +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; + +use crate::error::AppError; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReferenceLookupResult { + #[serde(rename = "type")] + pub record_type: String, + pub reference_number: String, + pub status: String, + pub summary: String, + pub created_at: String, +} + +#[async_trait] +pub trait ReferenceLookupProvider: Send + Sync { + async fn lookup( + &self, + reference_number: &str, + user_id: &str, + ) -> Result, AppError>; +} diff --git a/src/state.rs b/src/state.rs index 3a8ee23..e269024 100644 --- a/src/state.rs +++ b/src/state.rs @@ -10,6 +10,7 @@ use crate::{ jobs::service::JobsService, providers::{ help_center::help_center_provider::HelpCenterProvider, llm::ai_provider::AiProvider, + reference::reference_lookup_provider::ReferenceLookupProvider, tickets::ticket_provider::TicketProvider, }, services::action_confirmation::ActionConfirmationService, @@ -27,6 +28,7 @@ pub struct AppState { pub content_tools_service: ContentToolsService, pub ticket_service: TicketService, pub help_center: Arc, + pub reference_lookup_provider: Arc, pub action_confirmation_service: ActionConfirmationService, } @@ -37,6 +39,7 @@ impl AppState { ai_provider: Arc, help_center: Arc, ticket_provider: Arc, + reference_lookup_provider: Arc, ) -> Self { let jobs_service = JobsService::new(ai_provider.clone()); let form_service = FormService::new(ai_provider.clone()); @@ -50,6 +53,7 @@ impl AppState { ticket_service.clone(), content_tools_service.clone(), ai_provider.clone(), + reference_lookup_provider.clone(), ); let action_confirmation_service = ActionConfirmationService::new(); @@ -63,6 +67,7 @@ impl AppState { content_tools_service, ticket_service, help_center, + reference_lookup_provider, action_confirmation_service, } }