feat: wire NxtgaugeTicketProvider and NxtgaugeHelpCenterProvider to real backend APIs

- Add NxtgaugeTicketProvider: calls /api/support/tickets/ai/create with service key auth
- Add NxtgaugeHelpCenterProvider: calls /api/kb/articles for help search
- Add ExternalService error variant for HTTP call failures
- Add NXTGAUGE_USERS_URL config env var
This commit is contained in:
Tracewebstudio Dev 2026-04-15 18:18:39 +02:00
parent dbba72478c
commit 72d44bbd63
9 changed files with 195 additions and 6 deletions

7
Cargo.lock generated
View file

@ -1189,6 +1189,7 @@ dependencies = [
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"url", "url",
"urlencoding",
"uuid", "uuid",
] ]
@ -2495,6 +2496,12 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "urlencoding"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]] [[package]]
name = "utf8_iter" name = "utf8_iter"
version = "1.0.4" version = "1.0.4"

View file

@ -18,6 +18,7 @@ uuid = { version = "1", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio-rustls", "postgres", "chrono", "uuid", "migrate", "macros"] } sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio-rustls", "postgres", "chrono", "uuid", "migrate", "macros"] }
url = "2" url = "2"
urlencoding = "2"
[dev-dependencies] [dev-dependencies]
http-body-util = "0.1" http-body-util = "0.1"

View file

@ -10,6 +10,7 @@ pub struct AppConfig {
pub ollama_embed_model: String, pub ollama_embed_model: String,
pub help_center_seed_path: String, pub help_center_seed_path: String,
pub tickets_source: String, pub tickets_source: String,
pub nxtgauge_users_url: String,
} }
impl AppConfig { impl AppConfig {
@ -28,6 +29,10 @@ impl AppConfig {
"./seeds/help_articles.json", "./seeds/help_articles.json",
), ),
tickets_source: env_or_default("TICKETS_SOURCE", "chatbot"), tickets_source: env_or_default("TICKETS_SOURCE", "chatbot"),
nxtgauge_users_url: env_or_default(
"NXTGAUGE_USERS_URL",
"http://nxtgauge-rust-users:9101",
),
} }
} }

View file

@ -13,6 +13,8 @@ pub enum AppError {
ProviderUnavailable(String), ProviderUnavailable(String),
#[error("internal error: {0}")] #[error("internal error: {0}")]
Internal(String), Internal(String),
#[error("external service error: {0}")]
ExternalService(String),
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
@ -26,6 +28,7 @@ impl IntoResponse for AppError {
AppError::BadRequest(_) => StatusCode::BAD_REQUEST, AppError::BadRequest(_) => StatusCode::BAD_REQUEST,
AppError::ProviderUnavailable(_) => StatusCode::SERVICE_UNAVAILABLE, AppError::ProviderUnavailable(_) => StatusCode::SERVICE_UNAVAILABLE,
AppError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, AppError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
AppError::ExternalService(_) => StatusCode::BAD_GATEWAY,
}; };
let body = Json(ErrorBody { let body = Json(ErrorBody {

View file

@ -14,9 +14,9 @@ mod tickets;
use std::sync::Arc; use std::sync::Arc;
use config::AppConfig; use config::AppConfig;
use providers::help_center::local_help_center_provider::LocalHelpCenterProvider; use providers::help_center::nxtgauge_help_center_provider::NxtgaugeHelpCenterProvider;
use providers::llm::ollama_provider::OllamaAiProvider; use providers::llm::ollama_provider::OllamaAiProvider;
use providers::tickets::mock_ticket_provider::MockTicketProvider; use providers::tickets::nxtgauge_ticket_provider::NxtgaugeTicketProvider;
use retrieval::embeddings::ollama_embedding_provider::OllamaEmbeddingProvider; use retrieval::embeddings::ollama_embedding_provider::OllamaEmbeddingProvider;
use state::AppState; use state::AppState;
use tracing::{info, warn}; use tracing::{info, warn};
@ -45,12 +45,18 @@ async fn main() {
cfg.ollama_embed_model.clone(), cfg.ollama_embed_model.clone(),
)); ));
let help_center_provider = Arc::new(LocalHelpCenterProvider::from_seed_file( let http_client = reqwest::Client::new();
&cfg.help_center_seed_path,
embedding_provider, let help_center_provider = Arc::new(NxtgaugeHelpCenterProvider::new(
http_client.clone(),
cfg.nxtgauge_users_url.clone(),
)); ));
let ticket_provider = Arc::new(MockTicketProvider::new(cfg.tickets_source.clone())); let ticket_provider = Arc::new(NxtgaugeTicketProvider::new(
http_client,
cfg.nxtgauge_users_url.clone(),
cfg.tickets_source.clone(),
));
let state = AppState::new( let state = AppState::new(
cfg.clone(), cfg.clone(),

View file

@ -1,2 +1,3 @@
pub mod help_center_provider; pub mod help_center_provider;
pub mod local_help_center_provider; pub mod local_help_center_provider;
pub mod nxtgauge_help_center_provider;

View file

@ -0,0 +1,74 @@
use async_trait::async_trait;
use reqwest::Client;
use crate::{
error::AppError,
providers::help_center::help_center_provider::{HelpArticle, HelpCenterProvider},
};
#[derive(Clone)]
pub struct NxtgaugeHelpCenterProvider {
client: Client,
base_url: String,
}
impl NxtgaugeHelpCenterProvider {
pub fn new(client: Client, base_url: String) -> Self {
Self { client, base_url }
}
}
#[async_trait]
impl HelpCenterProvider for NxtgaugeHelpCenterProvider {
async fn search(&self, query: &str) -> Result<Vec<HelpArticle>, AppError> {
let url = format!("{}/api/kb/articles?q={}", self.base_url, urlencoding::encode(query));
let response = self
.client
.get(&url)
.send()
.await
.map_err(|e| AppError::ExternalService(format!("failed to call nxtgauge help center: {}", e)))?;
let status = response.status();
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
return Err(AppError::ExternalService(format!(
"help center API returned {}: {}",
status,
body
)));
}
#[derive(serde::Deserialize)]
struct KbSearchResponse {
articles: Vec<ArticleDto>,
}
#[derive(serde::Deserialize)]
struct ArticleDto {
id: String,
title: String,
summary: Option<String>,
content: String,
tags: Vec<String>,
}
let search_result: KbSearchResponse = response
.json()
.await
.map_err(|e| AppError::ExternalService(format!("failed to parse KB response: {}", e)))?;
Ok(search_result
.articles
.into_iter()
.map(|a| HelpArticle {
id: a.id,
title: a.title,
summary: a.summary.unwrap_or_default(),
content: a.content,
tags: a.tags,
})
.collect())
}
}

View file

@ -1,2 +1,3 @@
pub mod mock_ticket_provider; pub mod mock_ticket_provider;
pub mod nxtgauge_ticket_provider;
pub mod ticket_provider; pub mod ticket_provider;

View file

@ -0,0 +1,91 @@
use async_trait::async_trait;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use crate::{
error::AppError,
providers::tickets::ticket_provider::{TicketCreatePayload, TicketCreateResult, TicketProvider},
};
#[derive(Clone)]
pub struct NxtgaugeTicketProvider {
client: Client,
base_url: String,
source: String,
}
#[derive(Debug, Deserialize)]
struct NxtgaugeTicketResponse {
id: String,
status: String,
}
#[derive(Serialize)]
struct NxtgaugeTicketRequest {
subject: String,
description: Option<String>,
category: Option<String>,
priority: Option<String>,
#[serde(rename = "userId")]
user_id: Option<String>,
}
impl NxtgaugeTicketProvider {
pub fn new(client: Client, base_url: String, source: String) -> Self {
Self {
client,
base_url,
source,
}
}
}
#[async_trait]
impl TicketProvider for NxtgaugeTicketProvider {
async fn create_ticket(
&self,
payload: TicketCreatePayload,
) -> Result<TicketCreateResult, AppError> {
let url = format!("{}/api/support/tickets/ai/create", self.base_url);
let req = NxtgaugeTicketRequest {
subject: payload.subject,
description: Some(payload.description),
category: Some(payload.category),
priority: Some(payload.priority),
user_id: Some(payload.user_id),
};
let ai_service_key = std::env::var("AI_SERVICE_KEY")
.unwrap_or_else(|_| "nxtgauge-ai-assistant".to_string());
let response = self
.client
.post(&url)
.header("X-AI-Service-Key", ai_service_key)
.json(&req)
.send()
.await
.map_err(|e| AppError::ExternalService(format!("failed to call nxtgauge ticket API: {}", e)))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(AppError::ExternalService(format!(
"nxtgauge ticket API returned {}: {}",
status, body
)));
}
let result: NxtgaugeTicketResponse = response
.json()
.await
.map_err(|e| AppError::ExternalService(format!("failed to parse ticket response: {}", e)))?;
Ok(TicketCreateResult {
ticket_id: result.id,
status: result.status,
provider: format!("nxtgauge:{}", self.source),
})
}
}