Spaces:
Runtime error
Runtime error
| 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]) |