Job-Application-Assistant / agents /cover_letter_agent.py
Noo88ear's picture
🚀 Initial deployment of Multi-Agent Job Application Assistant
7498f2c
from __future__ import annotations
from typing import List, Optional
import re
from models.schemas import UserProfile, JobPosting, CoverLetterDraft
from memory.store import memory_store
from utils.text import extract_keywords_from_text, clamp_to_char_limit
from utils.ats import basic_cover_letter_template, strengthen_action_verbs
from utils.consistency import allowed_keywords_from_profile, coverage_score, conciseness_score
from services.web_research import get_role_guidelines, cover_letter_inspiration_from_url
from services.llm import llm
from utils.langextractor import distill_text
class CoverLetterAgent:
def __init__(self) -> None:
self.name = "cover_letter"
self.max_chars = 4000
def create_cover_letter(self, profile: UserProfile, job: JobPosting, user_id: str = "default_user", user_chat: Optional[str] = None, seed_text: Optional[str] = None, agent2_notes: Optional[str] = None, inspiration_url: Optional[str] = None) -> CoverLetterDraft:
jd_keywords: List[str] = extract_keywords_from_text(job.description or "", top_k=25)
allowed = allowed_keywords_from_profile(profile.skills, profile.experiences)
greeting = "Hiring Manager,"
body = [
(
f"I am excited to apply for the {job.title} role at {job.company}. "
f"With experience across {', '.join(profile.skills[:8])}, I can quickly contribute to your team."
),
(
"In my recent work, I delivered outcomes such as driving cost reductions, building scalable platforms, "
"and improving reliability. I have hands-on experience with the tools and practices highlighted "
f"in your description, including {', '.join(jd_keywords[:8])}."
),
(
"I am particularly interested in this opportunity because it aligns with my background and career goals. "
"I value impact, ownership, and collaboration."
),
]
closing = "Thank you for your time and consideration."
signature = profile.full_name
base_text = seed_text.strip() if seed_text else None
draft = base_text or basic_cover_letter_template(greeting, body, closing, signature)
if base_text and len(base_text) > 1500:
bullets = distill_text(base_text, max_points=10)
draft = ("\n".join(f"- {b}" for b in bullets) + "\n\n") + draft[:3000]
guidance = get_role_guidelines(job.title, job.description)
humor_notes = cover_letter_inspiration_from_url(inspiration_url) if inspiration_url else ""
used_keywords: List[str] = []
# Detect low overlap between profile and JD keywords to hint a career pivot narrative
overlap_count = sum(1 for k in jd_keywords if k.lower() in allowed)
overlap_ratio = overlap_count / max(1, len(jd_keywords[:15]))
career_change_hint = overlap_ratio < 0.25
# Prepare transferable skills (top profile skills), and pull 1-2 achievements across experiences
transferable_skills = profile.skills[:6] if profile.skills else []
sample_achievements: List[str] = []
for e in profile.experiences:
if e.achievements:
for a in e.achievements:
if a and len(sample_achievements) < 2:
sample_achievements.append(a.strip())
for cycle in range(3):
new_mentions = []
for kw in jd_keywords[:12]:
if kw.lower() in allowed and kw.lower() not in draft.lower():
new_mentions.append(kw)
if new_mentions:
draft = draft.rstrip() + "\n\nRelevant focus: " + ", ".join(new_mentions[:8]) + "\n"
used_keywords = list({*used_keywords, *new_mentions[:8]})
if llm.enabled:
system = (
"You refine cover letters. Preserve factual accuracy. Be concise (<= 1 page). "
"Keep ATS-friendly text; avoid flowery language. "
f"Apply latest guidance: {guidance}. "
"Emphasize transferable skills and a positive pivot narrative when the candidate is changing careers. "
"Structure: concise hook; 1–2 quantified achievements (STAR compressed); alignment to role/company; clear close/CTA. "
"Use active voice and strong action verbs; avoid clichés/buzzwords. UK English. Use digits for numbers and £ for currency. "
)
humor = f"\nInspiration guideline (do not copy text): {humor_notes}" if humor_notes else ""
notes = (f"\nNotes from Agent 2: {agent2_notes}" if agent2_notes else "")
custom = f"\nUser instructions: {user_chat}" if user_chat else ""
pivot = "\nCareer change: true — highlight transferable skills and motivation for the pivot." if career_change_hint else ""
examples = ("\nAchievements to consider: " + "; ".join(sample_achievements)) if sample_achievements else ""
tskills = ("\nTransferable skills: " + ", ".join(transferable_skills)) if transferable_skills else ""
user = (
f"Role: {job.title}. Company: {job.company}.\n"
f"Job keywords: {', '.join(jd_keywords[:20])}.\n"
f"Allowed keywords (from user profile): {', '.join(sorted(list(allowed))[:40])}.\n"
f"Rewrite the following cover letter to strengthen alignment without inventing new skills.{custom}{notes}{humor}{pivot}{examples}{tskills}\n"
f"Keep within {self.max_chars} characters.\n\n"
f"Cover letter content:\n{draft}"
)
draft = llm.generate(system, user, max_tokens=800, agent="cover")
# Simple buzzword scrub
lower = draft.lower()
for bad in [
"results-driven", "team player", "works well alone", "people person",
"perfectionist", "multi-tasker", "multi tasker", "dynamic go-getter",
]:
if bad in lower:
draft = draft.replace(bad, "")
lower = draft.lower()
# Strengthen weak openers
draft = strengthen_action_verbs(draft)
# Normalise £/% hints
draft = draft.replace("GBP", "£")
draft = re.sub(r"\bpercent\b", "%", draft, flags=re.IGNORECASE)
cov = coverage_score(draft, jd_keywords)
conc = conciseness_score(draft, self.max_chars)
if conc < 1.0:
draft = clamp_to_char_limit(draft, self.max_chars)
memory_store.save(user_id, self.name, {
"job_id": job.id,
"cycle": cycle + 1,
"coverage": cov,
"conciseness": conc,
"keywords_used": used_keywords,
"guidance": guidance[:500],
"user_chat": (user_chat or "")[:500],
"agent2_notes": (agent2_notes or "")[:500],
"inspiration_url": inspiration_url or "",
"draft": draft,
"career_change_hint": career_change_hint,
}, job_id=job.id)
draft = clamp_to_char_limit(draft, self.max_chars)
memory_store.save(user_id, self.name, {
"job_id": job.id,
"final": True,
"keywords_used": used_keywords,
"draft": draft,
}, job_id=job.id)
return CoverLetterDraft(job_id=job.id, text=draft, keywords_used=used_keywords[:12])