Files
resume-reformer/src/resume_agent/agent.py
T

269 lines
9.2 KiB
Python

from __future__ import annotations
import json
from pathlib import Path
from typing import Protocol, TypeVar
from pydantic import BaseModel
from resume_agent.models import CareerProfile, TailoringPackage
from resume_agent.prompts import (
AUDIT_PROMPT,
PROFILE_PROMPT,
PROFILE_REPAIR_PROMPT,
REVISION_PROMPT,
TAILOR_PROMPT,
)
T = TypeVar("T", bound=BaseModel)
class StructuredLLM(Protocol):
def parse(self, schema: type[T], instructions: str, input_text: str) -> T: ...
def build_profile(llm: StructuredLLM, resume_text: str, about: str = "") -> CareerProfile:
source = {
"resume": resume_text,
"candidate_notes": about,
}
profile = llm.parse(
CareerProfile,
PROFILE_PROMPT,
json.dumps(source, ensure_ascii=False),
)
try:
validate_profile(profile)
except ValueError as exc:
if profile.facts:
raise
repair_source = {
**source,
"previous_validation_error": str(exc),
"previous_result": profile.model_dump(mode="json"),
}
profile = llm.parse(
CareerProfile,
PROFILE_REPAIR_PROMPT,
json.dumps(repair_source, ensure_ascii=False),
)
try:
validate_profile(profile)
except ValueError as repair_exc:
if not profile.facts:
raise ValueError(
"The LLM could not extract evidence facts from "
f"{len(resume_text):,} characters of resume text after two attempts. "
"Try another model with reliable structured-output support, or simplify "
"the resume Markdown."
) from repair_exc
raise
return profile
def tailor_resume(
llm: StructuredLLM,
profile: CareerProfile,
job_text: str,
tailoring_strength: int = 50,
evidence_mode: str = "strict",
) -> TailoringPackage:
if not 0 <= tailoring_strength <= 100:
raise ValueError("Tailoring strength must be between 0 and 100.")
_validate_evidence_mode(evidence_mode)
payload = {
"canonical_profile": profile.model_dump(mode="json"),
"job_post": job_text,
"tailoring_strength": tailoring_strength,
"evidence_mode": evidence_mode,
}
draft = llm.parse(
TailoringPackage,
_tailoring_prompt(tailoring_strength, evidence_mode),
json.dumps(payload, ensure_ascii=False),
)
_restore_canonical_contact(profile, draft)
validate_package(profile, draft, require_evidence=evidence_mode == "strict")
audit_payload = {
"canonical_profile": profile.model_dump(mode="json"),
"proposed_package": draft.model_dump(mode="json"),
"tailoring_strength": tailoring_strength,
"evidence_mode": evidence_mode,
}
audited = llm.parse(
TailoringPackage,
_audit_prompt(evidence_mode),
json.dumps(audit_payload, ensure_ascii=False),
)
_restore_canonical_contact(profile, audited)
validate_package(profile, audited, require_evidence=evidence_mode == "strict")
return audited
def _tailoring_prompt(strength: int, evidence_mode: str) -> str:
if strength <= 20:
guidance = (
"Stay very close to the source wording and organization. Make only small "
"relevance edits and prefer concise, directly quoted evidence."
)
elif strength <= 70:
guidance = (
"Reorder and rewrite supported facts for clear job relevance while retaining "
"the candidate's original meaning and normal resume length."
)
else:
guidance = (
"Maximize truthful job alignment. Use the fullest relevant detail available "
"in the evidence, stronger active phrasing, and job-post terminology only "
"where directly supported. Include more supported bullets when useful."
)
return (
f"{TAILOR_PROMPT}\n\n"
f"Tailoring strength: {strength}/100.\n"
f"{guidance}\n"
f"{_evidence_guidance(evidence_mode)}\n"
"This setting changes editing intensity only. It never permits fabricated, "
"exaggerated, inferred, or unsupported claims."
)
def _evidence_guidance(evidence_mode: str) -> str:
if evidence_mode == "strict":
return (
"Evidence mode: strict. Every summary statement and resume item must cite "
"one or more directly supporting profile fact IDs in evidence_ids. Put IDs "
"only in evidence_ids, never in candidate-facing text."
)
return (
"Evidence mode: flexible profile-based rewrite. Use the complete canonical "
"profile as the factual source, but per-claim evidence IDs are optional and "
"evidence_ids may be empty. Do not invent information absent from the profile."
)
def _audit_prompt(evidence_mode: str) -> str:
if evidence_mode == "strict":
evidence_rules = (
"Require every candidate-facing claim to cite directly supporting fact IDs. "
"Reject unknown or mismatched IDs. Keep IDs only in evidence_ids."
)
else:
evidence_rules = (
"Audit claims against the canonical profile as a whole. Per-claim evidence "
"IDs are optional; do not reject an otherwise supported claim solely because "
"evidence_ids is empty."
)
return f"{AUDIT_PROMPT}\n\n{evidence_rules}"
def _validate_evidence_mode(evidence_mode: str) -> None:
if evidence_mode not in {"strict", "profile"}:
raise ValueError("Evidence mode must be either 'strict' or 'profile'.")
def revise_tailored_resume(
llm: StructuredLLM,
profile: CareerProfile,
current: TailoringPackage,
instruction: str,
evidence_mode: str = "strict",
) -> TailoringPackage:
instruction = instruction.strip()
if len(instruction) < 3:
raise ValueError("Describe how you want the tailored resume revised.")
_validate_evidence_mode(evidence_mode)
revision_payload = {
"canonical_profile": profile.model_dump(mode="json"),
"current_tailored_package": current.model_dump(mode="json"),
"candidate_request": instruction,
"evidence_mode": evidence_mode,
}
revised = llm.parse(
TailoringPackage,
f"{REVISION_PROMPT}\n\n{_evidence_guidance(evidence_mode)}",
json.dumps(revision_payload, ensure_ascii=False),
)
_restore_canonical_contact(profile, revised)
_validate_revision(profile, current, revised, evidence_mode)
audit_payload = {
"canonical_profile": profile.model_dump(mode="json"),
"original_job_analysis": current.job.model_dump(mode="json"),
"candidate_request": instruction,
"proposed_package": revised.model_dump(mode="json"),
"evidence_mode": evidence_mode,
}
audited = llm.parse(
TailoringPackage,
_audit_prompt(evidence_mode),
json.dumps(audit_payload, ensure_ascii=False),
)
_restore_canonical_contact(profile, audited)
_validate_revision(profile, current, audited, evidence_mode)
return audited
def _validate_revision(
profile: CareerProfile,
current: TailoringPackage,
revised: TailoringPackage,
evidence_mode: str,
) -> None:
validate_package(profile, revised, require_evidence=evidence_mode == "strict")
if revised.job != current.job:
raise ValueError("A resume revision cannot change the original job analysis.")
def _restore_canonical_contact(
profile: CareerProfile,
package: TailoringPackage,
) -> None:
package.resume.contact = profile.contact.model_copy(deep=True)
def validate_profile(profile: CareerProfile) -> None:
ids = [fact.id for fact in profile.facts]
if not ids:
raise ValueError("The profile contains no evidence facts.")
if len(ids) != len(set(ids)):
raise ValueError("The profile contains duplicate evidence IDs.")
if any(not fact.source_excerpt.strip() for fact in profile.facts):
raise ValueError("Every profile fact must include a source excerpt.")
def validate_package(
profile: CareerProfile,
package: TailoringPackage,
*,
require_evidence: bool = True,
) -> None:
valid_ids = {fact.id for fact in profile.facts}
backed_items = list(package.resume.summary)
for section in package.resume.sections:
backed_items.extend(section.items)
if not backed_items:
raise ValueError("The tailored resume contains no evidence-backed content.")
for item in backed_items:
if require_evidence and not item.evidence_ids:
raise ValueError(f"Resume claim has no evidence: {item.text}")
unknown = set(item.evidence_ids) - valid_ids
if unknown:
raise ValueError(
f"Resume claim references unknown evidence IDs: {', '.join(sorted(unknown))}"
)
if package.resume.contact != profile.contact:
raise ValueError("The tailored resume changed the candidate's contact information.")
def save_json(model: BaseModel, path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(model.model_dump_json(indent=2), encoding="utf-8")
def load_profile(path: Path) -> CareerProfile:
return CareerProfile.model_validate_json(path.read_text(encoding="utf-8"))