diff --git a/src/actions/mod.rs b/src/actions/mod.rs index d16d264..9d68ff7 100644 --- a/src/actions/mod.rs +++ b/src/actions/mod.rs @@ -355,6 +355,75 @@ pub fn get_action_registry() -> Vec { uses_llm: false, 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(), + }, ] } diff --git a/src/chat/orchestrator.rs b/src/chat/orchestrator.rs index 646a42e..576cc32 100644 --- a/src/chat/orchestrator.rs +++ b/src/chat/orchestrator.rs @@ -3,6 +3,10 @@ use std::sync::Arc; use crate::{ actions::get_action, chat::models::{ChatMessageRequest, ChatMessageResponse, SuggestedAction}, + content_tools::{ + models::{ImproveTextRequest, KbContentRequest, ServiceDescriptionRequest, SupportSummaryRequest}, + service::{ContentToolsService, ProfileKind}, + }, error::AppError, forms::{models::FormExtractRequest, service::FormService}, handlers::actions::UiEvent, @@ -18,6 +22,7 @@ pub struct ChatOrchestrator { jobs_service: JobsService, form_service: FormService, help_center: Arc, + content_tools_service: ContentToolsService, ai_provider: Arc, } @@ -27,12 +32,14 @@ impl ChatOrchestrator { form_service: FormService, help_center: Arc, _ticket_service: TicketService, + content_tools_service: ContentToolsService, ai_provider: Arc, ) -> Self { Self { jobs_service, form_service, help_center, + content_tools_service, 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" => { let extracted = self .form_service @@ -142,14 +160,14 @@ impl ChatOrchestrator { let reply = if matches.is_empty() { "No help article match found. Try rephrasing your question.".to_string() } else { - let mut response = format!("Found {} help article(s): - -", 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!("{}. **{}** -{} - -", i + 1, article.title, article.summary)); + 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)); @@ -205,27 +223,217 @@ impl ChatOrchestrator { }]), }) } - "explain_plan_limits" => Ok(ChatMessageResponse { - 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(), - 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("explain_plan_limits", None, None), - ui_events: Some(vec![UiEvent { - event_type: "show_usage_modal".to_string(), - target: "ai_plan_usage".to_string(), - record_id: None, + "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(), + 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!({}), + status: "completed".to_string(), + conversation_id, + confidence: Some(confidence), fields: None, - }]), - }), - "check_ai_pack_balance" => Ok(ChatMessageResponse { + missing_fields: None, + requires_confirmation: Some(false), + suggested_action: build_suggested_action("explain_plan_limits", None, None), + ui_events: Some(vec![UiEvent { + event_type: "show_usage_modal".to_string(), + target: "ai_plan_usage".to_string(), + record_id: None, + fields: None, + }]), + }) + } + "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(), + 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("check_ai_pack_balance", None, None), + ui_events: Some(vec![UiEvent { + event_type: "show_usage_modal".to_string(), + target: "ai_usage".to_string(), + record_id: 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: "Open the product usage view to see remaining AI actions, add-ons, and renewal details.".to_string(), + 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, @@ -233,10 +441,10 @@ impl ChatOrchestrator { fields: None, missing_fields: None, requires_confirmation: Some(false), - suggested_action: build_suggested_action("check_ai_pack_balance", None, None), + suggested_action: build_suggested_action("ai_auto_apply_status", None, None), ui_events: Some(vec![UiEvent { - event_type: "show_usage_modal".to_string(), - target: "ai_usage".to_string(), + event_type: "open_settings".to_string(), + target: "auto_apply".to_string(), record_id: None, fields: None, }]), @@ -261,23 +469,115 @@ impl ChatOrchestrator { ), ui_events: None, }), - "improve_resume_summary" => Ok(ChatMessageResponse { - 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(), - data: serde_json::json!({}), - status: "needs_input".to_string(), - conversation_id, - confidence: Some(confidence), - fields: None, - missing_fields: Some(vec!["current_summary".to_string()]), - requires_confirmation: Some(false), - suggested_action: build_suggested_action( + "improve_resume_summary" => { + self.improve_text_intent( + &request, + intent, + confidence, "improve_resume_summary", - None, - Some(vec!["current_summary".to_string()]), - ), - ui_events: None, - }), + &["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(), + reply: "Drafted a service description below. Review and save it to your listing.".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!({ "description": generated.description })), + missing_fields: None, + requires_confirmation: Some(true), + suggested_action: build_suggested_action( + "generate_service_description", + Some(serde_json::json!({ "description": generated.description })), + 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(|| { "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 .ai_provider - .complete(&system_prompt, &request.message) + .complete_as( + crate::providers::llm::ai_provider::models::ASKASH_MAIN, + &system_prompt, + &request.message, + ) .await?; 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 { + let conversation_id = request.conversation_id.clone(); + let expected: Vec = 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 { + fields + .iter() + .find(|f| f.name == name) + .map(|f| f.value.clone()) +} + +fn needs_input_response( + intent: String, + confidence: f32, + conversation_id: Option, + reply: &str, + missing_fields: Vec, +) -> 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( @@ -334,6 +774,10 @@ fn summarize_subject(message: &str) -> String { fn classify_intent(message: &str) -> (String, f32) { 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") || text.contains("generate job") || text.contains("create job") @@ -359,6 +803,44 @@ fn classify_intent(message: &str) -> (String, f32) { 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") { 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); } + if text.contains("lead") || (text.contains("credit") && !text.contains("ai")) { + return ("lead_credit_guidance".to_string(), 0.72); + } + ("general".to_string(), 0.55) } diff --git a/src/content_tools/mod.rs b/src/content_tools/mod.rs new file mode 100644 index 0000000..b9e52db --- /dev/null +++ b/src/content_tools/mod.rs @@ -0,0 +1,2 @@ +pub mod models; +pub mod service; diff --git a/src/content_tools/models.rs b/src/content_tools/models.rs new file mode 100644 index 0000000..7514df9 --- /dev/null +++ b/src/content_tools/models.rs @@ -0,0 +1,51 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ImproveTextRequest { + pub current_description: String, + pub context: Option, +} + +#[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, + pub pricing: Option, + pub availability: Option, +} + +#[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, +} + +#[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, +} diff --git a/src/content_tools/service.rs b/src/content_tools/service.rs new file mode 100644 index 0000000..fe7db23 --- /dev/null +++ b/src/content_tools/service.rs @@ -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, +} + +impl ContentToolsService { + pub fn new(ai_provider: Arc) -> Self { + Self { ai_provider } + } + + pub async fn improve_profile_text( + &self, + kind: ProfileKind, + request: ImproveTextRequest, + ) -> Result { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 + } +} diff --git a/src/cover_letter/service.rs b/src/cover_letter/service.rs index 61aaf09..7239ed4 100644 --- a/src/cover_letter/service.rs +++ b/src/cover_letter/service.rs @@ -1,6 +1,10 @@ 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)] pub struct CoverLetterService { @@ -36,7 +40,10 @@ impl CoverLetterService { 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 { cover_letter: generated.lines().take(10).collect::>().join("\n"), diff --git a/src/error.rs b/src/error.rs index f9c9ceb..41b05e1 100644 --- a/src/error.rs +++ b/src/error.rs @@ -9,6 +9,8 @@ use serde::Serialize; pub enum AppError { #[error("bad request: {0}")] BadRequest(String), + #[error("forbidden: {0}")] + Forbidden(String), #[error("provider unavailable: {0}")] ProviderUnavailable(String), #[error("internal error: {0}")] @@ -26,6 +28,7 @@ impl IntoResponse for AppError { fn into_response(self) -> Response { let status = match self { AppError::BadRequest(_) => StatusCode::BAD_REQUEST, + AppError::Forbidden(_) => StatusCode::FORBIDDEN, AppError::ProviderUnavailable(_) => StatusCode::SERVICE_UNAVAILABLE, AppError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, AppError::ExternalService(_) => StatusCode::BAD_GATEWAY, diff --git a/src/forms/service.rs b/src/forms/service.rs index c924b6f..e376c95 100644 --- a/src/forms/service.rs +++ b/src/forms/service.rs @@ -1,6 +1,10 @@ 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)] pub struct FormService { @@ -32,7 +36,8 @@ impl FormService { if fields.is_empty() { let helper = self .ai_provider - .complete( + .complete_as( + models::ASKASH_FAST, "You extract form fields.", &format!( "Extract likely form fields from: {}", diff --git a/src/handlers/content_tools.rs b/src/handlers/content_tools.rs new file mode 100644 index 0000000..276e040 --- /dev/null +++ b/src/handlers/content_tools.rs @@ -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, + Json(request): Json, +) -> Result, 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, + Json(request): Json, +) -> Result, 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, + Json(request): Json, +) -> Result, 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, + Json(request): Json, +) -> Result, 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, + Json(request): Json, +) -> Result, 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, + Json(request): Json, +) -> Result, 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, + State(state): State, + Json(request): Json, +) -> Result, 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, + State(state): State, + Json(request): Json, +) -> Result, 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(), + )) + } +} diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index 5f74b48..cd21194 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -1,6 +1,7 @@ pub mod actions; pub mod chat; pub mod confirm_action; +pub mod content_tools; pub mod cover_letter; pub mod forms; pub mod health; diff --git a/src/jobs/service.rs b/src/jobs/service.rs index d7ad07c..7882206 100644 --- a/src/jobs/service.rs +++ b/src/jobs/service.rs @@ -1,6 +1,10 @@ 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)] pub struct JobsService { @@ -29,7 +33,10 @@ impl JobsService { 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 { role_summary: format!("{} role for Nxtgauge platform.", request.role_title), diff --git a/src/main.rs b/src/main.rs index 91f49a2..214a543 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ mod actions; mod auth; mod chat; mod config; +mod content_tools; mod cover_letter; mod db; mod error; diff --git a/src/providers/llm/ai_provider.rs b/src/providers/llm/ai_provider.rs index 060df3b..1165475 100644 --- a/src/providers/llm/ai_provider.rs +++ b/src/providers/llm/ai_provider.rs @@ -2,7 +2,31 @@ use async_trait::async_trait; 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] pub trait AiProvider: Send + Sync { async fn complete(&self, system_prompt: &str, user_prompt: &str) -> Result; + + /// 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 { + self.complete(system_prompt, user_prompt).await + } } diff --git a/src/providers/llm/litellm_provider.rs b/src/providers/llm/litellm_provider.rs index 017b041..91efef8 100644 --- a/src/providers/llm/litellm_provider.rs +++ b/src/providers/llm/litellm_provider.rs @@ -28,6 +28,61 @@ impl LiteLLMProvider { user_prompt ) } + + async fn chat(&self, model: &str, system_prompt: &str, user_prompt: &str) -> Result { + 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 = 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)] @@ -62,57 +117,16 @@ struct Message { #[async_trait] impl AiProvider for LiteLLMProvider { async fn complete(&self, system_prompt: &str, user_prompt: &str) -> Result { - 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 = 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)) - } - } + async fn complete_as( + &self, + model: &str, + system_prompt: &str, + user_prompt: &str, + ) -> Result { + self.chat(model, system_prompt, user_prompt).await } } diff --git a/src/routes/mod.rs b/src/routes/mod.rs index e19d053..184560a 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -44,6 +44,29 @@ pub fn build_router(state: AppState) -> Router { .route("/tickets/create", post(handlers::tickets::create)) .route("/help/search", post(handlers::help::search)) .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(cors) diff --git a/src/state.rs b/src/state.rs index 80cbd05..3a8ee23 100644 --- a/src/state.rs +++ b/src/state.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use crate::{ chat::orchestrator::ChatOrchestrator, config::AppConfig, + content_tools::service::ContentToolsService, cover_letter::service::CoverLetterService, db::Database, forms::service::FormService, @@ -23,6 +24,7 @@ pub struct AppState { pub jobs_service: JobsService, pub form_service: FormService, pub cover_letter_service: CoverLetterService, + pub content_tools_service: ContentToolsService, pub ticket_service: TicketService, pub help_center: Arc, pub action_confirmation_service: ActionConfirmationService, @@ -39,12 +41,14 @@ impl AppState { let jobs_service = JobsService::new(ai_provider.clone()); let form_service = FormService::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 chat_orchestrator = ChatOrchestrator::new( jobs_service.clone(), form_service.clone(), help_center.clone(), ticket_service.clone(), + content_tools_service.clone(), ai_provider.clone(), ); let action_confirmation_service = ActionConfirmationService::new(); @@ -56,6 +60,7 @@ impl AppState { jobs_service, form_service, cover_letter_service, + content_tools_service, ticket_service, help_center, action_confirmation_service, diff --git a/src/tests.rs b/src/tests.rs index f002a04..77154b3 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -6,9 +6,9 @@ mod tests { use crate::prompts::{get_prompt, get_prompt_with_version, load_prompts}; #[test] - fn test_get_action_registry_returns_16_actions() { + fn test_get_action_registry_returns_20_actions() { let registry = get_action_registry(); - assert_eq!(registry.len(), 16); + assert_eq!(registry.len(), 20); } #[test]