init
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
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,
|
||||
) -> TailoringPackage:
|
||||
if not 0 <= tailoring_strength <= 100:
|
||||
raise ValueError("Tailoring strength must be between 0 and 100.")
|
||||
payload = {
|
||||
"canonical_profile": profile.model_dump(mode="json"),
|
||||
"job_post": job_text,
|
||||
"tailoring_strength": tailoring_strength,
|
||||
}
|
||||
draft = llm.parse(
|
||||
TailoringPackage,
|
||||
_tailoring_prompt(tailoring_strength),
|
||||
json.dumps(payload, ensure_ascii=False),
|
||||
)
|
||||
validate_package(profile, draft)
|
||||
|
||||
audit_payload = {
|
||||
"canonical_profile": profile.model_dump(mode="json"),
|
||||
"proposed_package": draft.model_dump(mode="json"),
|
||||
"tailoring_strength": tailoring_strength,
|
||||
}
|
||||
audited = llm.parse(
|
||||
TailoringPackage,
|
||||
AUDIT_PROMPT,
|
||||
json.dumps(audit_payload, ensure_ascii=False),
|
||||
)
|
||||
validate_package(profile, audited)
|
||||
return audited
|
||||
|
||||
|
||||
def _tailoring_prompt(strength: int) -> 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"
|
||||
"This setting changes editing intensity only. It never permits fabricated, "
|
||||
"exaggerated, inferred, or unsupported claims."
|
||||
)
|
||||
|
||||
|
||||
def revise_tailored_resume(
|
||||
llm: StructuredLLM,
|
||||
profile: CareerProfile,
|
||||
current: TailoringPackage,
|
||||
instruction: str,
|
||||
) -> TailoringPackage:
|
||||
instruction = instruction.strip()
|
||||
if len(instruction) < 3:
|
||||
raise ValueError("Describe how you want the tailored resume revised.")
|
||||
|
||||
revision_payload = {
|
||||
"canonical_profile": profile.model_dump(mode="json"),
|
||||
"current_tailored_package": current.model_dump(mode="json"),
|
||||
"candidate_request": instruction,
|
||||
}
|
||||
revised = llm.parse(
|
||||
TailoringPackage,
|
||||
REVISION_PROMPT,
|
||||
json.dumps(revision_payload, ensure_ascii=False),
|
||||
)
|
||||
_validate_revision(profile, current, revised)
|
||||
|
||||
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"),
|
||||
}
|
||||
audited = llm.parse(
|
||||
TailoringPackage,
|
||||
AUDIT_PROMPT,
|
||||
json.dumps(audit_payload, ensure_ascii=False),
|
||||
)
|
||||
_validate_revision(profile, current, audited)
|
||||
return audited
|
||||
|
||||
|
||||
def _validate_revision(
|
||||
profile: CareerProfile,
|
||||
current: TailoringPackage,
|
||||
revised: TailoringPackage,
|
||||
) -> None:
|
||||
validate_package(profile, revised)
|
||||
if revised.job != current.job:
|
||||
raise ValueError("A resume revision cannot change the original job analysis.")
|
||||
|
||||
|
||||
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) -> 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 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"))
|
||||
Reference in New Issue
Block a user