diff --git a/config/user.yaml.example b/config/user.yaml.example index 22c8ecb..b17c083 100644 --- a/config/user.yaml.example +++ b/config/user.yaml.example @@ -20,6 +20,14 @@ mission_preferences: music: "" # e.g. "I've played in bands for 15 years and care deeply about how artists get paid" animal_welfare: "" # e.g. "I volunteer at my local shelter every weekend" education: "" # e.g. "I tutored underserved kids for 3 years and care deeply about literacy" + social_impact: "" # e.g. "I want my work to reach people who need help most" + health: "" # e.g. "I care about people navigating rare or poorly-understood health conditions" + # Note: if left empty, Para 3 defaults to focusing on the people the company + # serves — not the industry. Fill in for a more personal connection. + +# Optional: how you write and communicate. Used to shape cover letter voice. +# e.g. "Warm and direct. Cares about people first. Finds rare and complex situations fascinating." +candidate_voice: "" # Set to true to include optional identity-related sections in research briefs. # Both are for your personal decision-making only — never included in applications. diff --git a/scripts/generate_cover_letter.py b/scripts/generate_cover_letter.py index 481c263..6fe018a 100644 --- a/scripts/generate_cover_letter.py +++ b/scripts/generate_cover_letter.py @@ -73,6 +73,20 @@ _MISSION_SIGNALS: dict[str, list[str]] = { "social good", "civic", "public health", "mental health", "food security", "housing", "homelessness", "poverty", "workforce development", ], + # Health is listed last — it's a genuine but lower-priority connection than + # music/animals/education/social_impact. detect_mission_alignment returns on first + # match, so dict order = preference order. + "health": [ + "patient", "patients", "healthcare", "health tech", "healthtech", + "pharma", "pharmaceutical", "clinical", "medical", + "hospital", "clinic", "therapy", "therapist", + "rare disease", "life sciences", "life science", + "treatment", "prescription", "biotech", "biopharma", "medtech", + "behavioral health", "population health", + "care management", "care coordination", "oncology", "specialty pharmacy", + "provider network", "payer", "health plan", "benefits administration", + "ehr", "emr", "fhir", "hipaa", + ], } _candidate = _profile.name if _profile else "the candidate" @@ -99,6 +113,15 @@ _MISSION_DEFAULTS: dict[str, str] = { f"cause {_candidate} cares deeply about. Para 3 should warmly reflect their genuine " "desire to apply their skills to work that makes a real difference in people's lives." ), + "health": ( + f"This company works in healthcare, life sciences, or patient care. " + f"Do NOT write about {_candidate}'s passion for pharmaceuticals or healthcare as an " + "industry. Instead, Para 3 should reflect genuine care for the PEOPLE these companies " + "exist to serve — those navigating complex, often invisible, or unusual health journeys; " + "patients facing rare or poorly understood conditions; individuals whose situations don't " + "fit a clean category. The connection is to the humans behind the data, not the industry. " + "If the user has provided a personal note, use that to anchor Para 3 specifically." + ), } @@ -189,6 +212,24 @@ def build_prompt( return "\n".join(parts) +def _trim_to_letter_end(text: str) -> str: + """Remove repetitive hallucinated content after the first complete sign-off. + + Fine-tuned models sometimes loop after completing the letter. This cuts at + the first closing + candidate name so only the intended letter is saved. + """ + candidate_first = (_profile.name.split()[0] if _profile else "").strip() + pattern = ( + r'(?:Warm regards|Sincerely|Best regards|Kind regards|Thank you)[,.]?\s*\n+\s*' + + (re.escape(candidate_first) if candidate_first else r'\w+') + + r'\b' + ) + m = re.search(pattern, text, re.IGNORECASE) + if m: + return text[:m.end()].strip() + return text.strip() + + def generate( title: str, company: str, @@ -227,8 +268,10 @@ def generate( if feedback: print("[cover-letter] Refinement mode: feedback provided", file=sys.stderr) - result = _router.complete(prompt) - return result.strip() + # max_tokens=1200 caps generation at ~900 words — enough for any cover letter + # and prevents fine-tuned models from looping into repetitive garbage output. + result = _router.complete(prompt, max_tokens=1200) + return _trim_to_letter_end(result) def main() -> None: