init: resume 작성 하네스 설계
This commit is contained in:
@@ -0,0 +1,277 @@
|
||||
"""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())
|
||||
Reference in New Issue
Block a user