feat: add LiteLLM provider and cover letter generation

- Add LiteLLM provider implementing AiProvider trait
- Support both Ollama and LiteLLM via LLM_PROVIDER env var
- Add cover letter generation endpoint
- Improve chat orchestrator with better intent detection
- Add search_kb, explain_plan_limits, check_ai_pack_balance intents
- Add LiteLLM config env vars
This commit is contained in:
Tracewebstudio Dev 2026-06-14 18:00:33 +02:00
parent 0be8bd65b5
commit 7d31d1a2bd
13 changed files with 379 additions and 19 deletions

View file

@ -12,4 +12,6 @@ pub struct ChatMessageResponse {
pub intent: String, pub intent: String,
pub reply: String, pub reply: String,
pub data: serde_json::Value, pub data: serde_json::Value,
#[serde(skip_serializing_if = "Option::is_none")]
pub conversation_id: Option<String>,
} }

View file

@ -42,6 +42,7 @@ impl ChatOrchestrator {
request: ChatMessageRequest, request: ChatMessageRequest,
) -> Result<ChatMessageResponse, AppError> { ) -> Result<ChatMessageResponse, AppError> {
let intent = classify_intent(&request.message); let intent = classify_intent(&request.message);
let conversation_id = request.conversation_id.clone();
match intent.as_str() { match intent.as_str() {
"job_description_generation" => { "job_description_generation" => {
@ -60,9 +61,10 @@ impl ChatOrchestrator {
.await?; .await?;
Ok(ChatMessageResponse { Ok(ChatMessageResponse {
intent, intent: intent.clone(),
reply: "Generated a draft job description.".to_string(), reply: "Generated a draft job description.".to_string(),
data: serde_json::to_value(jd).unwrap_or(serde_json::Value::Null), data: serde_json::to_value(jd).unwrap_or(serde_json::Value::Null),
conversation_id: conversation_id.clone(),
}) })
} }
"form_filling_assistance" => { "form_filling_assistance" => {
@ -75,23 +77,32 @@ impl ChatOrchestrator {
.await?; .await?;
Ok(ChatMessageResponse { Ok(ChatMessageResponse {
intent, intent: intent.clone(),
reply: extracted.suggested_next_step.clone(), reply: extracted.suggested_next_step.clone(),
data: serde_json::to_value(extracted).unwrap_or(serde_json::Value::Null), data: serde_json::to_value(extracted).unwrap_or(serde_json::Value::Null),
conversation_id: conversation_id.clone(),
}) })
} }
"help_article_retrieval" => { "search_kb" => {
let matches = self.help_center.search(&request.message).await?; let matches = self.help_center.search(&request.message).await?;
let reply = if matches.is_empty() { let reply = if matches.is_empty() {
"No help article match found yet.".to_string() "No help article match found. Try rephrasing your question.".to_string()
} else { } else {
format!("Found {} help articles.", matches.len()) let mut response = format!("Found {} help article(s):\n\n", matches.len());
for (i, article) in matches.iter().take(5).enumerate() {
response.push_str(&format!("{}. **{}**\n{}\n\n", i + 1, article.title, article.summary));
}
if matches.len() > 5 {
response.push_str(&format!("...and {} more articles.", matches.len() - 5));
}
response
}; };
Ok(ChatMessageResponse { Ok(ChatMessageResponse {
intent, intent: intent.clone(),
reply, reply,
data: serde_json::json!({ "matches": matches }), data: serde_json::json!({ "matches": matches }),
conversation_id: conversation_id.clone(),
}) })
} }
"support_ticket_creation" => { "support_ticket_creation" => {
@ -103,7 +114,7 @@ impl ChatOrchestrator {
priority: "medium".to_string(), priority: "medium".to_string(),
category: "general".to_string(), category: "general".to_string(),
user_id: request.user_id.unwrap_or_else(|| "anonymous".to_string()), user_id: request.user_id.unwrap_or_else(|| "anonymous".to_string()),
conversation_id: request.conversation_id, conversation_id: conversation_id.clone(),
source: Some("chatbot".to_string()), source: Some("chatbot".to_string()),
tags: Some(vec!["chat".to_string()]), tags: Some(vec!["chat".to_string()]),
metadata: None, metadata: None,
@ -111,16 +122,75 @@ impl ChatOrchestrator {
.await?; .await?;
Ok(ChatMessageResponse { Ok(ChatMessageResponse {
intent, intent: intent.clone(),
reply: format!("Support ticket created: {}", created.ticket_id), reply: format!("Support ticket created: {}. Our team will respond shortly.", created.ticket_id),
data: serde_json::to_value(created).unwrap_or(serde_json::Value::Null), data: serde_json::to_value(created).unwrap_or(serde_json::Value::Null),
conversation_id: conversation_id.clone(),
})
}
"explain_plan_limits" => {
let reply = "Your AI plan determines how many AI actions you can use per month.\n\n\
- **Free AI**: 10 actions/month\n\
- **Starter AI**: 100 actions/month\n\
- **Growth AI**: 500 actions/month\n\
- **Pro AI**: 2000 actions/month\n\n\
You can purchase add-on packs for more usage. Would you like to upgrade your plan?";
Ok(ChatMessageResponse {
intent: intent.clone(),
reply: reply.to_string(),
data: serde_json::json!({}),
conversation_id: conversation_id.clone(),
})
}
"check_ai_pack_balance" => {
let reply = "To check your AI balance, please visit the AI Usage section in your dashboard.\n\n\
You can view:\n\
- Monthly usage vs limit\n\
- Add-on balance remaining\n\
- Renewal date\n\n\
Would you like me to help with anything else?";
Ok(ChatMessageResponse {
intent: intent.clone(),
reply: reply.to_string(),
data: serde_json::json!({}),
conversation_id: conversation_id.clone(),
})
}
"generate_cover_letter" => {
let reply = "I can help you generate a cover letter. Please provide:\n\
- The job title or position\n\
- Your key skills and experience\n\
- Any specific company or role details (optional)\n\n\
Or you can use the 'Generate Cover Letter' button on the job application page.";
Ok(ChatMessageResponse {
intent: intent.clone(),
reply: reply.to_string(),
data: serde_json::json!({}),
conversation_id: conversation_id.clone(),
})
}
"improve_resume_summary" => {
let reply = "I can help improve your resume summary. Please share:\n\
- Your current resume summary (or paste it here)\n\
- The type of role you're targeting\n\
- Your key skills and experience\n\n\
Or you can use the 'Improve Resume' feature in your profile page.";
Ok(ChatMessageResponse {
intent: intent.clone(),
reply: reply.to_string(),
data: serde_json::json!({}),
conversation_id: conversation_id.clone(),
}) })
} }
_ => { _ => {
let generic = self let generic = self
.ai_provider .ai_provider
.complete( .complete(
"You are Nxtgauge workflow assistant. Keep answers concise and actionable.", "You are Nxtgauge workflow assistant. Keep answers concise and actionable. If users ask about features, guide them to use the appropriate buttons or pages.",
&request.message, &request.message,
) )
.await?; .await?;
@ -129,6 +199,7 @@ impl ChatOrchestrator {
intent, intent,
reply: generic, reply: generic,
data: serde_json::json!({}), data: serde_json::json!({}),
conversation_id: conversation_id.clone(),
}) })
} }
} }
@ -137,25 +208,75 @@ impl ChatOrchestrator {
fn classify_intent(message: &str) -> String { fn classify_intent(message: &str) -> String {
let text = message.to_lowercase(); let text = message.to_lowercase();
if text.contains("job description") || text.contains("jd") || text.contains("role") {
if text.contains("job description")
|| text.contains("generate job")
|| text.contains("create job")
|| text.contains("write job")
|| (text.contains("jd") && text.len() < 10)
{
return "job_description_generation".to_string(); return "job_description_generation".to_string();
} }
if text.contains("cover letter")
|| text.contains("write a letter")
|| text.contains("generate letter")
{
return "generate_cover_letter".to_string();
}
if text.contains("resume")
&& (text.contains("improve")
|| text.contains("rewrite")
|| text.contains("summary")
|| text.contains("tailor"))
{
return "improve_resume_summary".to_string();
}
if text.contains("form") || text.contains("field") || text.contains("fill") { if text.contains("form") || text.contains("field") || text.contains("fill") {
return "form_filling_assistance".to_string(); return "form_filling_assistance".to_string();
} }
if text.contains("help")
if text.contains("search kb")
|| text.contains("find article")
|| text.contains("how do i")
|| text.contains("how to")
|| text.contains("where do i")
|| (text.contains("help") && !text.contains("help me"))
|| text.contains("article") || text.contains("article")
|| text.contains("kb") || text.contains("kb")
|| text.contains("docs") || text.contains("docs")
|| text.contains("documentation")
{ {
return "help_article_retrieval".to_string(); return "search_kb".to_string();
} }
if text.contains("ticket") if text.contains("ticket")
|| text.contains("support") || text.contains("support")
|| text.contains("issue") || text.contains("issue")
|| text.contains("bug") || text.contains("bug")
|| text.contains("problem")
|| text.contains("not working")
|| text.contains("error")
{ {
return "support_ticket_creation".to_string(); return "support_ticket_creation".to_string();
} }
if text.contains("ai plan")
|| text.contains("ai limit")
|| text.contains("ai package")
|| text.contains("ai credit")
|| text.contains("upgrade ai")
{
return "explain_plan_limits".to_string();
}
if text.contains("balance")
&& (text.contains("ai") || text.contains("credit") || text.contains("action"))
{
return "check_ai_pack_balance".to_string();
}
"general".to_string() "general".to_string()
} }

View file

@ -5,9 +5,13 @@ pub struct AppConfig {
pub app_host: String, pub app_host: String,
pub app_port: u16, pub app_port: u16,
pub database_url: Option<String>, pub database_url: Option<String>,
pub llm_provider: String,
pub ollama_base_url: String, pub ollama_base_url: String,
pub ollama_chat_model: String, pub ollama_chat_model: String,
pub ollama_embed_model: String, pub ollama_embed_model: String,
pub litellm_base_url: String,
pub litellm_api_key: String,
pub litellm_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, pub nxtgauge_users_url: String,
@ -21,12 +25,19 @@ impl AppConfig {
database_url: std::env::var("DATABASE_URL") database_url: std::env::var("DATABASE_URL")
.ok() .ok()
.filter(|v| !v.trim().is_empty()), .filter(|v| !v.trim().is_empty()),
llm_provider: env_or_default("LLM_PROVIDER", "ollama"),
ollama_base_url: env_or_default( ollama_base_url: env_or_default(
"OLLAMA_BASE_URL", "OLLAMA_BASE_URL",
"http://ollama.nxtgauge-ai.svc.cluster.local:11434", "http://ollama.nxtgauge-ai.svc.cluster.local:11434",
), ),
ollama_chat_model: env_or_default("OLLAMA_CHAT_MODEL", "gemma3:270m"), ollama_chat_model: env_or_default("OLLAMA_CHAT_MODEL", "gemma3:270m"),
ollama_embed_model: env_or_default("OLLAMA_EMBED_MODEL", "nomic-embed-text"), ollama_embed_model: env_or_default("OLLAMA_EMBED_MODEL", "nomic-embed-text"),
litellm_base_url: env_or_default(
"LITELLM_BASE_URL",
"https://llm.nxtgauge.com/v1",
),
litellm_api_key: std::env::var("LITELLM_API_KEY").unwrap_or_default(),
litellm_model: env_or_default("LITELLM_MODEL", "askash-main"),
help_center_seed_path: env_or_default( help_center_seed_path: env_or_default(
"HELP_CENTER_SEED_PATH", "HELP_CENTER_SEED_PATH",
"./seeds/help_articles.json", "./seeds/help_articles.json",

2
src/cover_letter/mod.rs Normal file
View file

@ -0,0 +1,2 @@
pub mod models;
pub mod service;

View file

@ -0,0 +1,18 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GenerateCoverLetterRequest {
pub job_title: String,
pub company_name: Option<String>,
pub applicant_name: Option<String>,
pub applicant_skills: Option<Vec<String>>,
pub applicant_experience: Option<String>,
pub tone: Option<String>,
pub additional_notes: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GenerateCoverLetterResponse {
pub cover_letter: String,
pub raw_markdown: String,
}

View file

@ -0,0 +1,46 @@
use std::sync::Arc;
use crate::{cover_letter::models::*, error::AppError, providers::llm::ai_provider::AiProvider};
#[derive(Clone)]
pub struct CoverLetterService {
ai_provider: Arc<dyn AiProvider>,
}
impl CoverLetterService {
pub fn new(ai_provider: Arc<dyn AiProvider>) -> Self {
Self { ai_provider }
}
pub async fn generate(
&self,
request: GenerateCoverLetterRequest,
) -> Result<GenerateCoverLetterResponse, AppError> {
let system = "You are a professional cover letter writer. Write compelling, concise cover letters that highlight relevant skills and experience. Keep the tone professional but engaging.";
let user_prompt = format!(
"Write a cover letter for:\n\
Job Title: {}\n\
Company Name: {}\n\
Applicant Name: {}\n\
Key Skills: {:?}\n\
Experience: {}\n\
Tone: {}\n\
Additional Notes: {:?}\n\n\
Write a professional cover letter with 3-4 short paragraphs. Do not invent details not provided.",
request.job_title,
request.company_name.unwrap_or_else(|| "the company".to_string()),
request.applicant_name.unwrap_or_else(|| "the applicant".to_string()),
request.applicant_skills.clone().unwrap_or_default(),
request.applicant_experience.unwrap_or_else(|| "relevant experience".to_string()),
request.tone.unwrap_or_else(|| "professional".to_string()),
request.additional_notes
);
let generated = self.ai_provider.complete(system, &user_prompt).await?;
Ok(GenerateCoverLetterResponse {
cover_letter: generated.lines().take(10).collect::<Vec<_>>().join("\n"),
raw_markdown: generated,
})
}
}

View file

@ -0,0 +1,19 @@
use axum::{extract::State, Json};
use crate::{
cover_letter::models::{GenerateCoverLetterRequest, GenerateCoverLetterResponse},
state::AppState,
error::AppError,
};
pub async fn generate_cover_letter(
State(state): State<AppState>,
Json(request): Json<GenerateCoverLetterRequest>,
) -> Result<Json<GenerateCoverLetterResponse>, AppError> {
if request.job_title.trim().is_empty() {
return Err(AppError::BadRequest("job_title is required".to_string()));
}
let generated = state.cover_letter_service.generate(request).await?;
Ok(Json(generated))
}

View file

@ -1,4 +1,5 @@
pub mod chat; pub mod chat;
pub mod cover_letter;
pub mod forms; pub mod forms;
pub mod health; pub mod health;
pub mod help; pub mod help;

View file

@ -1,5 +1,6 @@
mod chat; mod chat;
mod config; mod config;
mod cover_letter;
mod db; mod db;
mod error; mod error;
mod forms; mod forms;
@ -16,7 +17,9 @@ use std::sync::Arc;
use config::AppConfig; use config::AppConfig;
use providers::help_center::nxtgauge_help_center_provider::NxtgaugeHelpCenterProvider; use providers::help_center::nxtgauge_help_center_provider::NxtgaugeHelpCenterProvider;
use providers::llm::ollama_provider::OllamaAiProvider; use providers::llm::ollama_provider::OllamaAiProvider;
use providers::llm::litellm_provider::LiteLLMProvider;
use providers::tickets::nxtgauge_ticket_provider::NxtgaugeTicketProvider; use providers::tickets::nxtgauge_ticket_provider::NxtgaugeTicketProvider;
use providers::llm::ai_provider::AiProvider;
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};
@ -35,12 +38,22 @@ async fn main() {
} }
}; };
let ai_provider = Arc::new(OllamaAiProvider::new( let ai_provider: Arc<dyn AiProvider> = if cfg.llm_provider == "litellm" {
info!("Using LiteLLM provider with model {}", cfg.litellm_model);
Arc::new(LiteLLMProvider::new(
cfg.litellm_base_url.clone(),
cfg.litellm_api_key.clone(),
cfg.litellm_model.clone(),
)) as Arc<dyn AiProvider>
} else {
info!("Using Ollama provider with model {}", cfg.ollama_chat_model);
Arc::new(OllamaAiProvider::new(
cfg.ollama_base_url.clone(), cfg.ollama_base_url.clone(),
cfg.ollama_chat_model.clone(), cfg.ollama_chat_model.clone(),
)); )) as Arc<dyn AiProvider>
};
let embedding_provider = Arc::new(OllamaEmbeddingProvider::new( let _embedding_provider = Arc::new(OllamaEmbeddingProvider::new(
cfg.ollama_base_url.clone(), cfg.ollama_base_url.clone(),
cfg.ollama_embed_model.clone(), cfg.ollama_embed_model.clone(),
)); ));

View file

@ -0,0 +1,118 @@
use async_trait::async_trait;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use crate::{error::AppError, providers::llm::ai_provider::AiProvider};
#[derive(Clone)]
pub struct LiteLLMProvider {
client: Client,
base_url: String,
api_key: String,
model: String,
}
impl LiteLLMProvider {
pub fn new(base_url: String, api_key: String, model: String) -> Self {
Self {
client: Client::new(),
base_url,
api_key,
model,
}
}
fn fallback_response(user_prompt: &str) -> String {
format!(
"LiteLLM model is unavailable right now. I captured your request so workflow can continue: {}",
user_prompt
)
}
}
#[derive(Debug, Serialize)]
struct ChatMessage {
role: String,
content: String,
}
#[derive(Debug, Serialize)]
struct ChatCompletionRequest {
model: String,
messages: Vec<ChatMessage>,
temperature: f32,
max_tokens: i32,
}
#[derive(Debug, Deserialize)]
struct ChatCompletionResponse {
choices: Vec<Choice>,
}
#[derive(Debug, Deserialize)]
struct Choice {
message: Message,
}
#[derive(Debug, Deserialize)]
struct Message {
content: String,
}
#[async_trait]
impl AiProvider for LiteLLMProvider {
async fn complete(&self, system_prompt: &str, user_prompt: &str) -> Result<String, AppError> {
let url = format!("{}/chat/completions", self.base_url.trim_end_matches('/'));
let payload = ChatCompletionRequest {
model: self.model.clone(),
messages: vec![
ChatMessage {
role: "system".to_string(),
content: system_prompt.to_string(),
},
ChatMessage {
role: "user".to_string(),
content: user_prompt.to_string(),
},
],
temperature: 0.2,
max_tokens: 2048,
};
let res = self
.client
.post(url)
.header("Authorization", format!("Bearer {}", self.api_key))
.header("Content-Type", "application/json")
.json(&payload)
.send()
.await;
let Ok(res) = res else {
return Ok(Self::fallback_response(user_prompt));
};
if !res.status().is_success() {
let status = res.status();
let body = res.text().await.unwrap_or_default();
tracing::warn!("LiteLLM request failed: {} - {}", status, body);
return Ok(Self::fallback_response(user_prompt));
}
let body: Result<ChatCompletionResponse, _> = res.json().await;
match body {
Ok(parsed) => {
if let Some(choice) = parsed.choices.first() {
Ok(choice.message.content.trim().to_string())
} else {
Ok(Self::fallback_response(user_prompt))
}
}
Err(e) => {
tracing::warn!("Failed to parse LiteLLM response: {}", e);
Ok(Self::fallback_response(user_prompt))
}
}
}
}

View file

@ -1,2 +1,3 @@
pub mod ai_provider; pub mod ai_provider;
pub mod ollama_provider; pub mod ollama_provider;
pub mod litellm_provider;

View file

@ -17,6 +17,10 @@ pub fn build_router(state: AppState) -> Router {
"/jobs/generate-description", "/jobs/generate-description",
post(handlers::jobs::generate_description), post(handlers::jobs::generate_description),
) )
.route(
"/cover-letter/generate",
post(handlers::cover_letter::generate_cover_letter),
)
.route("/forms/extract", post(handlers::forms::extract)) .route("/forms/extract", post(handlers::forms::extract))
.route("/tickets/create", post(handlers::tickets::create)) .route("/tickets/create", post(handlers::tickets::create))
.route("/help/search", post(handlers::help::search)), .route("/help/search", post(handlers::help::search)),

View file

@ -3,6 +3,7 @@ use std::sync::Arc;
use crate::{ use crate::{
chat::orchestrator::ChatOrchestrator, chat::orchestrator::ChatOrchestrator,
config::AppConfig, config::AppConfig,
cover_letter::service::CoverLetterService,
db::Database, db::Database,
forms::service::FormService, forms::service::FormService,
jobs::service::JobsService, jobs::service::JobsService,
@ -20,6 +21,7 @@ pub struct AppState {
pub chat_orchestrator: ChatOrchestrator, pub chat_orchestrator: ChatOrchestrator,
pub jobs_service: JobsService, pub jobs_service: JobsService,
pub form_service: FormService, pub form_service: FormService,
pub cover_letter_service: CoverLetterService,
pub ticket_service: TicketService, pub ticket_service: TicketService,
pub help_center: Arc<dyn HelpCenterProvider>, pub help_center: Arc<dyn HelpCenterProvider>,
} }
@ -34,13 +36,14 @@ impl AppState {
) -> Self { ) -> Self {
let jobs_service = JobsService::new(ai_provider.clone()); let jobs_service = JobsService::new(ai_provider.clone());
let form_service = FormService::new(ai_provider.clone()); let form_service = FormService::new(ai_provider.clone());
let cover_letter_service = CoverLetterService::new(ai_provider.clone());
let ticket_service = TicketService::new(ticket_provider, db.clone()); let ticket_service = TicketService::new(ticket_provider, db.clone());
let chat_orchestrator = ChatOrchestrator::new( let chat_orchestrator = ChatOrchestrator::new(
jobs_service.clone(), jobs_service.clone(),
form_service.clone(), form_service.clone(),
help_center.clone(), help_center.clone(),
ticket_service.clone(), ticket_service.clone(),
ai_provider, ai_provider.clone(),
); );
Self { Self {
@ -49,6 +52,7 @@ impl AppState {
chat_orchestrator, chat_orchestrator,
jobs_service, jobs_service,
form_service, form_service,
cover_letter_service,
ticket_service, ticket_service,
help_center, help_center,
} }