"""Trusted quality metrics derived from canonical resume artifacts.""" from __future__ import annotations from dataclasses import dataclass import hashlib import json from pathlib import Path from .models import ( CandidateProfile, ContentPlan, EvidenceMap, EvidenceMatchType, GenerationConfig, JobAnalysis, JobPosting, QualityCategory, QualityFinding, QualitySeverity, RequirementKind, ResumeDraft, _claim_mentions_requirement, _evidence_supports_requirement, ) RUBRIC_WEIGHTS: dict[QualityCategory, int] = { QualityCategory.EVIDENCE: 25, QualityCategory.JOB_ALIGNMENT: 20, QualityCategory.COMPLETENESS: 15, QualityCategory.KOREAN_LANGUAGE: 15, QualityCategory.READABILITY: 10, QualityCategory.FORMATTING: 5, QualityCategory.CONSISTENCY: 5, QualityCategory.PRIVACY: 5, } QUALITY_POLICY_VERSION = "1.1.0" DETERMINISTIC_BLOCKING_SCORE_CAP = 59.0 DETERMINISTIC_WARNING_SCORE_CAP = 89.0 def compute_evaluation_policy_fingerprint( *, system_prompt: str | None = None, evaluator_prompt: str | None = None, ) -> str: """Fingerprint the independent judge policy used to approve a resume. Custom prompt repositories must supply their exact prompt text. Callers that omit it are bound to the package-local, wheel-distributed templates. """ prompt_root = Path(__file__).resolve().with_name("prompt_templates") if system_prompt is None: system_prompt = (prompt_root / "base-system.md").read_text( encoding="utf-8" ).strip() if evaluator_prompt is None: evaluator_prompt = (prompt_root / "evaluate-resume.md").read_text( encoding="utf-8" ).strip() payload = { "policy_version": QUALITY_POLICY_VERSION, "system_prompt": system_prompt, "evaluator_prompt": evaluator_prompt, "rubric_weights": { category.value: weight for category, weight in RUBRIC_WEIGHTS.items() }, } canonical = json.dumps( payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"), ).encode("utf-8") return hashlib.sha256(canonical).hexdigest() @dataclass(frozen=True, slots=True) class CoverageMetrics: """Coverage values that must not be supplied by the evaluating model.""" evidence: float requirements: float def compute_evaluation_fingerprint( profile: CandidateProfile, draft: ResumeDraft, posting: JobPosting, analysis: JobAnalysis, evidence_map: EvidenceMap, plan: ContentPlan, config: GenerationConfig, *, policy_fingerprint: str | None = None, ) -> str: """Bind a quality decision to every artifact that can change release gates.""" payload = { "candidate_profile": profile.model_dump(mode="json", exclude={"updated_at"}), "draft": draft.model_dump(mode="json", exclude={"generated_at"}), "posting": posting.model_dump(mode="json", exclude={"collected_at"}), "analysis": analysis.model_dump(mode="json", exclude={"analysed_at"}), "evidence_map": evidence_map.model_dump( mode="json", exclude={"generated_at"} ), "content_plan": plan.model_dump(mode="json", exclude={"created_at"}), "config": config.model_dump(mode="json"), "evaluation_policy_fingerprint": ( policy_fingerprint or compute_evaluation_policy_fingerprint() ), } canonical = json.dumps( payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"), ).encode("utf-8") return hashlib.sha256(canonical).hexdigest() def compute_weighted_overall( category_scores: dict[QualityCategory, float], ) -> float: """Compute the documented 100-point rubric from required category scores.""" missing = [ category.value for category in RUBRIC_WEIGHTS if category not in category_scores ] if missing: raise ValueError(f"missing weighted rubric categories: {missing}") weighted = sum( category_scores[category] * weight for category, weight in RUBRIC_WEIGHTS.items() ) / 100 return round(weighted, 2) def apply_deterministic_score_caps( category_scores: dict[QualityCategory, float], findings: list[QualityFinding] | tuple[QualityFinding, ...], ) -> dict[QualityCategory, float]: """Prevent subjective scores from contradicting deterministic defects.""" capped = dict(category_scores) for finding in findings: if finding.category not in RUBRIC_WEIGHTS: continue if finding.blocking: cap = DETERMINISTIC_BLOCKING_SCORE_CAP elif finding.severity is QualitySeverity.WARNING: cap = DETERMINISTIC_WARNING_SCORE_CAP else: continue if finding.category in capped: capped[finding.category] = min(capped[finding.category], cap) return capped def compute_coverage( draft: ResumeDraft, analysis: JobAnalysis, evidence_map: EvidenceMap, profile: CandidateProfile, ) -> CoverageMetrics: """Compute claim evidence and priority-weighted job-requirement coverage. Context-only posting notes are excluded. Required, preferred, and responsibility items remain in the denominator so a high judge score cannot hide an omitted job criterion. Cross-model reference validation is expected to run before this function. """ claims = [claim for section in draft.sections for claim in section.claims] evidence_coverage = ( sum(bool(claim.evidence_ids) for claim in claims) / len(claims) if claims else 0.0 ) scored_requirements = [ requirement for requirement in analysis.requirements if requirement.kind is not RequirementKind.CONTEXT ] if not scored_requirements: requirement_coverage = 1.0 else: matches = {match.requirement_id: match for match in evidence_map.matches} requirements_by_id = { requirement.requirement_id: requirement for requirement in scored_requirements } covered_ids: set[str] = set() for claim in claims: claim_evidence = set(claim.evidence_ids) for requirement_id in claim.requirement_ids: match = matches.get(requirement_id) requirement = requirements_by_id.get(requirement_id) if ( match is not None and requirement is not None and match.match_type is not EvidenceMatchType.GAP and bool(claim_evidence & set(match.evidence_ids)) and _claim_mentions_requirement(claim.text, requirement) and any( evidence_id in profile.evidence_by_id and _evidence_supports_requirement( profile.evidence_by_id[evidence_id], requirement, direct=match.match_type is EvidenceMatchType.DIRECT, ) for evidence_id in claim_evidence & set(match.evidence_ids) ) ): covered_ids.add(requirement_id) total_weight = sum(requirement.priority for requirement in scored_requirements) covered_weight = sum( requirement.priority for requirement in scored_requirements if requirement.requirement_id in covered_ids ) requirement_coverage = covered_weight / total_weight return CoverageMetrics( evidence=evidence_coverage, requirements=requirement_coverage, ) __all__ = [ "CoverageMetrics", "QUALITY_POLICY_VERSION", "RUBRIC_WEIGHTS", "apply_deterministic_score_caps", "compute_coverage", "compute_evaluation_fingerprint", "compute_evaluation_policy_fingerprint", "compute_weighted_overall", ]