Update workflows and source files

This commit is contained in:
Ashwin Kumar Sivakumar 2026-07-02 06:51:03 +05:30
parent d66ef693e5
commit e33ed2192c
8 changed files with 340 additions and 389 deletions

View file

@ -12,7 +12,10 @@ concurrency:
jobs: jobs:
build: build:
runs-on: self-hosted runs-on: docker-ready
env:
DOCKER_HOST: tcp://127.0.0.1:2375
DOCKER_BUILDKIT: "1"
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@ -22,6 +25,12 @@ jobs:
- name: Set up Docker Buildx - name: Set up Docker Buildx
run: | run: |
set -euo pipefail set -euo pipefail
for attempt in $(seq 1 30); do
if docker version >/dev/null 2>&1; then
break
fi
sleep 2
done
docker version docker version
docker buildx create --use --name nxtgauge-builder || docker buildx use nxtgauge-builder docker buildx create --use --name nxtgauge-builder || docker buildx use nxtgauge-builder
docker buildx inspect --bootstrap docker buildx inspect --bootstrap

View file

@ -2,11 +2,42 @@ use serde::{Deserialize, Serialize};
use crate::handlers::actions::UiEvent; use crate::handlers::actions::UiEvent;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ChatContext {
#[serde(skip_serializing_if = "Option::is_none")]
pub page: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub form_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub product: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatMessageRequest { pub struct ChatMessageRequest {
pub message: String, pub message: String,
pub user_id: Option<String>, pub user_id: Option<String>,
pub conversation_id: Option<String>, pub conversation_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub role: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub product: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub context: Option<ChatContext>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SuggestedAction {
pub action: String,
pub backend_handler: String,
pub requires_confirmation: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub feature_code: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub fields: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub missing_fields: Option<Vec<String>>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@ -14,8 +45,19 @@ pub struct ChatMessageResponse {
pub intent: String, pub intent: String,
pub reply: String, pub reply: String,
pub data: serde_json::Value, pub data: serde_json::Value,
pub status: String,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub conversation_id: Option<String>, pub conversation_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub confidence: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub fields: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub missing_fields: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub requires_confirmation: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub suggested_action: Option<SuggestedAction>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ui_events: Option<Vec<UiEvent>>, pub ui_events: Option<Vec<UiEvent>>,
} }

View file

@ -1,7 +1,8 @@
use std::sync::Arc; use std::sync::Arc;
use crate::{ use crate::{
chat::models::{ChatMessageRequest, ChatMessageResponse}, actions::get_action,
chat::models::{ChatMessageRequest, ChatMessageResponse, SuggestedAction},
error::AppError, error::AppError,
forms::{models::FormExtractRequest, service::FormService}, forms::{models::FormExtractRequest, service::FormService},
handlers::actions::UiEvent, handlers::actions::UiEvent,
@ -9,7 +10,7 @@ use crate::{
providers::{ providers::{
help_center::help_center_provider::HelpCenterProvider, llm::ai_provider::AiProvider, help_center::help_center_provider::HelpCenterProvider, llm::ai_provider::AiProvider,
}, },
tickets::{models::CreateTicketRequest, service::TicketService}, tickets::service::TicketService,
}; };
#[derive(Clone)] #[derive(Clone)]
@ -17,7 +18,6 @@ pub struct ChatOrchestrator {
jobs_service: JobsService, jobs_service: JobsService,
form_service: FormService, form_service: FormService,
help_center: Arc<dyn HelpCenterProvider>, help_center: Arc<dyn HelpCenterProvider>,
ticket_service: TicketService,
ai_provider: Arc<dyn AiProvider>, ai_provider: Arc<dyn AiProvider>,
} }
@ -26,14 +26,13 @@ impl ChatOrchestrator {
jobs_service: JobsService, jobs_service: JobsService,
form_service: FormService, form_service: FormService,
help_center: Arc<dyn HelpCenterProvider>, help_center: Arc<dyn HelpCenterProvider>,
ticket_service: TicketService, _ticket_service: TicketService,
ai_provider: Arc<dyn AiProvider>, ai_provider: Arc<dyn AiProvider>,
) -> Self { ) -> Self {
Self { Self {
jobs_service, jobs_service,
form_service, form_service,
help_center, help_center,
ticket_service,
ai_provider, ai_provider,
} }
} }
@ -42,7 +41,7 @@ impl ChatOrchestrator {
&self, &self,
request: ChatMessageRequest, request: ChatMessageRequest,
) -> Result<ChatMessageResponse, AppError> { ) -> Result<ChatMessageResponse, AppError> {
let intent = classify_intent(&request.message); let (intent, confidence) = classify_intent(&request.message);
let conversation_id = request.conversation_id.clone(); let conversation_id = request.conversation_id.clone();
match intent.as_str() { match intent.as_str() {
@ -61,31 +60,33 @@ impl ChatOrchestrator {
}) })
.await?; .await?;
let ui_events = Some(vec![ let fields = serde_json::json!({
UiEvent { "role_summary": jd.role_summary,
event_type: "refresh_data".to_string(), "responsibilities": jd.responsibilities,
target: "jobs_list".to_string(), "requirements": jd.requirements,
record_id: None, });
fields: None,
},
UiEvent {
event_type: "open_preview".to_string(),
target: "job_draft".to_string(),
record_id: None,
fields: Some(serde_json::json!({
"role_summary": jd.role_summary,
"responsibilities": jd.responsibilities,
"requirements": jd.requirements,
})),
},
]);
Ok(ChatMessageResponse { Ok(ChatMessageResponse {
intent: intent.clone(), intent: intent.clone(),
reply: "Generated a draft job description.".to_string(), reply: "Generated a draft job description. Your product backend can review or save it.".to_string(),
data: serde_json::to_value(jd).unwrap_or(serde_json::Value::Null), data: serde_json::to_value(jd).unwrap_or(serde_json::Value::Null),
conversation_id: conversation_id.clone(), status: "completed".to_string(),
ui_events, conversation_id,
confidence: Some(confidence),
fields: Some(fields.clone()),
missing_fields: None,
requires_confirmation: Some(false),
suggested_action: build_suggested_action(
"generate_job_description",
Some(fields),
None,
),
ui_events: Some(vec![UiEvent {
event_type: "open_preview".to_string(),
target: "job_description".to_string(),
record_id: None,
fields: None,
}]),
}) })
} }
"form_filling_assistance" => { "form_filling_assistance" => {
@ -97,21 +98,43 @@ impl ChatOrchestrator {
}) })
.await?; .await?;
let ui_events = Some(vec![UiEvent { let extracted_fields = serde_json::to_value(&extracted.fields)
event_type: "fill_form".to_string(), .unwrap_or(serde_json::Value::Null);
target: "form_auto_fill".to_string(), let missing_fields = Some(extracted.missing_fields.clone());
record_id: None, let status = if extracted.missing_fields.is_empty() {
fields: Some(serde_json::json!({ "completed"
"fields": extracted.fields, } else {
})), "needs_input"
}]); };
Ok(ChatMessageResponse { Ok(ChatMessageResponse {
intent: intent.clone(), intent: intent.clone(),
reply: extracted.suggested_next_step.clone(), reply: extracted.suggested_next_step.clone(),
data: serde_json::to_value(extracted).unwrap_or(serde_json::Value::Null), data: serde_json::to_value(extracted).unwrap_or(serde_json::Value::Null),
conversation_id: conversation_id.clone(), status: status.to_string(),
ui_events, conversation_id,
confidence: Some(confidence),
fields: Some(extracted_fields.clone()),
missing_fields: missing_fields.clone(),
requires_confirmation: Some(false),
suggested_action: Some(SuggestedAction {
action: "fill_form".to_string(),
backend_handler: "frontend.fill_form".to_string(),
requires_confirmation: false,
feature_code: None,
fields: Some(extracted_fields),
missing_fields,
}),
ui_events: Some(vec![UiEvent {
event_type: "fill_form".to_string(),
target: request
.context
.as_ref()
.and_then(|ctx| ctx.form_id.clone())
.unwrap_or_else(|| "form_auto_fill".to_string()),
record_id: None,
fields: None,
}]),
}) })
} }
"search_kb" => { "search_kb" => {
@ -119,9 +142,14 @@ impl ChatOrchestrator {
let reply = if matches.is_empty() { let reply = if matches.is_empty() {
"No help article match found. Try rephrasing your question.".to_string() "No help article match found. Try rephrasing your question.".to_string()
} else { } else {
let mut response = format!("Found {} help article(s):\n\n", matches.len()); let mut response = format!("Found {} help article(s):
", matches.len());
for (i, article) in matches.iter().take(5).enumerate() { for (i, article) in matches.iter().take(5).enumerate() {
response.push_str(&format!("{}. **{}**\n{}\n\n", i + 1, article.title, article.summary)); response.push_str(&format!("{}. **{}**
{}
", i + 1, article.title, article.summary));
} }
if matches.len() > 5 { if matches.len() > 5 {
response.push_str(&format!("...and {} more articles.", matches.len() - 5)); response.push_str(&format!("...and {} more articles.", matches.len() - 5));
@ -130,118 +158,131 @@ impl ChatOrchestrator {
}; };
Ok(ChatMessageResponse { Ok(ChatMessageResponse {
intent: intent.clone(), intent,
reply, reply,
data: serde_json::json!({ "matches": matches }), data: serde_json::json!({ "matches": matches }),
conversation_id: conversation_id.clone(), status: "completed".to_string(),
conversation_id,
confidence: Some(confidence),
fields: None,
missing_fields: None,
requires_confirmation: Some(false),
suggested_action: build_suggested_action("search_kb", None, None),
ui_events: None, ui_events: None,
}) })
} }
"support_ticket_creation" => { "support_ticket_creation" => {
let created = self let fields = serde_json::json!({
.ticket_service "subject": summarize_subject(&request.message),
.create(CreateTicketRequest { "description": request.message,
subject: request.message.chars().take(80).collect::<String>(), "priority": "medium",
description: request.message.clone(), "category": "general",
priority: "medium".to_string(), });
category: "general".to_string(),
user_id: request.user_id.unwrap_or_else(|| "anonymous".to_string()),
conversation_id: conversation_id.clone(),
source: Some("chatbot".to_string()),
tags: Some(vec!["chat".to_string()]),
metadata: None,
})
.await?;
let ui_events = Some(vec![UiEvent {
event_type: "show_confirmation".to_string(),
target: "ticket_created".to_string(),
record_id: Some(created.ticket_id.clone()),
fields: None,
}]);
Ok(ChatMessageResponse { Ok(ChatMessageResponse {
intent: intent.clone(), intent: intent.clone(),
reply: format!("Support ticket created: {}. Our team will respond shortly.", created.ticket_id), reply: "I can prepare a support ticket. Confirm this action in your product backend to create it.".to_string(),
data: serde_json::to_value(created).unwrap_or(serde_json::Value::Null), data: fields.clone(),
conversation_id: conversation_id.clone(), status: "needs_confirmation".to_string(),
ui_events, conversation_id,
confidence: Some(confidence),
fields: Some(fields.clone()),
missing_fields: None,
requires_confirmation: Some(true),
suggested_action: Some(SuggestedAction {
action: "create_support_ticket".to_string(),
backend_handler: "support.create_ticket".to_string(),
requires_confirmation: true,
feature_code: Some("support_ticket_creation".to_string()),
fields: Some(fields),
missing_fields: None,
}),
ui_events: Some(vec![UiEvent {
event_type: "show_confirmation".to_string(),
target: "support_ticket".to_string(),
record_id: None,
fields: None,
}]),
}) })
} }
"explain_plan_limits" => { "explain_plan_limits" => Ok(ChatMessageResponse {
let ui_events = Some(vec![UiEvent { intent: intent.clone(),
event_type: "show_upgrade_modal".to_string(), 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(),
target: "ai_plan_upgrade_modal".to_string(), data: serde_json::json!({}),
record_id: None, status: "info".to_string(),
fields: None, conversation_id,
}]); confidence: Some(confidence),
fields: None,
let reply = "Your AI plan determines how many AI actions you can use per month.\n\n\ missing_fields: None,
- **Free AI**: 10 actions/month\n\ requires_confirmation: Some(false),
- **Starter AI**: 100 actions/month\n\ suggested_action: build_suggested_action("explain_plan_limits", None, None),
- **Growth AI**: 500 actions/month\n\ ui_events: Some(vec![UiEvent {
- **Pro AI**: 2000 actions/month\n\n\
You can purchase add-on packs for more usage. Opening the upgrade options...";
Ok(ChatMessageResponse {
intent: intent.clone(),
reply: reply.to_string(),
data: serde_json::json!({}),
conversation_id: conversation_id.clone(),
ui_events,
})
}
"check_ai_pack_balance" => {
let ui_events = Some(vec![UiEvent {
event_type: "show_usage_modal".to_string(), event_type: "show_usage_modal".to_string(),
target: "ai_usage_modal".to_string(), target: "ai_plan_usage".to_string(),
record_id: None, record_id: None,
fields: None, fields: None,
}]); }]),
}),
let reply = "Opening your AI usage dashboard..."; "check_ai_pack_balance" => Ok(ChatMessageResponse {
intent: intent.clone(),
Ok(ChatMessageResponse { reply: "Open the product usage view to see remaining AI actions, add-ons, and renewal details.".to_string(),
intent: intent.clone(), data: serde_json::json!({}),
reply: reply.to_string(), status: "info".to_string(),
data: serde_json::json!({}), conversation_id,
conversation_id: conversation_id.clone(), confidence: Some(confidence),
ui_events, fields: None,
}) missing_fields: None,
} requires_confirmation: Some(false),
"generate_cover_letter" => { suggested_action: build_suggested_action("check_ai_pack_balance", None, None),
let reply = "I can help you generate a cover letter. Please provide:\n\ ui_events: Some(vec![UiEvent {
- The job title or position\n\ event_type: "show_usage_modal".to_string(),
- Your key skills and experience\n\ target: "ai_usage".to_string(),
- Any specific company or role details (optional)\n\n\ record_id: None,
Or you can use the 'Generate Cover Letter' button on the job application page."; fields: None,
}]),
Ok(ChatMessageResponse { }),
intent: intent.clone(), "generate_cover_letter" => Ok(ChatMessageResponse {
reply: reply.to_string(), intent: intent.clone(),
data: serde_json::json!({}), reply: "I can help generate a cover letter. Provide the target role, your key skills, and any company details you want included.".to_string(),
conversation_id: conversation_id.clone(), data: serde_json::json!({}),
ui_events: None, status: "needs_input".to_string(),
}) conversation_id,
} confidence: Some(confidence),
"improve_resume_summary" => { fields: None,
let reply = "I can help improve your resume summary. Please share:\n\ missing_fields: Some(vec![
- Your current resume summary (or paste it here)\n\ "job_title".to_string(),
- The type of role you're targeting\n\ "applicant_skills".to_string(),
- Your key skills and experience\n\n\ ]),
Or you can use the 'Improve Resume' feature in your profile page."; requires_confirmation: Some(false),
suggested_action: build_suggested_action(
Ok(ChatMessageResponse { "generate_cover_letter",
intent: intent.clone(), None,
reply: reply.to_string(), Some(vec!["job_title".to_string(), "applicant_skills".to_string()]),
data: serde_json::json!({}), ),
conversation_id: conversation_id.clone(), ui_events: None,
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",
None,
Some(vec!["current_summary".to_string()]),
),
ui_events: None,
}),
_ => { _ => {
let system_prompt = crate::prompts::get_prompt("general_system") let system_prompt = crate::prompts::get_prompt("general_system").unwrap_or_else(|| {
.unwrap_or_else(|| "You are Nxtgauge workflow assistant. Keep answers concise and actionable. If users ask about features, guide them to use the appropriate buttons or pages.".to_string()); "You are a reusable workflow assistant. Suggest structured next steps, avoid product-specific promises, and keep answers concise."
.to_string()
});
let generic = self let generic = self
.ai_provider .ai_provider
@ -252,7 +293,13 @@ impl ChatOrchestrator {
intent, intent,
reply: generic, reply: generic,
data: serde_json::json!({}), data: serde_json::json!({}),
conversation_id: conversation_id.clone(), status: "completed".to_string(),
conversation_id,
confidence: Some(confidence),
fields: None,
missing_fields: None,
requires_confirmation: Some(false),
suggested_action: None,
ui_events: None, ui_events: None,
}) })
} }
@ -260,7 +307,31 @@ impl ChatOrchestrator {
} }
} }
fn classify_intent(message: &str) -> String { fn build_suggested_action(
action_code: &str,
fields: Option<serde_json::Value>,
missing_fields: Option<Vec<String>>,
) -> Option<SuggestedAction> {
let action = get_action(action_code)?;
Some(SuggestedAction {
action: action.action_code,
backend_handler: action.backend_handler,
requires_confirmation: action.requires_confirmation,
feature_code: Some(action.feature_code),
fields,
missing_fields,
})
}
fn summarize_subject(message: &str) -> String {
let trimmed = message.trim();
if trimmed.len() <= 80 {
return trimmed.to_string();
}
trimmed.chars().take(77).collect::<String>() + "..."
}
fn classify_intent(message: &str) -> (String, f32) {
let text = message.to_lowercase(); let text = message.to_lowercase();
if text.contains("job description") if text.contains("job description")
@ -269,14 +340,14 @@ fn classify_intent(message: &str) -> String {
|| text.contains("write job") || text.contains("write job")
|| (text.contains("jd") && text.len() < 10) || (text.contains("jd") && text.len() < 10)
{ {
return "job_description_generation".to_string(); return ("job_description_generation".to_string(), 0.92);
} }
if text.contains("cover letter") if text.contains("cover letter")
|| text.contains("write a letter") || text.contains("write a letter")
|| text.contains("generate letter") || text.contains("generate letter")
{ {
return "generate_cover_letter".to_string(); return ("generate_cover_letter".to_string(), 0.88);
} }
if text.contains("resume") if text.contains("resume")
@ -285,11 +356,11 @@ fn classify_intent(message: &str) -> String {
|| text.contains("summary") || text.contains("summary")
|| text.contains("tailor")) || text.contains("tailor"))
{ {
return "improve_resume_summary".to_string(); return ("improve_resume_summary".to_string(), 0.88);
} }
if text.contains("form") || text.contains("field") || text.contains("fill") { if text.contains("form") || text.contains("field") || text.contains("fill") {
return "form_filling_assistance".to_string(); return ("form_filling_assistance".to_string(), 0.84);
} }
if text.contains("search kb") if text.contains("search kb")
@ -303,7 +374,7 @@ fn classify_intent(message: &str) -> String {
|| text.contains("docs") || text.contains("docs")
|| text.contains("documentation") || text.contains("documentation")
{ {
return "search_kb".to_string(); return ("search_kb".to_string(), 0.86);
} }
if text.contains("ticket") if text.contains("ticket")
@ -314,7 +385,7 @@ fn classify_intent(message: &str) -> String {
|| text.contains("not working") || text.contains("not working")
|| text.contains("error") || text.contains("error")
{ {
return "support_ticket_creation".to_string(); return ("support_ticket_creation".to_string(), 0.82);
} }
if text.contains("ai plan") if text.contains("ai plan")
@ -323,14 +394,14 @@ fn classify_intent(message: &str) -> String {
|| text.contains("ai credit") || text.contains("ai credit")
|| text.contains("upgrade ai") || text.contains("upgrade ai")
{ {
return "explain_plan_limits".to_string(); return ("explain_plan_limits".to_string(), 0.78);
} }
if text.contains("balance") if text.contains("balance")
&& (text.contains("ai") || text.contains("credit") || text.contains("action")) && (text.contains("ai") || text.contains("credit") || text.contains("action"))
{ {
return "check_ai_pack_balance".to_string(); return ("check_ai_pack_balance".to_string(), 0.8);
} }
"general".to_string() ("general".to_string(), 0.55)
} }

View file

@ -12,11 +12,16 @@ pub struct ConfirmActionRequest {
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
pub struct ConfirmActionResponse { pub struct ConfirmActionResponse {
pub success: bool, pub success: bool,
pub delegated: bool,
pub message: String, pub message: String,
pub action: String, pub action: String,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub backend_handler: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub record_id: Option<String>, pub record_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub fields: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ui_events: Option<Vec<UiEvent>>, pub ui_events: Option<Vec<UiEvent>>,
} }

View file

@ -3,7 +3,6 @@ use axum::{extract::State, Json};
use crate::{ use crate::{
error::AppError, error::AppError,
handlers::actions::{ConfirmActionRequest, ConfirmActionResponse}, handlers::actions::{ConfirmActionRequest, ConfirmActionResponse},
services::action_confirmation::ActionConfirmationService,
state::AppState, state::AppState,
}; };
@ -19,11 +18,9 @@ pub async fn confirm_action(
return Err(AppError::BadRequest("action is required".to_string())); return Err(AppError::BadRequest("action is required".to_string()));
} }
let user_id = request.conversation_id.clone();
let response = state let response = state
.action_confirmation_service .action_confirmation_service
.confirm_action(request, &user_id) .confirm_action(request)
.await?; .await?;
Ok(Json(response)) Ok(Json(response))

View file

@ -1,263 +1,52 @@
use std::sync::Arc;
use reqwest::Client;
use crate::{ use crate::{
actions::get_action, actions::get_action,
error::AppError, error::AppError,
handlers::actions::{ConfirmActionRequest, ConfirmActionResponse, UiEvent}, handlers::actions::{ConfirmActionRequest, ConfirmActionResponse},
}; };
#[derive(Clone)] #[derive(Clone, Default)]
pub struct ActionConfirmationService { pub struct ActionConfirmationService;
http_client: Client,
jobs_service_url: String,
users_service_url: String,
customers_service_url: String,
}
impl ActionConfirmationService { impl ActionConfirmationService {
pub fn new( pub fn new() -> Self {
users_service_url: String, Self
jobs_service_url: String,
customers_service_url: String,
) -> Self {
Self {
http_client: Client::new(),
users_service_url,
jobs_service_url,
customers_service_url,
}
} }
pub async fn confirm_action( pub async fn confirm_action(
&self, &self,
request: ConfirmActionRequest, request: ConfirmActionRequest,
user_id: &str,
) -> Result<ConfirmActionResponse, AppError> { ) -> Result<ConfirmActionResponse, AppError> {
if !request.confirmed { if !request.confirmed {
return Ok(ConfirmActionResponse { return Ok(ConfirmActionResponse {
success: false, success: false,
delegated: false,
message: "Action cancelled by user.".to_string(), message: "Action cancelled by user.".to_string(),
action: request.action.clone(), action: request.action.clone(),
backend_handler: None,
record_id: None, record_id: None,
fields: None,
ui_events: None, ui_events: None,
}); });
} }
let action = get_action(&request.action) let action = get_action(&request.action);
.ok_or_else(|| AppError::BadRequest(format!("Unknown action: {}", request.action)))?; let backend_handler = action
.as_ref()
match request.action.as_str() { .map(|item| item.backend_handler.clone())
"create_job_draft" => { .unwrap_or_else(|| request.action.clone());
self.create_job_draft(&request, user_id).await
}
"fill_company_profile_form" => {
self.fill_company_profile(&request, user_id).await
}
"improve_company_profile" => {
self.improve_company_profile(&request, user_id).await
}
"create_customer_requirement_draft" => {
self.create_requirement_draft(&request, user_id).await
}
"improve_requirement_description" => {
self.improve_requirement(&request, user_id).await
}
"improve_professional_profile" => {
self.improve_professional_profile(&request, user_id).await
}
"improve_jobseeker_profile" => {
self.improve_jobseeker_profile(&request, user_id).await
}
_ => Ok(ConfirmActionResponse {
success: false,
message: format!("Action {} not yet implemented for confirmation", request.action),
action: request.action.clone(),
record_id: None,
ui_events: None,
}),
}
}
async fn create_job_draft(
&self,
request: &ConfirmActionRequest,
_user_id: &str,
) -> Result<ConfirmActionResponse, AppError> {
let fields = request.fields.as_object().cloned().unwrap_or_default();
let title = fields.get("job_title")
.and_then(|v| v.as_str())
.unwrap_or("Untitled Job");
let description = fields.get("description")
.and_then(|v| v.as_str())
.unwrap_or("");
let location = fields.get("location")
.and_then(|v| v.as_str())
.unwrap_or("");
Ok(ConfirmActionResponse { Ok(ConfirmActionResponse {
success: true, success: true,
message: format!("Job draft created: {}", title), delegated: true,
message: format!(
"Action approved. Execute '{}' in the product backend.",
backend_handler
),
action: request.action.clone(), action: request.action.clone(),
backend_handler: Some(backend_handler),
record_id: None, record_id: None,
ui_events: Some(vec![ fields: Some(request.fields),
UiEvent { ui_events: None,
event_type: "refresh_data".to_string(),
target: "jobs_list".to_string(),
record_id: None,
fields: None,
},
UiEvent {
event_type: "open_preview".to_string(),
target: "job_draft".to_string(),
record_id: None,
fields: Some(serde_json::json!({
"title": title,
"description": description,
"location": location,
})),
},
]),
})
}
async fn fill_company_profile(
&self,
request: &ConfirmActionRequest,
_user_id: &str,
) -> Result<ConfirmActionResponse, AppError> {
let fields = request.fields.as_object().cloned().unwrap_or_default();
Ok(ConfirmActionResponse {
success: true,
message: "Company profile data extracted. Review and save.".to_string(),
action: request.action.clone(),
record_id: None,
ui_events: Some(vec![UiEvent {
event_type: "fill_form".to_string(),
target: "company_profile_form".to_string(),
record_id: None,
fields: Some(serde_json::json!(fields)),
}]),
})
}
async fn improve_company_profile(
&self,
request: &ConfirmActionRequest,
_user_id: &str,
) -> Result<ConfirmActionResponse, AppError> {
let fields = request.fields.as_object().cloned().unwrap_or_default();
Ok(ConfirmActionResponse {
success: true,
message: "Company profile improved. Review changes.".to_string(),
action: request.action.clone(),
record_id: None,
ui_events: Some(vec![UiEvent {
event_type: "fill_form".to_string(),
target: "company_profile_form".to_string(),
record_id: None,
fields: Some(serde_json::json!(fields)),
}]),
})
}
async fn create_requirement_draft(
&self,
request: &ConfirmActionRequest,
_user_id: &str,
) -> Result<ConfirmActionResponse, AppError> {
let fields = request.fields.as_object().cloned().unwrap_or_default();
let title = fields.get("title")
.and_then(|v| v.as_str())
.unwrap_or("Untitled Requirement");
Ok(ConfirmActionResponse {
success: true,
message: format!("Requirement draft created: {}", title),
action: request.action.clone(),
record_id: None,
ui_events: Some(vec![
UiEvent {
event_type: "refresh_data".to_string(),
target: "requirements_list".to_string(),
record_id: None,
fields: None,
},
UiEvent {
event_type: "open_preview".to_string(),
target: "requirement_draft".to_string(),
record_id: None,
fields: Some(serde_json::json!(fields)),
},
]),
})
}
async fn improve_requirement(
&self,
request: &ConfirmActionRequest,
_user_id: &str,
) -> Result<ConfirmActionResponse, AppError> {
let fields = request.fields.as_object().cloned().unwrap_or_default();
Ok(ConfirmActionResponse {
success: true,
message: "Requirement improved. Review changes.".to_string(),
action: request.action.clone(),
record_id: None,
ui_events: Some(vec![UiEvent {
event_type: "fill_form".to_string(),
target: "requirement_form".to_string(),
record_id: None,
fields: Some(serde_json::json!(fields)),
}]),
})
}
async fn improve_professional_profile(
&self,
request: &ConfirmActionRequest,
_user_id: &str,
) -> Result<ConfirmActionResponse, AppError> {
let fields = request.fields.as_object().cloned().unwrap_or_default();
Ok(ConfirmActionResponse {
success: true,
message: "Professional profile improved. Review changes.".to_string(),
action: request.action.clone(),
record_id: None,
ui_events: Some(vec![UiEvent {
event_type: "fill_form".to_string(),
target: "professional_profile_form".to_string(),
record_id: None,
fields: Some(serde_json::json!(fields)),
}]),
})
}
async fn improve_jobseeker_profile(
&self,
request: &ConfirmActionRequest,
_user_id: &str,
) -> Result<ConfirmActionResponse, AppError> {
let fields = request.fields.as_object().cloned().unwrap_or_default();
Ok(ConfirmActionResponse {
success: true,
message: "Job seeker profile improved. Review changes.".to_string(),
action: request.action.clone(),
record_id: None,
ui_events: Some(vec![UiEvent {
event_type: "fill_form".to_string(),
target: "jobseeker_profile_form".to_string(),
record_id: None,
fields: Some(serde_json::json!(fields)),
}]),
}) })
} }
} }

View file

@ -47,11 +47,7 @@ impl AppState {
ticket_service.clone(), ticket_service.clone(),
ai_provider.clone(), ai_provider.clone(),
); );
let action_confirmation_service = ActionConfirmationService::new( let action_confirmation_service = ActionConfirmationService::new();
config.nxtgauge_users_url.clone(),
"http://nxtgauge-rust-jobs:9103".to_string(),
"http://nxtgauge-rust-customers:9105".to_string(),
);
Self { Self {
config, config,

View file

@ -137,9 +137,12 @@ mod tests {
fn test_confirm_action_response_serialization() { fn test_confirm_action_response_serialization() {
let response = ConfirmActionResponse { let response = ConfirmActionResponse {
success: true, success: true,
delegated: true,
message: "Created".to_string(), message: "Created".to_string(),
action: "create_job_draft".to_string(), action: "create_job_draft".to_string(),
backend_handler: Some("jobs.create_draft".to_string()),
record_id: Some("456".to_string()), record_id: Some("456".to_string()),
fields: None,
ui_events: Some(vec![UiEvent { ui_events: Some(vec![UiEvent {
event_type: "refresh_data".to_string(), event_type: "refresh_data".to_string(),
target: "jobs_list".to_string(), target: "jobs_list".to_string(),
@ -157,9 +160,12 @@ mod tests {
fn test_confirm_action_response_skips_none_fields() { fn test_confirm_action_response_skips_none_fields() {
let response = ConfirmActionResponse { let response = ConfirmActionResponse {
success: false, success: false,
delegated: false,
message: "Cancelled".to_string(), message: "Cancelled".to_string(),
action: "test".to_string(), action: "test".to_string(),
backend_handler: None,
record_id: None, record_id: None,
fields: None,
ui_events: None, ui_events: None,
}; };
let json = serde_json::to_string(&response).unwrap(); let json = serde_json::to_string(&response).unwrap();
@ -195,7 +201,13 @@ mod tests {
intent: "job_description_generation".to_string(), intent: "job_description_generation".to_string(),
reply: "Generated".to_string(), reply: "Generated".to_string(),
data: serde_json::json!({"title": "Engineer"}), data: serde_json::json!({"title": "Engineer"}),
status: "completed".to_string(),
conversation_id: Some("conv-123".to_string()), conversation_id: Some("conv-123".to_string()),
confidence: Some(0.9),
fields: None,
missing_fields: None,
requires_confirmation: Some(false),
suggested_action: None,
ui_events: None, ui_events: None,
}; };
let json = serde_json::to_string(&response).unwrap(); let json = serde_json::to_string(&response).unwrap();
@ -209,7 +221,13 @@ mod tests {
intent: "form_filling".to_string(), intent: "form_filling".to_string(),
reply: "Form filled".to_string(), reply: "Form filled".to_string(),
data: serde_json::json!({}), data: serde_json::json!({}),
status: "completed".to_string(),
conversation_id: None, conversation_id: None,
confidence: Some(0.8),
fields: None,
missing_fields: None,
requires_confirmation: Some(false),
suggested_action: None,
ui_events: Some(vec![UiEvent { ui_events: Some(vec![UiEvent {
event_type: "fill_form".to_string(), event_type: "fill_form".to_string(),
target: "test_form".to_string(), target: "test_form".to_string(),
@ -437,7 +455,13 @@ mod tests {
{"title": "Article 2", "url": "https://example.com/2"} {"title": "Article 2", "url": "https://example.com/2"}
] ]
}), }),
status: "completed".to_string(),
conversation_id: Some("conv-789".to_string()), conversation_id: Some("conv-789".to_string()),
confidence: Some(0.85),
fields: None,
missing_fields: None,
requires_confirmation: Some(false),
suggested_action: None,
ui_events: None, ui_events: None,
}; };
let json = serde_json::to_string(&response).unwrap(); let json = serde_json::to_string(&response).unwrap();
@ -521,7 +545,13 @@ mod tests {
intent: "test_intent".to_string(), intent: "test_intent".to_string(),
reply: "Test reply".to_string(), reply: "Test reply".to_string(),
data: serde_json::json!({"key": "value"}), data: serde_json::json!({"key": "value"}),
status: "completed".to_string(),
conversation_id: Some("conv-123".to_string()), conversation_id: Some("conv-123".to_string()),
confidence: Some(0.7),
fields: None,
missing_fields: None,
requires_confirmation: Some(false),
suggested_action: None,
ui_events: None, ui_events: None,
}; };
let json = serde_json::to_string(&response).unwrap(); let json = serde_json::to_string(&response).unwrap();
@ -549,9 +579,12 @@ mod tests {
fn test_confirm_action_response_success() { fn test_confirm_action_response_success() {
let response = ConfirmActionResponse { let response = ConfirmActionResponse {
success: true, success: true,
delegated: true,
message: "Action completed".to_string(), message: "Action completed".to_string(),
action: "create_job_draft".to_string(), action: "create_job_draft".to_string(),
backend_handler: Some("jobs.create_draft".to_string()),
record_id: Some("789".to_string()), record_id: Some("789".to_string()),
fields: None,
ui_events: None, ui_events: None,
}; };
let json = serde_json::to_string(&response).unwrap(); let json = serde_json::to_string(&response).unwrap();
@ -563,9 +596,12 @@ mod tests {
fn test_confirm_action_response_with_ui_events() { fn test_confirm_action_response_with_ui_events() {
let response = ConfirmActionResponse { let response = ConfirmActionResponse {
success: true, success: true,
delegated: true,
message: "Created".to_string(), message: "Created".to_string(),
action: "test".to_string(), action: "test".to_string(),
backend_handler: Some("test.handler".to_string()),
record_id: None, record_id: None,
fields: None,
ui_events: Some(vec![UiEvent { ui_events: Some(vec![UiEvent {
event_type: "show_confirmation".to_string(), event_type: "show_confirmation".to_string(),
target: "modal".to_string(), target: "modal".to_string(),
@ -584,6 +620,9 @@ mod tests {
message: "Hello AI".to_string(), message: "Hello AI".to_string(),
user_id: Some("user-123".to_string()), user_id: Some("user-123".to_string()),
conversation_id: Some("conv-456".to_string()), conversation_id: Some("conv-456".to_string()),
role: None,
product: None,
context: None,
}; };
let json = serde_json::to_string(&request).unwrap(); let json = serde_json::to_string(&request).unwrap();
let parsed: ChatMessageRequest = serde_json::from_str(&json).unwrap(); let parsed: ChatMessageRequest = serde_json::from_str(&json).unwrap();
@ -597,6 +636,9 @@ mod tests {
message: "Test".to_string(), message: "Test".to_string(),
user_id: None, user_id: None,
conversation_id: None, conversation_id: None,
role: None,
product: None,
context: None,
}; };
assert!(request.conversation_id.is_none()); assert!(request.conversation_id.is_none());
assert!(request.user_id.is_none()); assert!(request.user_id.is_none());