Skip to content

Commit e149d8d

Browse files
committed
Enforce document voice consistency
1 parent ecbceff commit e149d8d

6 files changed

Lines changed: 225 additions & 3 deletions

File tree

src/agents/cover_letter_agent.py

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import re
2+
13
from src.config import get_openai_max_completion_tokens_for_task
24
from src.prompts import build_cover_letter_agent_prompt
35
from src.schemas import (
@@ -15,6 +17,10 @@
1517
from .common import coerce_string, coerce_string_list, unique_strings
1618

1719

20+
_THIRD_PERSON_SELF_REFERENCE_RE = re.compile(r"\b(he|his|him|she|her)\b", re.IGNORECASE)
21+
_CANDIDATE_LABEL_RE = re.compile(r"\b(the candidate|this candidate)\b", re.IGNORECASE)
22+
23+
1824
class CoverLetterAgent:
1925
def __init__(self, openai_service=None):
2026
self._openai_service = openai_service
@@ -49,14 +55,25 @@ def run(
4955
task_name="cover_letter",
5056
metadata=prompt.get("metadata"),
5157
)
52-
return CoverLetterAgentOutput(
58+
output = CoverLetterAgentOutput(
5359
greeting=coerce_string(payload.get("greeting"), default="Dear Hiring Team"),
5460
opening_paragraph=coerce_string(payload.get("opening_paragraph")),
5561
body_paragraphs=coerce_string_list(payload.get("body_paragraphs"), limit=3),
5662
closing_paragraph=coerce_string(payload.get("closing_paragraph")),
5763
signoff=coerce_string(payload.get("signoff"), default="Sincerely"),
5864
signature_name=coerce_string(payload.get("signature_name"), default=candidate_profile.full_name or "Candidate"),
5965
)
66+
if self._contains_third_person_self_reference(candidate_profile, output):
67+
return self._fallback(
68+
candidate_profile,
69+
job_description,
70+
fit_analysis,
71+
tailored_draft,
72+
tailoring_output,
73+
strategy_output,
74+
resume_generation_output,
75+
)
76+
return output
6077
return self._fallback(
6178
candidate_profile,
6279
job_description,
@@ -67,6 +84,26 @@ def run(
6784
resume_generation_output,
6885
)
6986

87+
@staticmethod
88+
def _contains_third_person_self_reference(
89+
candidate_profile: CandidateProfile,
90+
cover_letter_output: CoverLetterAgentOutput,
91+
) -> bool:
92+
text_blocks = [
93+
cover_letter_output.opening_paragraph,
94+
*cover_letter_output.body_paragraphs,
95+
cover_letter_output.closing_paragraph,
96+
]
97+
combined_text = " ".join(str(block or "").strip() for block in text_blocks if str(block or "").strip())
98+
if not combined_text:
99+
return False
100+
candidate_name = str(candidate_profile.full_name or "").strip()
101+
if candidate_name and candidate_name.lower() in combined_text.lower():
102+
return True
103+
if _CANDIDATE_LABEL_RE.search(combined_text):
104+
return True
105+
return _THIRD_PERSON_SELF_REFERENCE_RE.search(combined_text) is not None
106+
70107
@staticmethod
71108
def _fallback(
72109
candidate_profile: CandidateProfile,

src/agents/resume_generation_agent.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import re
2+
13
from src.config import get_openai_max_completion_tokens_for_task
24
from src.prompts import build_resume_generation_agent_prompt
35
from src.schemas import (
@@ -14,6 +16,12 @@
1416
from .common import coerce_string, coerce_string_list, unique_strings
1517

1618

19+
_RESUME_SELF_REFERENCE_RE = re.compile(
20+
r"\b(i|me|my|mine|myself|he|his|him|himself|she|her|hers|herself|the candidate|this candidate)\b",
21+
re.IGNORECASE,
22+
)
23+
24+
1725
class ResumeGenerationAgent:
1826
def __init__(self, openai_service=None):
1927
self._openai_service = openai_service
@@ -48,15 +56,32 @@ def run(
4856
task_name="resume_generation",
4957
metadata=prompt.get("metadata"),
5058
)
51-
return ResumeGenerationAgentOutput(
59+
output = ResumeGenerationAgentOutput(
5260
professional_summary=coerce_string(payload.get("professional_summary")),
5361
highlighted_skills=coerce_string_list(payload.get("highlighted_skills"), limit=8),
5462
experience_bullets=coerce_string_list(payload.get("experience_bullets"), limit=6),
5563
section_order=coerce_string_list(payload.get("section_order"), limit=6),
5664
template_hint=coerce_string(payload.get("template_hint"), default="classic_ats"),
5765
)
66+
if self._contains_self_reference(candidate_profile, output):
67+
return self._fallback(fit_analysis, tailored_draft, tailoring_output)
68+
return output
5869
return self._fallback(fit_analysis, tailored_draft, tailoring_output)
5970

71+
@staticmethod
72+
def _contains_self_reference(
73+
candidate_profile: CandidateProfile,
74+
resume_output: ResumeGenerationAgentOutput,
75+
) -> bool:
76+
text_blocks = [resume_output.professional_summary, *resume_output.experience_bullets]
77+
combined_text = " ".join(str(block or "").strip() for block in text_blocks if str(block or "").strip())
78+
if not combined_text:
79+
return False
80+
candidate_name = str(candidate_profile.full_name or "").strip()
81+
if candidate_name and candidate_name.lower() in combined_text.lower():
82+
return True
83+
return _RESUME_SELF_REFERENCE_RE.search(combined_text) is not None
84+
6085
@staticmethod
6186
def _fallback(
6287
fit_analysis: FitAnalysis,

src/prompts.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,7 @@ def build_resume_generation_agent_prompt(
347347
"system": (
348348
"You are the Resume Generation Agent. Produce the final tailored resume content from grounded upstream analysis. "
349349
"You may rewrite, reorder, and emphasize, but you must not invent employers, achievements, dates, metrics, or unsupported skills. "
350+
"Write in standard resume style: no first-person or third-person pronouns, no full-name self-reference inside the summary or bullets, and no cover-letter phrasing. "
350351
"Keep the output ATS-safe and recruiter-readable. "
351352
+ _build_contract(contract)
352353
),
@@ -391,6 +392,8 @@ def build_cover_letter_agent_prompt(
391392
return {
392393
"system": (
393394
"You are the Cover Letter Agent. Write a recruiter-facing cover letter only after the review stage has approved or corrected the upstream outputs. "
395+
"Write entirely in first person from the candidate's perspective. "
396+
"Do not describe the candidate as he, she, him, his, her, or by full name anywhere in the letter body; reserve the candidate name for the signature line only. "
394397
"Use the approved tailoring, strategy, review, and resume-generation context as the source of truth. "
395398
"Do not invent employers, metrics, projects, technologies, or direct experience that are not supported by the provided inputs. "
396399
"Keep the result specific to the role, grounded, and ready for packaging into the final artifact. "

tests/test_cover_letter_agent.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
from src.agents.cover_letter_agent import CoverLetterAgent
2+
from src.schemas import CandidateProfile, FitAnalysis, JobDescription, JobRequirements, TailoredResumeDraft, TailoringAgentOutput, WorkExperience
3+
4+
5+
class FakeThirdPersonOpenAIService:
6+
model = "fake-model"
7+
8+
@staticmethod
9+
def is_available():
10+
return True
11+
12+
@staticmethod
13+
def run_json_prompt(system_prompt, user_prompt, expected_keys=None, **kwargs):
14+
return {
15+
"greeting": "Dear Hiring Team",
16+
"opening_paragraph": "I am excited to apply for the Data Scientist role. Leander Antony is a project-based machine-learning candidate with hands-on Python experience.",
17+
"body_paragraphs": [
18+
"His portfolio work includes predictive modeling and validation projects.",
19+
],
20+
"closing_paragraph": "He would welcome the opportunity to discuss the role further.",
21+
"signoff": "Sincerely",
22+
"signature_name": "Leander Antony",
23+
}
24+
25+
26+
def test_cover_letter_agent_falls_back_when_ai_uses_third_person_self_reference():
27+
agent = CoverLetterAgent(openai_service=FakeThirdPersonOpenAIService())
28+
candidate_profile = CandidateProfile(
29+
full_name="Leander Antony",
30+
experience=[
31+
WorkExperience(
32+
title="AI Engineer",
33+
organization="Example Labs",
34+
)
35+
],
36+
)
37+
job_description = JobDescription(
38+
title="Data Scientist",
39+
raw_text="",
40+
cleaned_text="",
41+
requirements=JobRequirements(hard_skills=["Python", "SQL"]),
42+
)
43+
fit_analysis = FitAnalysis(
44+
target_role="Data Scientist",
45+
overall_score=82,
46+
readiness_label="Strong",
47+
matched_hard_skills=["Python", "SQL"],
48+
experience_signal="I have built predictive models and validation workflows in portfolio projects.",
49+
)
50+
tailored_draft = TailoredResumeDraft(
51+
target_role="Data Scientist",
52+
professional_summary="Project-based machine learning candidate with grounded Python experience.",
53+
highlighted_skills=["Python", "SQL"],
54+
)
55+
tailoring_output = TailoringAgentOutput(
56+
professional_summary="Project-based machine learning candidate with grounded Python experience.",
57+
cover_letter_themes=["Highlight predictive modeling and validation work."],
58+
)
59+
60+
result = agent.run(
61+
candidate_profile,
62+
job_description,
63+
fit_analysis,
64+
tailored_draft,
65+
tailoring_output,
66+
)
67+
68+
assert "Leander Antony is" not in result.opening_paragraph
69+
assert "His portfolio work" not in " ".join(result.body_paragraphs)
70+
assert result.opening_paragraph.startswith("I am excited to apply for the Data Scientist role.")
71+
assert result.closing_paragraph.startswith("I would welcome the opportunity to discuss")

tests/test_prompts.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
from src.prompts import (
22
build_assistant_prompt,
33
build_application_qa_assistant_prompt,
4+
build_cover_letter_agent_prompt,
45
build_fit_agent_prompt,
6+
build_resume_generation_agent_prompt,
57
build_review_agent_prompt,
68
build_strategy_agent_prompt,
79
)
@@ -94,4 +96,30 @@ def test_review_prompt_allows_null_corrections_when_no_rewrite_is_needed():
9496
assert "null when no tailoring changes are needed" in prompt["system"]
9597
assert "null when no strategy changes are needed" in prompt["system"]
9698
assert "unresolved_issues" in prompt["system"]
97-
assert "Approve when the final corrected wording stays grounded" in prompt["system"]
99+
assert "Approve when the final corrected wording stays grounded" in prompt["system"]
100+
101+
102+
def test_cover_letter_prompt_requires_first_person_voice():
103+
prompt = build_cover_letter_agent_prompt(
104+
candidate_profile={"full_name": "Leander Antony"},
105+
job_description={"title": "Data Scientist"},
106+
fit_analysis={"experience_signal": "Grounded ML project experience."},
107+
tailored_draft={"professional_summary": "Project-based ML candidate."},
108+
tailoring_output={"professional_summary": "Grounded summary."},
109+
)
110+
111+
assert "Write entirely in first person from the candidate's perspective" in prompt["system"]
112+
assert "Do not describe the candidate as he, she, him, his, her, or by full name" in prompt["system"]
113+
114+
115+
def test_resume_generation_prompt_requires_pronoun_free_resume_style():
116+
prompt = build_resume_generation_agent_prompt(
117+
candidate_profile={"full_name": "Leander Antony"},
118+
job_description={"title": "Data Scientist"},
119+
fit_analysis={"matched_hard_skills": ["Python"]},
120+
tailored_draft={"professional_summary": "Grounded draft summary."},
121+
tailoring_output={"professional_summary": "Grounded summary."},
122+
)
123+
124+
assert "no first-person or third-person pronouns" in prompt["system"]
125+
assert "no full-name self-reference inside the summary or bullets" in prompt["system"]
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
from src.agents.resume_generation_agent import ResumeGenerationAgent
2+
from src.schemas import CandidateProfile, FitAnalysis, ResumeGenerationAgentOutput, TailoredResumeDraft, TailoringAgentOutput
3+
4+
5+
class FakePronounResumeOpenAIService:
6+
model = "fake-model"
7+
8+
@staticmethod
9+
def is_available():
10+
return True
11+
12+
@staticmethod
13+
def run_json_prompt(system_prompt, user_prompt, expected_keys=None, **kwargs):
14+
return {
15+
"professional_summary": "I am a project-based machine learning candidate with strong predictive modeling experience.",
16+
"highlighted_skills": ["Python", "SQL", "XGBoost"],
17+
"experience_bullets": [
18+
"I built predictive models for fraud detection.",
19+
"Leander Antony developed validation workflows for ML projects.",
20+
],
21+
"section_order": ["Professional Summary", "Core Skills", "Professional Experience", "Education"],
22+
"template_hint": "classic_ats",
23+
}
24+
25+
26+
def test_resume_generation_agent_falls_back_when_ai_uses_self_referential_resume_voice():
27+
agent = ResumeGenerationAgent(openai_service=FakePronounResumeOpenAIService())
28+
candidate_profile = CandidateProfile(full_name="Leander Antony")
29+
fit_analysis = FitAnalysis(
30+
target_role="Data Scientist",
31+
overall_score=84,
32+
readiness_label="Strong",
33+
matched_hard_skills=["Python", "SQL", "XGBoost"],
34+
)
35+
tailored_draft = TailoredResumeDraft(
36+
target_role="Data Scientist",
37+
professional_summary="Candidate profile aligned to Data Scientist with grounded evidence around Python, SQL, XGBoost.",
38+
highlighted_skills=["Python", "SQL", "XGBoost"],
39+
priority_bullets=["Built predictive modeling workflows for fraud detection use cases."],
40+
)
41+
tailoring_output = TailoringAgentOutput(
42+
professional_summary="Candidate profile aligned to Data Scientist with grounded evidence around Python, SQL, XGBoost.",
43+
rewritten_bullets=["Built predictive modeling workflows for fraud detection use cases."],
44+
highlighted_skills=["Python", "SQL", "XGBoost"],
45+
)
46+
47+
result = agent.run(
48+
candidate_profile,
49+
job_description={"title": "Data Scientist"},
50+
fit_analysis=fit_analysis,
51+
tailored_draft=tailored_draft,
52+
tailoring_output=tailoring_output,
53+
)
54+
55+
assert isinstance(result, ResumeGenerationAgentOutput)
56+
assert result.professional_summary == tailoring_output.professional_summary
57+
assert result.experience_bullets == ["Built predictive modeling workflows for fraud detection use cases."]
58+
assert all("I " not in bullet for bullet in result.experience_bullets)

0 commit comments

Comments
 (0)