"""Dependency-light command line entry point for contract validation.""" from __future__ import annotations import argparse import json import sys from pathlib import Path from typing import Sequence from pydantic import BaseModel, ValidationError from .io import InputError, load_model from .models import ( CandidateProfile, ContentPlan, EvidenceMap, GenerationConfig, JobAnalysis, JobPosting, OutputMode, QualityCategory, QualityReport, ResumeDraft, ) from .output_constraints import as_quality_findings, validate_output_constraints from .quality import ( apply_deterministic_score_caps, compute_coverage, compute_evaluation_fingerprint, compute_weighted_overall, ) SCHEMA_MODELS: dict[str, type[BaseModel]] = { "candidate": CandidateProfile, "job": JobPosting, "job-analysis": JobAnalysis, "evidence-map": EvidenceMap, "content-plan": ContentPlan, "draft": ResumeDraft, "quality-report": QualityReport, "config": GenerationConfig, } def _build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="resume-harness", description="근거 기반 한국식 이력서 하네스", ) subparsers = parser.add_subparsers(dest="command", required=True) validate = subparsers.add_parser("validate", help="입력 계약과 참조를 검증합니다") validate.add_argument("--candidate", required=True, type=Path) validate.add_argument("--job", required=True, type=Path) validate.add_argument("--config", required=True, type=Path) validate.add_argument("--analysis", type=Path) validate.add_argument("--evidence-map", dest="evidence_map", type=Path) validate.add_argument("--content-plan", dest="content_plan", type=Path) validate.add_argument("--draft", type=Path) render = subparsers.add_parser( "render", help="승인된 정본을 ATS 친화 Markdown으로 렌더링합니다" ) render.add_argument("--candidate", required=True, type=Path) render.add_argument("--job", required=True, type=Path) render.add_argument("--draft", required=True, type=Path) render.add_argument("--config", required=True, type=Path) render.add_argument("--analysis", required=True, type=Path) render.add_argument("--evidence-map", dest="evidence_map", required=True, type=Path) render.add_argument("--content-plan", dest="content_plan", required=True, type=Path) render.add_argument("--quality-report", required=True, type=Path) render.add_argument("--output", type=Path) schema = subparsers.add_parser("schema", help="JSON Schema를 출력합니다") schema.add_argument("model", choices=sorted(SCHEMA_MODELS)) schema.add_argument("--output", type=Path) return parser def _validate(args: argparse.Namespace) -> int: profile = load_model(args.candidate, CandidateProfile) posting = load_model(args.job, JobPosting) config = load_model(args.config, GenerationConfig) config.assert_profile_compatible(profile) analysis = load_model(args.analysis, JobAnalysis) if args.analysis else None if analysis is not None: analysis.assert_matches_posting(posting) evidence_map = ( load_model(args.evidence_map, EvidenceMap) if args.evidence_map else None ) if evidence_map is not None: if analysis is None: raise InputError("--evidence-map 검증에는 --analysis가 필요합니다.") evidence_map.assert_referential_integrity(profile, analysis) content_plan = ( load_model(args.content_plan, ContentPlan) if args.content_plan else None ) if content_plan is not None: content_plan.assert_referential_integrity(profile, analysis) if evidence_map is not None: content_plan.assert_matches_evidence_map(evidence_map) if content_plan.mode != config.resume_mode: raise InputError("콘텐츠 계획과 생성 설정의 resume_mode가 다릅니다.") draft = load_model(args.draft, ResumeDraft) if args.draft else None findings = [] if draft is not None: draft.assert_referential_integrity(profile, analysis) if content_plan is not None: draft.assert_matches_plan(content_plan) if evidence_map is not None: draft.assert_matches_evidence_map(evidence_map) from .validators import validate_resume_draft findings = validate_resume_draft( profile, draft, config, analysis=analysis ) findings.extend( as_quality_findings( validate_output_constraints( draft, config, analysis=analysis, ) ) ) blocking = [finding for finding in findings if finding.blocking] result = { "status": "invalid" if blocking else "valid", "candidate_id": profile.candidate_id, "posting_id": posting.posting_id, "resume_mode": config.resume_mode.value, "evidence_count": len(profile.facts), "requirement_count": len(analysis.requirements) if analysis else None, "finding_count": len(findings), "blocking_count": len(blocking), "findings": [finding.model_dump(mode="json") for finding in findings], } print(json.dumps(result, ensure_ascii=False, indent=2)) return 2 if blocking else 0 def _schema(args: argparse.Namespace) -> int: schema = SCHEMA_MODELS[args.model].model_json_schema() rendered = json.dumps(schema, ensure_ascii=False, indent=2) if args.output: args.output.write_text(rendered + "\n", encoding="utf-8") else: print(rendered) return 0 def _render(args: argparse.Namespace) -> int: profile = load_model(args.candidate, CandidateProfile) posting = load_model(args.job, JobPosting) draft = load_model(args.draft, ResumeDraft) config = load_model(args.config, GenerationConfig) analysis = load_model(args.analysis, JobAnalysis) evidence_map = load_model(args.evidence_map, EvidenceMap) content_plan = load_model(args.content_plan, ContentPlan) report = load_model(args.quality_report, QualityReport) if config.output_mode is not OutputMode.MARKDOWN: raise InputError("현재 구현된 렌더 출력은 markdown뿐입니다.") if not config.strict_evidence: raise InputError("최종 렌더에는 strict_evidence=true가 필요합니다.") analysis.assert_matches_posting(posting) evidence_map.assert_referential_integrity(profile, analysis) content_plan.assert_referential_integrity(profile, analysis) content_plan.assert_matches_evidence_map(evidence_map) draft.assert_referential_integrity(profile, analysis) draft.assert_matches_plan(content_plan) draft.assert_matches_evidence_map(evidence_map) # The quality report is a release artifact; rendering still repeats the # local hard gates but does not require the content plan at this boundary. config.assert_profile_compatible(profile) if report.draft_id != draft.draft_id: raise InputError("품질 보고서가 다른 초안을 참조합니다.") if report.draft_fingerprint != draft.fingerprint(): raise InputError("품질 보고서가 현재 초안 내용과 일치하지 않습니다.") expected_evaluation_fingerprint = compute_evaluation_fingerprint( profile, draft, posting, analysis, evidence_map, content_plan, config, ) if report.evaluation_fingerprint != expected_evaluation_fingerprint: raise InputError("품질 보고서가 현재 평가 컨텍스트와 일치하지 않습니다.") coverage = compute_coverage(draft, analysis, evidence_map, profile) if abs(report.evidence_coverage - coverage.evidence) > 1e-9: raise InputError("품질 보고서의 근거 연결률이 현재 초안과 일치하지 않습니다.") if abs(report.requirement_coverage - coverage.requirements) > 1e-9: raise InputError("품질 보고서의 직무 요건 커버리지가 현재 초안과 일치하지 않습니다.") try: weighted_overall = compute_weighted_overall(report.category_scores) except ValueError as exc: raise InputError("품질 보고서에 가중 루브릭 영역이 누락되었습니다.") from exc if abs(report.overall_score - weighted_overall) > 1e-9: raise InputError("품질 보고서의 전체 점수가 가중 루브릭과 일치하지 않습니다.") from .validators import validate_resume_draft deterministic = validate_resume_draft( profile, draft, config, analysis=analysis ) capped_category_scores = apply_deterministic_score_caps( report.category_scores, deterministic ) if capped_category_scores != report.category_scores: raise InputError( "품질 보고서의 영역 점수가 결정적 결함에 허용되는 상한을 초과합니다." ) if any(finding.blocking for finding in deterministic): raise InputError("결정적 품질 검사에 blocking finding이 남아 있습니다.") if any(finding.blocking for finding in report.findings): raise InputError("품질 보고서에 blocking finding이 남아 있습니다.") if report.overall_score < max(90, config.minimum_quality_score): raise InputError("전체 품질 점수가 릴리스 기준보다 낮습니다.") if report.evidence_coverage < max(1.0, config.minimum_evidence_coverage): raise InputError("근거 연결률이 100%가 아닙니다.") if report.requirement_coverage < max(0.8, config.minimum_requirement_coverage): raise InputError("직무 요건 커버리지가 릴리스 기준보다 낮습니다.") major_categories = ( QualityCategory.EVIDENCE, QualityCategory.JOB_ALIGNMENT, QualityCategory.KOREAN_LANGUAGE, QualityCategory.PRIVACY, ) missing_or_low = [ category.value for category in major_categories if report.category_scores.get(category, -1) < 80 ] if missing_or_low: raise InputError( "주요 품질 영역이 누락되었거나 80점 미만입니다: " + ", ".join(missing_or_low) ) from .renderer import render_markdown rendered = render_markdown(draft, profile, config, analysis=analysis) if args.output: args.output.write_text(rendered, encoding="utf-8") else: print(rendered, end="") return 0 def main(argv: Sequence[str] | None = None) -> int: args = _build_parser().parse_args(argv) try: if args.command == "validate": return _validate(args) if args.command == "schema": return _schema(args) if args.command == "render": return _render(args) except (InputError, ValidationError, ValueError) as exc: print(f"검증 실패: {exc}", file=sys.stderr) return 2 return 2 if __name__ == "__main__": raise SystemExit(main())