feat(ai): cover letter inline generation + draft_content field on AskAshResponse
All checks were successful
build-and-release / build (photographers) (push) Successful in 7s
build-and-release / build (makeup-artists) (push) Successful in 12s
build-and-release / build (social-media-managers) (push) Successful in 8s
build-and-release / build (tutors) (push) Successful in 8s
build-and-release / build (ugc-content-creators) (push) Successful in 8s
build-and-release / build (video-editors) (push) Successful in 8s
backend-integration-tests / ai-credits (push) Successful in 13s
build-and-release / build (users) (push) Successful in 3m55s
build-and-release / build (developers) (push) Successful in 13s
build-and-release / build (customers) (push) Successful in 18s
build-and-release / build (employees) (push) Successful in 17s
build-and-release / build (companies) (push) Successful in 21s
build-and-release / build (cron) (push) Successful in 20s
build-and-release / build (catering-services) (push) Successful in 24s
build-and-release / build (fitness-trainers) (push) Successful in 7s
build-and-release / build (gateway) (push) Successful in 7s
build-and-release / build (job-seekers) (push) Successful in 7s
build-and-release / build (graphic-designers) (push) Successful in 7s
build-and-release / build (payments) (push) Successful in 8s
build-and-release / build (jobs) (push) Successful in 11s

AskAshResponse now carries draft_content: Option<String> — a clean raw
generated text separate from the prose wrapper in message. Used by:
  - profile_draft: the improved summary text (for Save to Profile)
  - cover_letter_draft: the letter body (for clipboard copy)

When Intent::CoverLetter is detected in ask_ash:
  - Fetches job_seeker_profiles (full_name, summary, experience, skills)
  - Calls LLM with cover_letter_generate feature key (charges credits)
  - Returns action_type: 'cover_letter_draft', suggested_action: 'open_cover_letter'
  - draft_content holds the raw letter body; message wraps it in --- delimiters
  - If no profile found: returns navigation response to open_cover_letter
  - LLM failure falls through to generic path

Profile draft updated to populate draft_content: Some(improved) so the
frontend no longer needs regex parsing to extract the clean text.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tracewebstudio Dev 2026-08-15 13:16:20 +02:00
parent cd7e5bdc05
commit ad4ab0477a

View file

@ -2220,8 +2220,14 @@ pub struct AskAshResponse {
/// "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<String>,
/// 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<String>,
pub remaining_credits: Option<i32>,
pub remaining_daily_actions: Option<i32>,
}
@ -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<String>, i32, Vec<String>)> = 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,
}),