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"))