269 lines
9.2 KiB
Python
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"))
|