From cd7e5bdc059d74cdc3166860a4e7e7b139fa91f5 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Fri, 14 Aug 2026 18:43:24 +0200 Subject: [PATCH] feat(ai): profile improvement draft + save_profile confirm action When a job seeker asks Ask Ash to improve their resume/profile/summary: - Fetches current job_seeker_profiles.summary from DB - Calls LLM (via orchestrator, charges profile_improve feature credits) - Returns action_type: 'profile_draft' with the improved text - If no summary exists yet, returns a navigation response to profile page ai_chat_confirm now handles action='save_profile': - Takes draft_text from fields - UPDATEs job_seeker_profiles.summary for the authenticated user - Returns 404 if no profile row found (not yet onboarded) - Returns helpful error messages on failure Co-Authored-By: Claude Sonnet 4.6 --- apps/users/src/handlers/ai.rs | 150 ++++++++++++++++++++++++++++++++++ 1 file changed, 150 insertions(+) diff --git a/apps/users/src/handlers/ai.rs b/apps/users/src/handlers/ai.rs index fe1ab6b..3c6cc21 100644 --- a/apps/users/src/handlers/ai.rs +++ b/apps/users/src/handlers/ai.rs @@ -2426,6 +2426,101 @@ async fn ai_chat_ask( .into_response(); } + // ── Profile improvement early return (Intent::Resume) ───────────────────── + // When the user asks to improve/tailor their resume/profile, fetch their + // current summary, generate an improved version via LLM, and return it as a + // draft the user can review and save with a single button click. + if matches!(routed.intent, phase3::Intent::Resume) { + let current_summary: Option = sqlx::query_scalar( + "SELECT summary FROM job_seeker_profiles WHERE user_id = $1" + ) + .bind(user_id) + .fetch_optional(&state.pool) + .await + .ok() + .flatten(); + + if let Some(summary) = current_summary.filter(|s| !s.is_empty()) { + let improve_prompt = format!( + "You are a professional career coach and resume writer. \ + Improve the following professional summary for a job seeker's profile. \ + Make it more compelling, achievement-focused, and engaging — \ + keep it 3–4 sentences and under 100 words. \ + Return ONLY the improved summary text, no preamble or explanation.\n\n\ + Current summary:\n{summary}" + ); + + let draft_result = orchestrator::call_feature( + &state, + &auth, + "profile_improve", + None, + &improve_prompt, + body.model.as_deref(), + None, + ) + .await; + + match draft_result { + Ok(r) => { + let improved = r.text.trim().to_string(); + let msg = format!( + "Here's an improved version of your profile summary:\n\n\"{improved}\"\n\nClick **Save to Profile** to apply it, or ask me to adjust the tone or focus." + ); + 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("profile_draft".to_string()), + suggested_action: Some("save_profile".to_string()), + action_type: Some("profile_draft".to_string()), + 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(); + } + // LLM failed — fall through to generic path which will give a navigation response + tracing::warn!("profile_improve LLM call failed, falling through: {}", e); + } + } + } else { + // No profile summary yet — guide them to set one up first + return ( + StatusCode::OK, + Json(AskAshResponse { + message: "I couldn't find a profile summary to improve. Head to your profile page to add one first, then I can help you make it shine!".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_resume_tailor".to_string()), + action_type: Some("navigation".to_string()), + remaining_credits: None, + remaining_daily_actions: None, + }), + ) + .into_response(); + } + } + let system_prompt = build_persona_pillar_system_prompt(persona, pillar); let mut user_block = String::new(); if let Some(p) = persona { @@ -2648,6 +2743,61 @@ async fn ai_chat_confirm( } } + "save_profile" => { + let fields = body.fields.unwrap_or_default(); + let draft_text = fields + .get("draft_text") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim() + .to_string(); + + if draft_text.is_empty() { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "success": false, "error": "draft_text is required" })), + ) + .into_response(); + } + + let result = sqlx::query( + "UPDATE job_seeker_profiles SET summary = $2, updated_at = NOW() WHERE user_id = $1" + ) + .bind(user_id) + .bind(&draft_text) + .execute(&state.pool) + .await; + + match result { + Ok(res) if res.rows_affected() > 0 => ( + StatusCode::OK, + Json(serde_json::json!({ + "success": true, + "action": "save_profile", + "message": "Your profile summary has been updated! It will be visible to companies that view your profile." + })), + ) + .into_response(), + Ok(_) => ( + // UPDATE matched 0 rows — no job_seeker_profiles row for this user + StatusCode::NOT_FOUND, + Json(serde_json::json!({ + "success": false, + "error": "No job seeker profile found. Please create your profile first." + })), + ) + .into_response(), + Err(e) => { + tracing::error!("chat_confirm save_profile failed for user {}: {}", user_id, e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "success": false, "error": "Failed to save profile summary" })), + ) + .into_response() + } + } + } + other => { tracing::warn!("ai_chat_confirm: unknown action '{}'", other); (