feat: look up records by human-readable reference number in chat
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:
Ashwin Kumar Sivakumar 2026-07-19 16:36:44 +05:30
parent fef82cb92d
commit 114626e169
7 changed files with 278 additions and 32 deletions

View file

@ -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,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<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);
}

View file

@ -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);

View file

@ -1,3 +1,4 @@
pub mod help_center;
pub mod llm;
pub mod reference;
pub mod tickets;

View file

@ -0,0 +1,2 @@
pub mod nxtgauge_reference_provider;
pub mod reference_lookup_provider;

View 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))
}
}

View 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>;
}

View file

@ -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,
}
}