Files

278 lines
11 KiB
Python

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