Route through LiteLLM's task-specific models and implement missing Ask Ash features

Wires up 8 previously missing/stub Ask Ash capabilities: resume
improvement, job post improvement, professional/jobseeker/company
profile improvement, service description generation, KB
article/notification writing, admin support ticket summarization, and
lead/credit guidance. Also upgrades explain_plan_limits and
check_ai_pack_balance from static canned strings to real generated
answers.

Adds AiProvider::complete_as(model, ...) so callers can target
specific LiteLLM model aliases (jd-generator, profile-writer,
service-writer, support-drafter, decision-support, askash-main/fast)
that were already defined in apps/litellm/base/configmap.yaml but
never actually used by ai-assistant, since it wasn't even configured
to use the litellm provider (defaulted to plain Ollama with the tiny
gemma3:270m model for every task, with no LLM_PROVIDER/LITELLM_* env
vars set in the deployment).

New content_tools module holds the shared generation logic; new
routes registered for each feature; KB content generation and support
ticket summarization are gated to ADMIN/EMPLOYEE roles via JWT claims.
Registry gains 4 new ActionDefinitions (improve_job_post,
generate_kb_content, lead_credit_guidance, ai_auto_apply_status) to
match the existing registry pattern.
This commit is contained in:
Ashwin Kumar Sivakumar 2026-07-02 18:48:43 +05:30
parent 6decf8dce2
commit f0fbb15e54
17 changed files with 1119 additions and 105 deletions

View file

@ -355,6 +355,75 @@ pub fn get_action_registry() -> Vec<ActionDefinition> {
uses_llm: false, uses_llm: false,
backend_handler: "ai.check_balance".to_string(), backend_handler: "ai.check_balance".to_string(),
}, },
ActionDefinition {
action_code: "improve_job_post".to_string(),
intent: "improve_job_post".to_string(),
allowed_roles: vec!["COMPANY".to_string()],
requires_login: true,
requires_verification: true,
requires_confirmation: true,
required_fields: vec!["current_description".to_string()],
optional_fields: vec![],
feature_code: "improve_job_post".to_string(),
ai_action_cost: 2,
uses_llm: true,
backend_handler: "jobs.improve_post".to_string(),
},
ActionDefinition {
action_code: "generate_kb_content".to_string(),
intent: "generate_kb_content".to_string(),
allowed_roles: vec!["ADMIN".to_string(), "EMPLOYEE".to_string()],
requires_login: true,
requires_verification: true,
requires_confirmation: false,
required_fields: vec!["topic".to_string()],
optional_fields: vec!["details".to_string(), "content_type".to_string()],
feature_code: "generate_kb_content".to_string(),
ai_action_cost: 1,
uses_llm: true,
backend_handler: "support.generate_kb_content".to_string(),
},
ActionDefinition {
action_code: "lead_credit_guidance".to_string(),
intent: "lead_credit_guidance".to_string(),
allowed_roles: vec![
"COMPANY".to_string(),
"CUSTOMER".to_string(),
"PHOTOGRAPHER".to_string(),
"MAKEUP_ARTIST".to_string(),
"TUTOR".to_string(),
"DEVELOPER".to_string(),
"VIDEO_EDITOR".to_string(),
"GRAPHIC_DESIGNER".to_string(),
"SOCIAL_MEDIA_MANAGER".to_string(),
"FITNESS_TRAINER".to_string(),
"CATERING_SERVICES".to_string(),
"UGC_CONTENT_CREATOR".to_string(),
],
requires_login: true,
requires_verification: false,
requires_confirmation: false,
required_fields: vec![],
optional_fields: vec![],
feature_code: "lead_credit_guidance".to_string(),
ai_action_cost: 1,
uses_llm: true,
backend_handler: "ai.lead_credit_guidance".to_string(),
},
ActionDefinition {
action_code: "ai_auto_apply_status".to_string(),
intent: "ai_auto_apply_status".to_string(),
allowed_roles: vec!["JOB_SEEKER".to_string()],
requires_login: true,
requires_verification: false,
requires_confirmation: false,
required_fields: vec![],
optional_fields: vec![],
feature_code: "ai_auto_apply_status".to_string(),
ai_action_cost: 0,
uses_llm: false,
backend_handler: "job_seeker.auto_apply_status".to_string(),
},
] ]
} }

View file

@ -3,6 +3,10 @@ use std::sync::Arc;
use crate::{ use crate::{
actions::get_action, actions::get_action,
chat::models::{ChatMessageRequest, ChatMessageResponse, SuggestedAction}, chat::models::{ChatMessageRequest, ChatMessageResponse, SuggestedAction},
content_tools::{
models::{ImproveTextRequest, KbContentRequest, ServiceDescriptionRequest, SupportSummaryRequest},
service::{ContentToolsService, ProfileKind},
},
error::AppError, error::AppError,
forms::{models::FormExtractRequest, service::FormService}, forms::{models::FormExtractRequest, service::FormService},
handlers::actions::UiEvent, handlers::actions::UiEvent,
@ -18,6 +22,7 @@ pub struct ChatOrchestrator {
jobs_service: JobsService, jobs_service: JobsService,
form_service: FormService, form_service: FormService,
help_center: Arc<dyn HelpCenterProvider>, help_center: Arc<dyn HelpCenterProvider>,
content_tools_service: ContentToolsService,
ai_provider: Arc<dyn AiProvider>, ai_provider: Arc<dyn AiProvider>,
} }
@ -27,12 +32,14 @@ impl ChatOrchestrator {
form_service: FormService, form_service: FormService,
help_center: Arc<dyn HelpCenterProvider>, help_center: Arc<dyn HelpCenterProvider>,
_ticket_service: TicketService, _ticket_service: TicketService,
content_tools_service: ContentToolsService,
ai_provider: Arc<dyn AiProvider>, ai_provider: Arc<dyn AiProvider>,
) -> Self { ) -> Self {
Self { Self {
jobs_service, jobs_service,
form_service, form_service,
help_center, help_center,
content_tools_service,
ai_provider, ai_provider,
} }
} }
@ -89,6 +96,17 @@ impl ChatOrchestrator {
}]), }]),
}) })
} }
"improve_job_post" => {
self.improve_text_intent(
&request,
intent,
confidence,
"improve_job_post",
&["current_description"],
ImproveKind::JobPost,
)
.await
}
"form_filling_assistance" => { "form_filling_assistance" => {
let extracted = self let extracted = self
.form_service .form_service
@ -142,14 +160,14 @@ impl ChatOrchestrator {
let reply = if matches.is_empty() { let reply = if matches.is_empty() {
"No help article match found. Try rephrasing your question.".to_string() "No help article match found. Try rephrasing your question.".to_string()
} else { } else {
let mut response = format!("Found {} help article(s): let mut response = format!("Found {} help article(s):\n\n", matches.len());
", matches.len());
for (i, article) in matches.iter().take(5).enumerate() { for (i, article) in matches.iter().take(5).enumerate() {
response.push_str(&format!("{}. **{}** response.push_str(&format!(
{} "{}. **{}**\n{}\n\n",
i + 1,
", i + 1, article.title, article.summary)); article.title,
article.summary
));
} }
if matches.len() > 5 { if matches.len() > 5 {
response.push_str(&format!("...and {} more articles.", matches.len() - 5)); response.push_str(&format!("...and {} more articles.", matches.len() - 5));
@ -205,11 +223,106 @@ impl ChatOrchestrator {
}]), }]),
}) })
} }
"explain_plan_limits" => Ok(ChatMessageResponse { "support_ticket_summary" => {
let extracted = self
.form_service
.extract(FormExtractRequest {
raw_user_input: request.message.clone(),
expected_fields: Some(vec!["ticket_id".to_string()]),
})
.await?;
if !extracted.missing_fields.is_empty() {
return Ok(needs_input_response(
intent,
confidence,
conversation_id,
"Share the ticket id (e.g. \"ticket_id: 1234\") along with the ticket thread text, and I'll summarize it.",
extracted.missing_fields,
));
}
let ticket_id = field_value(&extracted.fields, "ticket_id").unwrap_or_default();
let summary = self
.content_tools_service
.summarize_support_ticket(SupportSummaryRequest {
ticket_id: ticket_id.clone(),
ticket_text: request.message.clone(),
})
.await?;
Ok(ChatMessageResponse {
intent: intent.clone(), intent: intent.clone(),
reply: "Your backend should explain plan limits in terms of monthly actions, add-on balance, and feature access. Use the billing or usage modal in the product UI for exact numbers.".to_string(), reply: summary.summary.clone(),
data: serde_json::to_value(&summary).unwrap_or(serde_json::Value::Null),
status: "completed".to_string(),
conversation_id,
confidence: Some(confidence),
fields: Some(serde_json::json!({ "ticket_id": ticket_id, "summary": summary.summary })),
missing_fields: None,
requires_confirmation: Some(false),
suggested_action: build_suggested_action("support_ticket_summary", None, None),
ui_events: None,
})
}
"generate_kb_content" => {
let extracted = self
.form_service
.extract(FormExtractRequest {
raw_user_input: request.message.clone(),
expected_fields: Some(vec!["topic".to_string()]),
})
.await?;
if !extracted.missing_fields.is_empty() {
return Ok(needs_input_response(
intent,
confidence,
conversation_id,
"Tell me the topic (e.g. \"topic: resetting your password, content_type: kb_article\") and I'll draft it.",
extracted.missing_fields,
));
}
let topic = field_value(&extracted.fields, "topic").unwrap_or_default();
let content_type = field_value(&extracted.fields, "content_type")
.unwrap_or_else(|| "kb_article".to_string());
let details = field_value(&extracted.fields, "details");
let generated = self
.content_tools_service
.generate_kb_content(KbContentRequest {
content_type,
topic: topic.clone(),
details,
})
.await?;
Ok(ChatMessageResponse {
intent: intent.clone(),
reply: "Drafted the content below. Review before publishing.".to_string(),
data: serde_json::to_value(&generated).unwrap_or(serde_json::Value::Null),
status: "completed".to_string(),
conversation_id,
confidence: Some(confidence),
fields: Some(serde_json::json!({ "topic": topic, "content": generated.content })),
missing_fields: None,
requires_confirmation: Some(false),
suggested_action: build_suggested_action("generate_kb_content", None, None),
ui_events: None,
})
}
"explain_plan_limits" => {
let guidance = self
.content_tools_service
.guidance("AI plan limits, add-ons, and feature access", &request.message)
.await?;
Ok(ChatMessageResponse {
intent: intent.clone(),
reply: guidance,
data: serde_json::json!({}), data: serde_json::json!({}),
status: "info".to_string(), status: "completed".to_string(),
conversation_id, conversation_id,
confidence: Some(confidence), confidence: Some(confidence),
fields: None, fields: None,
@ -222,12 +335,19 @@ impl ChatOrchestrator {
record_id: None, record_id: None,
fields: None, fields: None,
}]), }]),
}), })
"check_ai_pack_balance" => Ok(ChatMessageResponse { }
"check_ai_pack_balance" => {
let guidance = self
.content_tools_service
.guidance("AI action balance, credit packs, and renewals", &request.message)
.await?;
Ok(ChatMessageResponse {
intent: intent.clone(), intent: intent.clone(),
reply: "Open the product usage view to see remaining AI actions, add-ons, and renewal details.".to_string(), reply: guidance,
data: serde_json::json!({}), data: serde_json::json!({}),
status: "info".to_string(), status: "completed".to_string(),
conversation_id, conversation_id,
confidence: Some(confidence), confidence: Some(confidence),
fields: None, fields: None,
@ -240,6 +360,94 @@ impl ChatOrchestrator {
record_id: None, record_id: None,
fields: None, fields: None,
}]), }]),
})
}
"lead_credit_guidance" => {
let guidance = self
.content_tools_service
.guidance("leads, contact requests, and tracecoin credits", &request.message)
.await?;
Ok(ChatMessageResponse {
intent: intent.clone(),
reply: guidance,
data: serde_json::json!({}),
status: "completed".to_string(),
conversation_id,
confidence: Some(confidence),
fields: None,
missing_fields: None,
requires_confirmation: Some(false),
suggested_action: build_suggested_action("lead_credit_guidance", None, None),
ui_events: None,
})
}
"request_professional_contact" => {
let extracted = self
.form_service
.extract(FormExtractRequest {
raw_user_input: request.message.clone(),
expected_fields: Some(vec!["requirement_id".to_string()]),
})
.await?;
if !extracted.missing_fields.is_empty() {
return Ok(needs_input_response(
intent,
confidence,
conversation_id,
"Share the requirement id you'd like to request contact for (e.g. \"requirement_id: abc123\").",
extracted.missing_fields,
));
}
let requirement_id = field_value(&extracted.fields, "requirement_id").unwrap_or_default();
let fields = serde_json::json!({ "requirement_id": requirement_id });
Ok(ChatMessageResponse {
intent: intent.clone(),
reply: "I can send a contact request for this lead. Confirm to proceed - this uses a lead credit.".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: build_suggested_action(
"request_professional_contact",
Some(fields),
None,
),
ui_events: Some(vec![UiEvent {
event_type: "show_confirmation".to_string(),
target: "professional_contact_request".to_string(),
record_id: None,
fields: None,
}]),
})
}
"ai_auto_apply_status" => Ok(ChatMessageResponse {
intent: intent.clone(),
reply: "Auto-apply runs automatically in the background based on the job \
preferences and criteria you've saved - it doesn't need to be \
triggered from chat. Open Settings > Auto Apply to review or change \
your matching criteria, pause it, or see which jobs it applied to."
.to_string(),
data: serde_json::json!({}),
status: "info".to_string(),
conversation_id,
confidence: Some(confidence),
fields: None,
missing_fields: None,
requires_confirmation: Some(false),
suggested_action: build_suggested_action("ai_auto_apply_status", None, None),
ui_events: Some(vec![UiEvent {
event_type: "open_settings".to_string(),
target: "auto_apply".to_string(),
record_id: None,
fields: None,
}]),
}), }),
"generate_cover_letter" => Ok(ChatMessageResponse { "generate_cover_letter" => Ok(ChatMessageResponse {
intent: intent.clone(), intent: intent.clone(),
@ -261,23 +469,115 @@ impl ChatOrchestrator {
), ),
ui_events: None, ui_events: None,
}), }),
"improve_resume_summary" => Ok(ChatMessageResponse { "improve_resume_summary" => {
self.improve_text_intent(
&request,
intent,
confidence,
"improve_resume_summary",
&["current_summary"],
ImproveKind::Resume,
)
.await
}
"improve_jobseeker_profile" => {
self.improve_text_intent(
&request,
intent,
confidence,
"improve_jobseeker_profile",
&["current_content"],
ImproveKind::JobSeekerProfile,
)
.await
}
"improve_company_profile" => {
self.improve_text_intent(
&request,
intent,
confidence,
"improve_company_profile",
&["current_description"],
ImproveKind::CompanyProfile,
)
.await
}
"improve_professional_profile" => {
self.improve_text_intent(
&request,
intent,
confidence,
"improve_professional_profile",
&["current_description"],
ImproveKind::ProfessionalProfile,
)
.await
}
"generate_service_description" => {
let extracted = self
.form_service
.extract(FormExtractRequest {
raw_user_input: request.message.clone(),
expected_fields: Some(vec![
"service_type".to_string(),
"experience".to_string(),
"location".to_string(),
]),
})
.await?;
if !extracted.missing_fields.is_empty() {
return Ok(needs_input_response(
intent,
confidence,
conversation_id,
"Share your service type, experience, and location (e.g. \"service_type: Wedding Photography, experience: 5 years, location: Mumbai\") and I'll write a description.",
extracted.missing_fields,
));
}
let service_type = field_value(&extracted.fields, "service_type").unwrap_or_default();
let experience = field_value(&extracted.fields, "experience").unwrap_or_default();
let location = field_value(&extracted.fields, "location").unwrap_or_default();
let specialization = field_value(&extracted.fields, "specialization");
let pricing = field_value(&extracted.fields, "pricing");
let availability = field_value(&extracted.fields, "availability");
let generated = self
.content_tools_service
.generate_service_description(ServiceDescriptionRequest {
service_type,
experience,
location,
specialization,
pricing,
availability,
})
.await?;
Ok(ChatMessageResponse {
intent: intent.clone(), intent: intent.clone(),
reply: "Share the current summary and the role you are targeting, and I will prepare an improved version for your backend to review.".to_string(), reply: "Drafted a service description below. Review and save it to your listing.".to_string(),
data: serde_json::json!({}), data: serde_json::to_value(&generated).unwrap_or(serde_json::Value::Null),
status: "needs_input".to_string(), status: "completed".to_string(),
conversation_id, conversation_id,
confidence: Some(confidence), confidence: Some(confidence),
fields: None, fields: Some(serde_json::json!({ "description": generated.description })),
missing_fields: Some(vec!["current_summary".to_string()]), missing_fields: None,
requires_confirmation: Some(false), requires_confirmation: Some(true),
suggested_action: build_suggested_action( suggested_action: build_suggested_action(
"improve_resume_summary", "generate_service_description",
Some(serde_json::json!({ "description": generated.description })),
None, None,
Some(vec!["current_summary".to_string()]),
), ),
ui_events: None, ui_events: Some(vec![UiEvent {
}), event_type: "open_preview".to_string(),
target: "service_description".to_string(),
record_id: None,
fields: None,
}]),
})
}
_ => { _ => {
let system_prompt = crate::prompts::get_prompt("general_system").unwrap_or_else(|| { let system_prompt = crate::prompts::get_prompt("general_system").unwrap_or_else(|| {
"You are a reusable workflow assistant. Suggest structured next steps, avoid product-specific promises, and keep answers concise." "You are a reusable workflow assistant. Suggest structured next steps, avoid product-specific promises, and keep answers concise."
@ -286,7 +586,11 @@ impl ChatOrchestrator {
let generic = self let generic = self
.ai_provider .ai_provider
.complete(&system_prompt, &request.message) .complete_as(
crate::providers::llm::ai_provider::models::ASKASH_MAIN,
&system_prompt,
&request.message,
)
.await?; .await?;
Ok(ChatMessageResponse { Ok(ChatMessageResponse {
@ -305,6 +609,142 @@ impl ChatOrchestrator {
} }
} }
} }
async fn improve_text_intent(
&self,
request: &ChatMessageRequest,
intent: String,
confidence: f32,
action_code: &str,
expected_fields: &[&str],
kind: ImproveKind,
) -> Result<ChatMessageResponse, AppError> {
let conversation_id = request.conversation_id.clone();
let expected: Vec<String> = expected_fields.iter().map(|s| s.to_string()).collect();
let primary_field = expected_fields[0];
let extracted = self
.form_service
.extract(FormExtractRequest {
raw_user_input: request.message.clone(),
expected_fields: Some(expected.clone()),
})
.await?;
// Free-form messages (no "key: value" pairs) fall back to a single
// "details" field - treat a long enough message as the text to improve.
let current_text = field_value(&extracted.fields, primary_field)
.or_else(|| field_value(&extracted.fields, "details"))
.filter(|v| v.trim().len() >= 20);
let Some(current_text) = current_text else {
return Ok(needs_input_response(
intent,
confidence,
conversation_id,
"Share the current text you'd like improved (at least a sentence or two) and I'll rewrite it.",
expected,
));
};
let response = match kind {
ImproveKind::Resume => {
self.content_tools_service
.improve_profile_text(
ProfileKind::Resume,
ImproveTextRequest { current_description: current_text, context: None },
)
.await?
}
ImproveKind::JobSeekerProfile => {
self.content_tools_service
.improve_profile_text(
ProfileKind::JobSeekerProfile,
ImproveTextRequest { current_description: current_text, context: None },
)
.await?
}
ImproveKind::CompanyProfile => {
self.content_tools_service
.improve_profile_text(
ProfileKind::CompanyProfile,
ImproveTextRequest { current_description: current_text, context: None },
)
.await?
}
ImproveKind::ProfessionalProfile => {
self.content_tools_service
.improve_profile_text(
ProfileKind::ProfessionalProfile,
ImproveTextRequest { current_description: current_text, context: None },
)
.await?
}
ImproveKind::JobPost => {
self.content_tools_service
.improve_job_post(ImproveTextRequest { current_description: current_text, context: None })
.await?
}
};
let fields = serde_json::json!({ "improved_description": response.improved_description });
Ok(ChatMessageResponse {
intent: intent.clone(),
reply: "Here's an improved version. Review it before saving.".to_string(),
data: serde_json::to_value(&response).unwrap_or(serde_json::Value::Null),
status: "completed".to_string(),
conversation_id,
confidence: Some(confidence),
fields: Some(fields.clone()),
missing_fields: None,
requires_confirmation: Some(true),
suggested_action: build_suggested_action(action_code, Some(fields), None),
ui_events: Some(vec![UiEvent {
event_type: "open_preview".to_string(),
target: "improved_text".to_string(),
record_id: None,
fields: None,
}]),
})
}
}
enum ImproveKind {
Resume,
JobSeekerProfile,
CompanyProfile,
ProfessionalProfile,
JobPost,
}
fn field_value(fields: &[crate::forms::models::ExtractedField], name: &str) -> Option<String> {
fields
.iter()
.find(|f| f.name == name)
.map(|f| f.value.clone())
}
fn needs_input_response(
intent: String,
confidence: f32,
conversation_id: Option<String>,
reply: &str,
missing_fields: Vec<String>,
) -> ChatMessageResponse {
ChatMessageResponse {
intent: intent.clone(),
reply: reply.to_string(),
data: serde_json::json!({}),
status: "needs_input".to_string(),
conversation_id,
confidence: Some(confidence),
fields: None,
missing_fields: Some(missing_fields.clone()),
requires_confirmation: Some(false),
suggested_action: build_suggested_action(&intent, None, Some(missing_fields)),
ui_events: None,
}
} }
fn build_suggested_action( fn build_suggested_action(
@ -334,6 +774,10 @@ fn summarize_subject(message: &str) -> String {
fn classify_intent(message: &str) -> (String, f32) { fn classify_intent(message: &str) -> (String, f32) {
let text = message.to_lowercase(); let text = message.to_lowercase();
if text.contains("job post") && (text.contains("improve") || text.contains("edit") || text.contains("rewrite")) {
return ("improve_job_post".to_string(), 0.88);
}
if text.contains("job description") if text.contains("job description")
|| text.contains("generate job") || text.contains("generate job")
|| text.contains("create job") || text.contains("create job")
@ -359,6 +803,44 @@ fn classify_intent(message: &str) -> (String, f32) {
return ("improve_resume_summary".to_string(), 0.88); return ("improve_resume_summary".to_string(), 0.88);
} }
if (text.contains("job seeker profile") || text.contains("my profile"))
&& (text.contains("improve") || text.contains("rewrite"))
{
return ("improve_jobseeker_profile".to_string(), 0.82);
}
if text.contains("company profile") && (text.contains("improve") || text.contains("rewrite")) {
return ("improve_company_profile".to_string(), 0.86);
}
if text.contains("professional profile")
|| (text.contains("profile") && text.contains("improve") && text.contains("service"))
{
return ("improve_professional_profile".to_string(), 0.82);
}
if text.contains("service description") || text.contains("describe my service") {
return ("generate_service_description".to_string(), 0.86);
}
if text.contains("request contact") || text.contains("contact professional") || text.contains("reach out to") {
return ("request_professional_contact".to_string(), 0.8);
}
if text.contains("auto apply") || text.contains("auto-apply") || text.contains("autoapply") {
return ("ai_auto_apply_status".to_string(), 0.85);
}
if (text.contains("kb article") || text.contains("knowledge base") || text.contains("write a notification") || text.contains("draft a notification"))
&& (text.contains("write") || text.contains("draft") || text.contains("generate") || text.contains("create"))
{
return ("generate_kb_content".to_string(), 0.85);
}
if text.contains("summarize ticket") || text.contains("ticket summary") || text.contains("summarise ticket") {
return ("support_ticket_summary".to_string(), 0.85);
}
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(), 0.84); return ("form_filling_assistance".to_string(), 0.84);
} }
@ -403,5 +885,9 @@ fn classify_intent(message: &str) -> (String, f32) {
return ("check_ai_pack_balance".to_string(), 0.8); return ("check_ai_pack_balance".to_string(), 0.8);
} }
if text.contains("lead") || (text.contains("credit") && !text.contains("ai")) {
return ("lead_credit_guidance".to_string(), 0.72);
}
("general".to_string(), 0.55) ("general".to_string(), 0.55)
} }

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

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

View file

@ -0,0 +1,51 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImproveTextRequest {
pub current_description: String,
pub context: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImproveTextResponse {
pub improved_description: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceDescriptionRequest {
pub service_type: String,
pub experience: String,
pub location: String,
pub specialization: Option<String>,
pub pricing: Option<String>,
pub availability: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceDescriptionResponse {
pub description: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KbContentRequest {
/// "kb_article" | "notification"
pub content_type: String,
pub topic: String,
pub details: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KbContentResponse {
pub content: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SupportSummaryRequest {
pub ticket_id: String,
pub ticket_text: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SupportSummaryResponse {
pub summary: String,
}

View file

@ -0,0 +1,178 @@
use std::sync::Arc;
use crate::{
content_tools::models::*,
error::AppError,
providers::llm::ai_provider::{models, AiProvider},
};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ProfileKind {
Resume,
JobSeekerProfile,
CompanyProfile,
ProfessionalProfile,
}
impl ProfileKind {
fn label(self) -> &'static str {
match self {
ProfileKind::Resume => "resume summary",
ProfileKind::JobSeekerProfile => "job seeker profile",
ProfileKind::CompanyProfile => "company profile",
ProfileKind::ProfessionalProfile => "professional service provider profile",
}
}
}
#[derive(Clone)]
pub struct ContentToolsService {
ai_provider: Arc<dyn AiProvider>,
}
impl ContentToolsService {
pub fn new(ai_provider: Arc<dyn AiProvider>) -> Self {
Self { ai_provider }
}
pub async fn improve_profile_text(
&self,
kind: ProfileKind,
request: ImproveTextRequest,
) -> Result<ImproveTextResponse, AppError> {
let system = format!(
"You are Nxtgauge's profile writing assistant. Rewrite a {} to be clearer, \
more compelling, and more likely to attract the right matches on the platform. \
Keep every factual claim from the original - never invent experience, skills, \
or credentials that weren't mentioned.",
kind.label()
);
let user_prompt = format!(
"Current {}:\n{}\n\nAdditional context: {}\n\nRewrite this to be more compelling and well-structured. Return only the improved text.",
kind.label(),
request.current_description,
request.context.unwrap_or_else(|| "none".to_string()),
);
let improved = self
.ai_provider
.complete_as(models::PROFILE_WRITER, &system, &user_prompt)
.await?;
Ok(ImproveTextResponse {
improved_description: improved,
})
}
pub async fn improve_job_post(
&self,
request: ImproveTextRequest,
) -> Result<ImproveTextResponse, AppError> {
let system = "You are Nxtgauge's job post editor. Improve an existing job post draft \
so it is clearer, better structured, and more likely to attract qualified \
candidates. Keep every factual detail from the original - do not invent \
requirements, salary, or responsibilities that weren't mentioned.";
let user_prompt = format!(
"Current job post:\n{}\n\nAdditional context: {}\n\nRewrite this job post. Return only the improved text.",
request.current_description,
request.context.unwrap_or_else(|| "none".to_string()),
);
let improved = self
.ai_provider
.complete_as(models::JD_GENERATOR, system, &user_prompt)
.await?;
Ok(ImproveTextResponse {
improved_description: improved,
})
}
pub async fn generate_service_description(
&self,
request: ServiceDescriptionRequest,
) -> Result<ServiceDescriptionResponse, AppError> {
let system = "You are Nxtgauge's service listing writer. Write a compelling service \
description for a professional service provider that highlights their \
experience and specialization to attract customers on the platform.";
let user_prompt = format!(
"Service type: {}\nExperience: {}\nLocation: {}\nSpecialization: {:?}\nPricing: {:?}\nAvailability: {:?}\n\n\
Write a 2-3 paragraph service description. Return only the description.",
request.service_type,
request.experience,
request.location,
request.specialization,
request.pricing,
request.availability,
);
let description = self
.ai_provider
.complete_as(models::SERVICE_WRITER, system, &user_prompt)
.await?;
Ok(ServiceDescriptionResponse { description })
}
pub async fn generate_kb_content(
&self,
request: KbContentRequest,
) -> Result<KbContentResponse, AppError> {
let is_notification = request.content_type.eq_ignore_ascii_case("notification");
let system = if is_notification {
"You are Nxtgauge's notification copywriter. Write a short, clear in-app \
notification (1-2 sentences) about the given topic."
} else {
"You are Nxtgauge's help center writer. Write a clear, well-structured knowledge \
base article (with a title and short sections) that helps users understand the \
given topic."
};
let user_prompt = format!(
"Topic: {}\nDetails: {}\n\nWrite the {}. Return only the content.",
request.topic,
request.details.unwrap_or_else(|| "none provided".to_string()),
if is_notification { "notification text" } else { "KB article" },
);
let content = self
.ai_provider
.complete_as(models::SUPPORT_DRAFTER, system, &user_prompt)
.await?;
Ok(KbContentResponse { content })
}
pub async fn summarize_support_ticket(
&self,
request: SupportSummaryRequest,
) -> Result<SupportSummaryResponse, AppError> {
let system = "You are Nxtgauge's support operations assistant. Summarize a support \
ticket thread for an admin/employee: what the user needs, what's been \
tried, and a recommended next step. Be concise.";
let user_prompt = format!(
"Ticket {}:\n{}\n\nSummarize in 3-5 sentences.",
request.ticket_id, request.ticket_text
);
let summary = self
.ai_provider
.complete_as(models::SUPPORT_DRAFTER, system, &user_prompt)
.await?;
Ok(SupportSummaryResponse { summary })
}
pub async fn guidance(&self, topic: &str, question: &str) -> Result<String, AppError> {
let system = format!(
"You are Nxtgauge's platform assistant. Answer questions about {} clearly and \
concisely, in terms of what the user should do next in the product (check a \
specific dashboard page, contact support, etc). Don't invent specific numbers \
you don't have.",
topic
);
self.ai_provider
.complete_as(models::DECISION_SUPPORT, &system, question)
.await
}
}

View file

@ -1,6 +1,10 @@
use std::sync::Arc; use std::sync::Arc;
use crate::{cover_letter::models::*, error::AppError, providers::llm::ai_provider::AiProvider}; use crate::{
cover_letter::models::*,
error::AppError,
providers::llm::ai_provider::{models, AiProvider},
};
#[derive(Clone)] #[derive(Clone)]
pub struct CoverLetterService { pub struct CoverLetterService {
@ -36,7 +40,10 @@ impl CoverLetterService {
request.additional_notes request.additional_notes
); );
let generated = self.ai_provider.complete(system, &user_prompt).await?; let generated = self
.ai_provider
.complete_as(models::ASKASH_MAIN, system, &user_prompt)
.await?;
Ok(GenerateCoverLetterResponse { Ok(GenerateCoverLetterResponse {
cover_letter: generated.lines().take(10).collect::<Vec<_>>().join("\n"), cover_letter: generated.lines().take(10).collect::<Vec<_>>().join("\n"),

View file

@ -9,6 +9,8 @@ use serde::Serialize;
pub enum AppError { pub enum AppError {
#[error("bad request: {0}")] #[error("bad request: {0}")]
BadRequest(String), BadRequest(String),
#[error("forbidden: {0}")]
Forbidden(String),
#[error("provider unavailable: {0}")] #[error("provider unavailable: {0}")]
ProviderUnavailable(String), ProviderUnavailable(String),
#[error("internal error: {0}")] #[error("internal error: {0}")]
@ -26,6 +28,7 @@ impl IntoResponse for AppError {
fn into_response(self) -> Response { fn into_response(self) -> Response {
let status = match self { let status = match self {
AppError::BadRequest(_) => StatusCode::BAD_REQUEST, AppError::BadRequest(_) => StatusCode::BAD_REQUEST,
AppError::Forbidden(_) => StatusCode::FORBIDDEN,
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, AppError::ExternalService(_) => StatusCode::BAD_GATEWAY,

View file

@ -1,6 +1,10 @@
use std::{collections::HashSet, sync::Arc}; use std::{collections::HashSet, sync::Arc};
use crate::{error::AppError, forms::models::*, providers::llm::ai_provider::AiProvider}; use crate::{
error::AppError,
forms::models::*,
providers::llm::ai_provider::{models, AiProvider},
};
#[derive(Clone)] #[derive(Clone)]
pub struct FormService { pub struct FormService {
@ -32,7 +36,8 @@ impl FormService {
if fields.is_empty() { if fields.is_empty() {
let helper = self let helper = self
.ai_provider .ai_provider
.complete( .complete_as(
models::ASKASH_FAST,
"You extract form fields.", "You extract form fields.",
&format!( &format!(
"Extract likely form fields from: {}", "Extract likely form fields from: {}",

View file

@ -0,0 +1,138 @@
use axum::{
extract::{Extension, State},
Json,
};
use crate::{
auth::AuthUser,
content_tools::{
models::*,
service::ProfileKind,
},
error::AppError,
state::AppState,
};
pub async fn improve_resume(
State(state): State<AppState>,
Json(request): Json<ImproveTextRequest>,
) -> Result<Json<ImproveTextResponse>, AppError> {
if request.current_description.trim().is_empty() {
return Err(AppError::BadRequest("current_description is required".to_string()));
}
let response = state
.content_tools_service
.improve_profile_text(ProfileKind::Resume, request)
.await?;
Ok(Json(response))
}
pub async fn improve_jobseeker_profile(
State(state): State<AppState>,
Json(request): Json<ImproveTextRequest>,
) -> Result<Json<ImproveTextResponse>, AppError> {
if request.current_description.trim().is_empty() {
return Err(AppError::BadRequest("current_description is required".to_string()));
}
let response = state
.content_tools_service
.improve_profile_text(ProfileKind::JobSeekerProfile, request)
.await?;
Ok(Json(response))
}
pub async fn improve_company_profile(
State(state): State<AppState>,
Json(request): Json<ImproveTextRequest>,
) -> Result<Json<ImproveTextResponse>, AppError> {
if request.current_description.trim().is_empty() {
return Err(AppError::BadRequest("current_description is required".to_string()));
}
let response = state
.content_tools_service
.improve_profile_text(ProfileKind::CompanyProfile, request)
.await?;
Ok(Json(response))
}
pub async fn improve_professional_profile(
State(state): State<AppState>,
Json(request): Json<ImproveTextRequest>,
) -> Result<Json<ImproveTextResponse>, AppError> {
if request.current_description.trim().is_empty() {
return Err(AppError::BadRequest("current_description is required".to_string()));
}
let response = state
.content_tools_service
.improve_profile_text(ProfileKind::ProfessionalProfile, request)
.await?;
Ok(Json(response))
}
pub async fn improve_job_post(
State(state): State<AppState>,
Json(request): Json<ImproveTextRequest>,
) -> Result<Json<ImproveTextResponse>, AppError> {
if request.current_description.trim().is_empty() {
return Err(AppError::BadRequest("current_description is required".to_string()));
}
let response = state.content_tools_service.improve_job_post(request).await?;
Ok(Json(response))
}
pub async fn generate_service_description(
State(state): State<AppState>,
Json(request): Json<ServiceDescriptionRequest>,
) -> Result<Json<ServiceDescriptionResponse>, AppError> {
if request.service_type.trim().is_empty() {
return Err(AppError::BadRequest("service_type is required".to_string()));
}
let response = state
.content_tools_service
.generate_service_description(request)
.await?;
Ok(Json(response))
}
pub async fn generate_kb_content(
Extension(auth_user): Extension<AuthUser>,
State(state): State<AppState>,
Json(request): Json<KbContentRequest>,
) -> Result<Json<KbContentResponse>, AppError> {
require_admin_or_employee(&auth_user)?;
if request.topic.trim().is_empty() {
return Err(AppError::BadRequest("topic is required".to_string()));
}
let response = state.content_tools_service.generate_kb_content(request).await?;
Ok(Json(response))
}
pub async fn support_summary(
Extension(auth_user): Extension<AuthUser>,
State(state): State<AppState>,
Json(request): Json<SupportSummaryRequest>,
) -> Result<Json<SupportSummaryResponse>, AppError> {
require_admin_or_employee(&auth_user)?;
if request.ticket_text.trim().is_empty() {
return Err(AppError::BadRequest("ticket_text is required".to_string()));
}
let response = state
.content_tools_service
.summarize_support_ticket(request)
.await?;
Ok(Json(response))
}
fn require_admin_or_employee(auth_user: &AuthUser) -> Result<(), AppError> {
let roles = auth_user.claims.roles.clone().unwrap_or_default();
let has_access = roles
.iter()
.any(|r| r.eq_ignore_ascii_case("ADMIN") || r.eq_ignore_ascii_case("EMPLOYEE"));
if has_access {
Ok(())
} else {
Err(AppError::Forbidden(
"This action requires an admin or employee role".to_string(),
))
}
}

View file

@ -1,6 +1,7 @@
pub mod actions; pub mod actions;
pub mod chat; pub mod chat;
pub mod confirm_action; pub mod confirm_action;
pub mod content_tools;
pub mod cover_letter; pub mod cover_letter;
pub mod forms; pub mod forms;
pub mod health; pub mod health;

View file

@ -1,6 +1,10 @@
use std::sync::Arc; use std::sync::Arc;
use crate::{error::AppError, jobs::models::*, providers::llm::ai_provider::AiProvider}; use crate::{
error::AppError,
jobs::models::*,
providers::llm::ai_provider::{models, AiProvider},
};
#[derive(Clone)] #[derive(Clone)]
pub struct JobsService { pub struct JobsService {
@ -29,7 +33,10 @@ impl JobsService {
request.company_context request.company_context
); );
let generated = self.ai_provider.complete(system, &user_prompt).await?; let generated = self
.ai_provider
.complete_as(models::JD_GENERATOR, system, &user_prompt)
.await?;
Ok(GenerateJobDescriptionResponse { Ok(GenerateJobDescriptionResponse {
role_summary: format!("{} role for Nxtgauge platform.", request.role_title), role_summary: format!("{} role for Nxtgauge platform.", request.role_title),

View file

@ -2,6 +2,7 @@ mod actions;
mod auth; mod auth;
mod chat; mod chat;
mod config; mod config;
mod content_tools;
mod cover_letter; mod cover_letter;
mod db; mod db;
mod error; mod error;

View file

@ -2,7 +2,31 @@ use async_trait::async_trait;
use crate::error::AppError; use crate::error::AppError;
/// LiteLLM model aliases already deployed (apps/litellm/base/configmap.yaml),
/// each pointing at whichever local Ollama model fits that task.
pub mod models {
pub const JD_GENERATOR: &str = "jd-generator";
pub const PROFILE_WRITER: &str = "profile-writer";
pub const SERVICE_WRITER: &str = "service-writer";
pub const SUPPORT_DRAFTER: &str = "support-drafter";
pub const DECISION_SUPPORT: &str = "decision-support";
pub const ASKASH_MAIN: &str = "askash-main";
pub const ASKASH_FAST: &str = "askash-fast";
}
#[async_trait] #[async_trait]
pub trait AiProvider: Send + Sync { pub trait AiProvider: Send + Sync {
async fn complete(&self, system_prompt: &str, user_prompt: &str) -> Result<String, AppError>; async fn complete(&self, system_prompt: &str, user_prompt: &str) -> Result<String, AppError>;
/// Complete using a specific named model (a LiteLLM model alias, e.g.
/// `models::JD_GENERATOR`). Providers that don't support per-call model
/// overrides (Ollama direct, the fake test provider) fall back to `complete`.
async fn complete_as(
&self,
_model: &str,
system_prompt: &str,
user_prompt: &str,
) -> Result<String, AppError> {
self.complete(system_prompt, user_prompt).await
}
} }

View file

@ -28,6 +28,61 @@ impl LiteLLMProvider {
user_prompt user_prompt
) )
} }
async fn chat(&self, model: &str, system_prompt: &str, user_prompt: &str) -> Result<String, AppError> {
let url = format!("{}/chat/completions", self.base_url.trim_end_matches('/'));
let payload = ChatCompletionRequest {
model: model.to_string(),
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 ({}): {} - {}", model, 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))
}
}
}
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
@ -62,57 +117,16 @@ struct Message {
#[async_trait] #[async_trait]
impl AiProvider for LiteLLMProvider { impl AiProvider for LiteLLMProvider {
async fn complete(&self, system_prompt: &str, user_prompt: &str) -> Result<String, AppError> { 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 model = self.model.clone();
self.chat(&model, system_prompt, user_prompt).await
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; async fn complete_as(
match body { &self,
Ok(parsed) => { model: &str,
if let Some(choice) = parsed.choices.first() { system_prompt: &str,
Ok(choice.message.content.trim().to_string()) user_prompt: &str,
} else { ) -> Result<String, AppError> {
Ok(Self::fallback_response(user_prompt)) self.chat(model, system_prompt, user_prompt).await
}
}
Err(e) => {
tracing::warn!("Failed to parse LiteLLM response: {}", e);
Ok(Self::fallback_response(user_prompt))
}
}
} }
} }

View file

@ -44,6 +44,29 @@ pub fn build_router(state: AppState) -> Router {
.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))
.route("/actions/confirm", post(handlers::confirm_action::confirm_action)) .route("/actions/confirm", post(handlers::confirm_action::confirm_action))
.route("/resume/improve", post(handlers::content_tools::improve_resume))
.route(
"/job-seekers/improve-profile",
post(handlers::content_tools::improve_jobseeker_profile),
)
.route(
"/companies/improve-profile",
post(handlers::content_tools::improve_company_profile),
)
.route(
"/professionals/improve-profile",
post(handlers::content_tools::improve_professional_profile),
)
.route(
"/professionals/generate-service-description",
post(handlers::content_tools::generate_service_description),
)
.route("/jobs/improve-post", post(handlers::content_tools::improve_job_post))
.route("/kb/generate", post(handlers::content_tools::generate_kb_content))
.route(
"/admin/support-summary",
post(handlers::content_tools::support_summary),
)
.layer(middleware::from_fn_with_state(state.clone(), require_auth)), .layer(middleware::from_fn_with_state(state.clone(), require_auth)),
) )
.layer(cors) .layer(cors)

View file

@ -3,6 +3,7 @@ use std::sync::Arc;
use crate::{ use crate::{
chat::orchestrator::ChatOrchestrator, chat::orchestrator::ChatOrchestrator,
config::AppConfig, config::AppConfig,
content_tools::service::ContentToolsService,
cover_letter::service::CoverLetterService, cover_letter::service::CoverLetterService,
db::Database, db::Database,
forms::service::FormService, forms::service::FormService,
@ -23,6 +24,7 @@ pub struct AppState {
pub jobs_service: JobsService, pub jobs_service: JobsService,
pub form_service: FormService, pub form_service: FormService,
pub cover_letter_service: CoverLetterService, pub cover_letter_service: CoverLetterService,
pub content_tools_service: ContentToolsService,
pub ticket_service: TicketService, pub ticket_service: TicketService,
pub help_center: Arc<dyn HelpCenterProvider>, pub help_center: Arc<dyn HelpCenterProvider>,
pub action_confirmation_service: ActionConfirmationService, pub action_confirmation_service: ActionConfirmationService,
@ -39,12 +41,14 @@ impl AppState {
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 cover_letter_service = CoverLetterService::new(ai_provider.clone());
let content_tools_service = ContentToolsService::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(),
content_tools_service.clone(),
ai_provider.clone(), ai_provider.clone(),
); );
let action_confirmation_service = ActionConfirmationService::new(); let action_confirmation_service = ActionConfirmationService::new();
@ -56,6 +60,7 @@ impl AppState {
jobs_service, jobs_service,
form_service, form_service,
cover_letter_service, cover_letter_service,
content_tools_service,
ticket_service, ticket_service,
help_center, help_center,
action_confirmation_service, action_confirmation_service,

View file

@ -6,9 +6,9 @@ mod tests {
use crate::prompts::{get_prompt, get_prompt_with_version, load_prompts}; use crate::prompts::{get_prompt, get_prompt_with_version, load_prompts};
#[test] #[test]
fn test_get_action_registry_returns_16_actions() { fn test_get_action_registry_returns_20_actions() {
let registry = get_action_registry(); let registry = get_action_registry();
assert_eq!(registry.len(), 16); assert_eq!(registry.len(), 20);
} }
#[test] #[test]