feat: look up records by human-readable reference number in chat
All checks were successful
build-and-release / build (push) Successful in 3m26s
All checks were successful
build-and-release / build (push) Successful in 3m26s
Adds a ReferenceLookupProvider that calls the backend's new
GET /api/support/reference/{number} endpoint, and wires intent detection
so a customer mentioning a reference number like NXT-VER-26-000042 gets
that record's status looked up and summarized directly in chat.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
fef82cb92d
commit
114626e169
7 changed files with 278 additions and 32 deletions
|
|
@ -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<dyn HelpCenterProvider>,
|
||||
content_tools_service: ContentToolsService,
|
||||
ai_provider: Arc<dyn AiProvider>,
|
||||
reference_lookup_provider: Arc<dyn ReferenceLookupProvider>,
|
||||
}
|
||||
|
||||
impl ChatOrchestrator {
|
||||
|
|
@ -34,6 +36,7 @@ impl ChatOrchestrator {
|
|||
_ticket_service: TicketService,
|
||||
content_tools_service: ContentToolsService,
|
||||
ai_provider: Arc<dyn AiProvider>,
|
||||
reference_lookup_provider: Arc<dyn ReferenceLookupProvider>,
|
||||
) -> Self {
|
||||
Self {
|
||||
jobs_service,
|
||||
|
|
@ -41,6 +44,7 @@ impl ChatOrchestrator {
|
|||
help_center,
|
||||
content_tools_service,
|
||||
ai_provider,
|
||||
reference_lookup_provider,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -189,40 +193,57 @@ 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);
|
||||
|
||||
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: "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(),
|
||||
reply,
|
||||
data: serde_json::to_value(&result).unwrap_or(serde_json::Value::Null),
|
||||
status: "completed".to_string(),
|
||||
conversation_id,
|
||||
confidence: Some(confidence),
|
||||
fields: Some(fields.clone()),
|
||||
fields: Some(serde_json::json!({
|
||||
"type": result.record_type,
|
||||
"reference_number": result.reference_number,
|
||||
"status": result.status,
|
||||
})),
|
||||
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,
|
||||
}]),
|
||||
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
|
||||
.form_service
|
||||
|
|
@ -610,6 +631,47 @@ impl ChatOrchestrator {
|
|||
}
|
||||
}
|
||||
|
||||
fn handle_support_ticket_creation(
|
||||
&self,
|
||||
request: &ChatMessageRequest,
|
||||
intent: String,
|
||||
confidence: f32,
|
||||
conversation_id: Option<String>,
|
||||
) -> Result<ChatMessageResponse, AppError> {
|
||||
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::<String>() + "..."
|
||||
}
|
||||
|
||||
/// 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<String> {
|
||||
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<String> {
|
||||
let chars: Vec<char> = 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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
pub mod help_center;
|
||||
pub mod llm;
|
||||
pub mod reference;
|
||||
pub mod tickets;
|
||||
|
|
|
|||
2
src/providers/reference/mod.rs
Normal file
2
src/providers/reference/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
pub mod nxtgauge_reference_provider;
|
||||
pub mod reference_lookup_provider;
|
||||
76
src/providers/reference/nxtgauge_reference_provider.rs
Normal file
76
src/providers/reference/nxtgauge_reference_provider.rs
Normal file
|
|
@ -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<Option<ReferenceLookupResult>, 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))
|
||||
}
|
||||
}
|
||||
23
src/providers/reference/reference_lookup_provider.rs
Normal file
23
src/providers/reference/reference_lookup_provider.rs
Normal file
|
|
@ -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<Option<ReferenceLookupResult>, AppError>;
|
||||
}
|
||||
|
|
@ -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<dyn HelpCenterProvider>,
|
||||
pub reference_lookup_provider: Arc<dyn ReferenceLookupProvider>,
|
||||
pub action_confirmation_service: ActionConfirmationService,
|
||||
}
|
||||
|
||||
|
|
@ -37,6 +39,7 @@ impl AppState {
|
|||
ai_provider: Arc<dyn AiProvider>,
|
||||
help_center: Arc<dyn HelpCenterProvider>,
|
||||
ticket_provider: Arc<dyn TicketProvider>,
|
||||
reference_lookup_provider: Arc<dyn ReferenceLookupProvider>,
|
||||
) -> 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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue