278 lines
11 KiB
Python
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())
|