diff --git a/apps/users/src/handlers/ai.rs b/apps/users/src/handlers/ai.rs index 3c6cc21..d8f2140 100644 --- a/apps/users/src/handlers/ai.rs +++ b/apps/users/src/handlers/ai.rs @@ -2216,12 +2216,18 @@ pub struct AskAshResponse { pub status: Option, pub suggested_action: Option, /// Semantic category of what the frontend should do next: - /// "ticket_created" — ticket was auto-created inline - /// "ticket_pending" — user wants a ticket but none was created yet (show confirm button) - /// "kb_results" — KB articles were found (show link to help center) - /// "usage_info" — usage/credits summary - /// "navigation" — navigate to the relevant feature page + /// "ticket_created" — ticket was auto-created inline + /// "ticket_pending" — user wants a ticket but none was created yet (show confirm button) + /// "kb_results" — KB articles were found (show link to help center) + /// "usage_info" — usage/credits summary + /// "profile_draft" — AI-improved profile summary; show Save to Profile button + /// "cover_letter_draft"— AI-generated cover letter; show Copy + open tool buttons + /// "navigation" — navigate to the relevant feature page pub action_type: Option, + /// Raw generated content (profile draft text, cover letter body, etc.) separate from + /// the display message. The frontend uses this for clipboard copy and save actions + /// without needing to parse the prose wrapper out of `message`. + pub draft_content: Option, pub remaining_credits: Option, pub remaining_daily_actions: Option, } @@ -2358,6 +2364,7 @@ async fn ai_chat_ask( status: Some("usage_summary".to_string()), suggested_action: Some("show_usage_modal".to_string()), action_type: Some("usage_info".to_string()), + draft_content: None, remaining_credits: Some(remaining_credits), remaining_daily_actions: Some(remaining_daily_actions), }), @@ -2384,6 +2391,7 @@ async fn ai_chat_ask( status: Some("kb_results".to_string()), suggested_action: Some("open_help_search".to_string()), action_type: Some("kb_results".to_string()), + draft_content: None, remaining_credits: None, remaining_daily_actions: None, }), @@ -2419,6 +2427,7 @@ async fn ai_chat_ask( status: Some("ticket_created".to_string()), suggested_action: Some("open_support_ticket".to_string()), action_type: Some("ticket_created".to_string()), + draft_content: None, remaining_credits: None, remaining_daily_actions: None, }), @@ -2482,6 +2491,7 @@ async fn ai_chat_ask( status: Some("profile_draft".to_string()), suggested_action: Some("save_profile".to_string()), action_type: Some("profile_draft".to_string()), + draft_content: Some(improved), remaining_credits: Some(r.remaining_credits), remaining_daily_actions: Some(r.remaining_daily_actions), }), @@ -2513,6 +2523,110 @@ async fn ai_chat_ask( status: Some("navigation".to_string()), suggested_action: Some("open_resume_tailor".to_string()), action_type: Some("navigation".to_string()), + draft_content: None, + remaining_credits: None, + remaining_daily_actions: None, + }), + ) + .into_response(); + } + } + + // ── Cover letter inline generation (Intent::CoverLetter) ───────────────── + // Generates a general professional cover letter from the user's profile so + // they have a strong starting point they can copy immediately or open the + // dedicated Cover Letter Tool to tailor it to a specific job. + if matches!(routed.intent, phase3::Intent::CoverLetter) { + let seeker: Option<(String, Option, i32, Vec)> = sqlx::query_as( + "SELECT full_name, summary, experience_years, skills \ + FROM job_seeker_profiles WHERE user_id = $1" + ) + .bind(user_id) + .fetch_optional(&state.pool) + .await + .ok() + .and_then(|r| r); + + if let Some((full_name, summary, experience, skills)) = seeker { + let skills_str = skills.join(", "); + let cover_prompt = format!( + "You are a professional cover letter writer. \ + Write a compelling, concise cover letter for a job application. \ + IMPORTANT: Do NOT include phone number, email, or any contact information — \ + candidates keep their contacts private on this platform. \ + Write 3 short paragraphs (opening, body, closing). Under 200 words total. \ + Return ONLY the cover letter text with no heading, date, or address block.\n\n\ + Candidate: {full_name}\n\ + Experience: {experience} years\n\ + Summary: {}\n\ + Skills: {skills_str}", + summary.as_deref().unwrap_or("Experienced professional") + ); + + let result = orchestrator::call_feature( + &state, + &auth, + "cover_letter_generate", + None, + &cover_prompt, + body.model.as_deref(), + None, + ) + .await; + + match result { + Ok(r) => { + let letter = r.text.trim().to_string(); + let msg = format!( + "Here's a cover letter draft based on your profile. Copy it and tailor it to the specific role, or open the Cover Letter Tool to generate one matched to a job posting.\n\n---\n{letter}\n---" + ); + return ( + StatusCode::OK, + Json(AskAshResponse { + message: msg, + persona: persona.map(|p| p.as_str().to_string()), + pillar: pillar.map(|p| p.as_str().to_string()), + intent, + confidence, + conversation_id, + kb_matches, + ticket: None, + ollama_used: true, + status: Some("cover_letter_draft".to_string()), + suggested_action: Some("open_cover_letter".to_string()), + action_type: Some("cover_letter_draft".to_string()), + draft_content: Some(letter), + remaining_credits: Some(r.remaining_credits), + remaining_daily_actions: Some(r.remaining_daily_actions), + }), + ) + .into_response(); + } + Err(e) => { + if matches!(e, orchestrator::AiCallError::Plan(_) | orchestrator::AiCallError::Credit(_)) { + return e.into_response(); + } + tracing::warn!("cover_letter inline generation failed, falling through: {}", e); + } + } + } else { + // No job seeker profile — navigate to the tool + return ( + StatusCode::OK, + Json(AskAshResponse { + message: "I couldn't find your job seeker profile. Set up your profile first so I can generate a tailored cover letter for you!".to_string(), + persona: persona.map(|p| p.as_str().to_string()), + pillar: pillar.map(|p| p.as_str().to_string()), + intent, + confidence, + conversation_id, + kb_matches, + ticket: None, + ollama_used: false, + status: Some("navigation".to_string()), + suggested_action: Some("open_cover_letter".to_string()), + action_type: Some("navigation".to_string()), + draft_content: None, remaining_credits: None, remaining_daily_actions: None, }), @@ -2656,6 +2770,7 @@ Assistant:"); status: Some("answered".to_string()), suggested_action: Some(routed.suggested_action.to_string()), action_type: Some(action_type.to_string()), + draft_content: None, remaining_credits, remaining_daily_actions, }),