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:
parent
6decf8dce2
commit
f0fbb15e54
17 changed files with 1119 additions and 105 deletions
|
|
@ -355,6 +355,75 @@ pub fn get_action_registry() -> Vec<ActionDefinition> {
|
|||
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(),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<dyn HelpCenterProvider>,
|
||||
content_tools_service: ContentToolsService,
|
||||
ai_provider: Arc<dyn AiProvider>,
|
||||
}
|
||||
|
||||
|
|
@ -27,12 +32,14 @@ impl ChatOrchestrator {
|
|||
form_service: FormService,
|
||||
help_center: Arc<dyn HelpCenterProvider>,
|
||||
_ticket_service: TicketService,
|
||||
content_tools_service: ContentToolsService,
|
||||
ai_provider: Arc<dyn AiProvider>,
|
||||
) -> 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<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(
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
2
src/content_tools/mod.rs
Normal file
2
src/content_tools/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
pub mod models;
|
||||
pub mod service;
|
||||
51
src/content_tools/models.rs
Normal file
51
src/content_tools/models.rs
Normal 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,
|
||||
}
|
||||
178
src/content_tools/service.rs
Normal file
178
src/content_tools/service.rs
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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::<Vec<_>>().join("\n"),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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: {}",
|
||||
|
|
|
|||
138
src/handlers/content_tools.rs
Normal file
138
src/handlers/content_tools.rs
Normal 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(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ mod actions;
|
|||
mod auth;
|
||||
mod chat;
|
||||
mod config;
|
||||
mod content_tools;
|
||||
mod cover_letter;
|
||||
mod db;
|
||||
mod error;
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,61 @@ impl LiteLLMProvider {
|
|||
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)]
|
||||
|
|
@ -62,57 +117,16 @@ struct Message {
|
|||
#[async_trait]
|
||||
impl AiProvider for LiteLLMProvider {
|
||||
async fn complete(&self, system_prompt: &str, user_prompt: &str) -> Result<String, AppError> {
|
||||
let url = format!("{}/chat/completions", self.base_url.trim_end_matches('/'));
|
||||
let 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;
|
||||
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<String, AppError> {
|
||||
self.chat(model, system_prompt, user_prompt).await
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<dyn HelpCenterProvider>,
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue