Files
resume-haness/tests/test_models.py
T

1421 lines
46 KiB
Python

from __future__ import annotations
from datetime import date, datetime, timezone
import pytest
from pydantic import ValidationError
from resume_harness.models import (
CandidateProfile,
ClaimKind,
ContentPlan,
ContactInfo,
ConstraintKind,
DateRange,
DraftClaim,
DraftSection,
EvidenceCategory,
EvidenceItem,
EvidenceMap,
EvidenceMatch,
EvidenceMatchType,
EvidenceSource,
GenerationConfig,
JobAnalysis,
JobPosting,
JobRequirement,
PlannedSection,
PostingConstraint,
QualityCategory,
QualityFinding,
QualityReport,
QualitySeverity,
RequirementCategory,
RequirementKind,
ResumeDate,
ResumeDraft,
ResumeMode,
SectionType,
SensitiveDataCategory,
SensitiveDataConsent,
)
UTC = timezone.utc
NOW = datetime(2026, 7, 1, 12, tzinfo=UTC)
def make_fact(evidence_id: str = "ev-1", **overrides: object) -> EvidenceItem:
values: dict[str, object] = {
"evidence_id": evidence_id,
"category": EvidenceCategory.PROJECT,
"content": "결제 API 응답 시간을 40% 단축했다.",
"source": EvidenceSource.PORTFOLIO,
"verification_status": "document_verified",
"metrics": {"latency_reduction": "40%"},
"keywords": ["Python", "API"],
"date_range": {
"start": {"year": 2024, "month": 1},
"end": {"year": 2024, "month": 6},
},
}
values.update(overrides)
return EvidenceItem.model_validate(values)
def make_profile(
*,
facts: list[EvidenceItem] | None = None,
consents: list[SensitiveDataConsent] | None = None,
) -> CandidateProfile:
return CandidateProfile(
candidate_id="candidate-1",
name="김하네스",
contact=ContactInfo(email="harness@example.com", phone="010-1234-5678"),
facts=facts or [make_fact()],
consents=consents or [],
updated_at=NOW,
)
def make_analysis(*, requirement_count: int = 1) -> JobAnalysis:
requirements = [
JobRequirement(
requirement_id=f"req-{index}",
text=f"Python 기반 서비스 개발 역량 {index}",
kind=RequirementKind.REQUIRED,
category=RequirementCategory.SKILL,
priority=5,
source_quote=f"Python 기반 서비스 개발 역량 {index}",
classification_quote=(
f"필수 요건\nPython 기반 서비스 개발 역량 {index}"
),
keywords=["Python", f"역량-{index}"],
)
for index in range(1, requirement_count + 1)
]
return JobAnalysis(
analysis_id="analysis-1",
posting_id="posting-1",
target_role="백엔드 엔지니어",
summary="검증 가능한 서비스 개발 경험을 중시한다.",
requirements=requirements,
keywords=["Python", "백엔드"],
analysed_at=NOW,
)
def make_claim(**overrides: object) -> DraftClaim:
values: dict[str, object] = {
"claim_id": "claim-1",
"text": "결제 API 응답 시간을 40% 단축",
"evidence_ids": ["ev-1"],
"requirement_ids": ["req-1"],
"order": 0,
}
values.update(overrides)
return DraftClaim.model_validate(values)
def make_section(*, claims: list[DraftClaim] | None = None) -> DraftSection:
return DraftSection(
section_id="section-projects",
section_type=SectionType.PROJECTS,
heading="주요 프로젝트",
claims=claims or [make_claim()],
order=0,
)
def test_resume_date_preserves_precision_and_formats_korean_style() -> None:
year = ResumeDate(year=2020)
month = ResumeDate(year=2020, month=3)
day = ResumeDate(year=2020, month=3, day=9)
assert year.precision == "year"
assert month.format_ko() == "2020.03"
assert day.format_ko() == "2020.03.09"
assert year.latest() == date(2020, 12, 31)
@pytest.mark.parametrize(
"payload",
[
{"year": 2024, "day": 1},
{"year": 2023, "month": 2, "day": 29},
],
)
def test_resume_date_rejects_invalid_precision_or_calendar_date(
payload: dict[str, int],
) -> None:
with pytest.raises(ValidationError):
ResumeDate.model_validate(payload)
def test_date_range_validates_order_and_ongoing_semantics() -> None:
# Year precision overlaps a December start in that same year.
valid = DateRange(
start=ResumeDate(year=2024, month=12), end=ResumeDate(year=2024)
)
assert valid.end is not None
with pytest.raises(ValidationError, match="earlier"):
DateRange(
start=ResumeDate(year=2025, month=1),
end=ResumeDate(year=2024, month=12),
)
with pytest.raises(ValidationError, match="ongoing"):
DateRange(
start=ResumeDate(year=2024),
end=ResumeDate(year=2025),
ongoing=True,
)
def test_contact_info_requires_a_valid_contact_channel() -> None:
with pytest.raises(ValidationError, match="contact channel"):
ContactInfo()
with pytest.raises(ValidationError, match="email"):
ContactInfo(email="not-an-email")
with pytest.raises(ValidationError, match="HTTP"):
ContactInfo(links=["github.com/example"])
def test_candidate_profile_requires_unique_evidence_ids() -> None:
with pytest.raises(ValidationError, match="duplicate evidence_id"):
make_profile(facts=[make_fact(), make_fact()])
def test_sensitive_evidence_requires_matching_active_consent() -> None:
consent = SensitiveDataConsent(
consent_id="consent-photo",
category=SensitiveDataCategory.PHOTO,
purpose="지원용 이력서 사진 포함",
granted_at=datetime(2026, 1, 1, tzinfo=UTC),
expires_at=datetime(2027, 1, 1, tzinfo=UTC),
)
photo = make_fact(
evidence_id="ev-photo",
category=EvidenceCategory.OTHER,
content="지원자가 제공한 증명사진",
sensitive_category=SensitiveDataCategory.PHOTO,
consent_id="consent-photo",
)
profile = make_profile(facts=[photo], consents=[consent])
assert profile.facts[0].consent_id == consent.consent_id
with pytest.raises(ValidationError, match="unknown consent"):
make_profile(facts=[photo])
wrong_category = consent.model_copy(
update={"category": SensitiveDataCategory.BIRTH_DATE}
)
with pytest.raises(ValidationError, match="category differ"):
make_profile(facts=[photo], consents=[wrong_category])
def test_expired_or_naive_consent_is_rejected() -> None:
with pytest.raises(ValidationError):
SensitiveDataConsent(
consent_id="consent-1",
category=SensitiveDataCategory.PHOTO,
purpose="이력서 사진 포함",
granted_at=datetime(2026, 1, 1),
)
expired = SensitiveDataConsent(
consent_id="consent-photo",
category=SensitiveDataCategory.PHOTO,
purpose="지원용 이력서 사진 포함",
granted_at=datetime(2025, 1, 1, tzinfo=UTC),
expires_at=datetime(2026, 1, 1, tzinfo=UTC),
)
photo = make_fact(
evidence_id="ev-photo",
sensitive_category=SensitiveDataCategory.PHOTO,
consent_id="consent-photo",
)
with pytest.raises(ValidationError, match="active consent"):
make_profile(facts=[photo], consents=[expired])
def test_prohibited_identifiers_never_enter_evidence_or_claims() -> None:
with pytest.raises(ValidationError, match="resident registration"):
make_fact(content="주민번호 900101-1234567")
with pytest.raises(ValidationError, match="never enter"):
make_fact(
sensitive_category=SensitiveDataCategory.NATIONAL_ID,
consent_id="consent-national-id",
)
with pytest.raises(ValidationError, match="resident registration"):
make_claim(text="식별번호 900101-1234567")
with pytest.raises(ValidationError, match="health"):
make_fact(content="건강 상태: 양호")
with pytest.raises(ValidationError, match="political opinion"):
make_fact(content="정치적 견해: 특정 정당 지지")
with pytest.raises(ValidationError, match="property"):
make_fact(content="재산 총액: 10억원")
def test_intake_requires_sensitive_tagging_and_keeps_contact_separate() -> None:
with pytest.raises(ValidationError, match="birth_date"):
make_fact(content="생년월일: 1990년 1월 1일")
with pytest.raises(ValidationError, match="ContactInfo"):
make_fact(content="연락처는 applicant@example.com")
with pytest.raises(ValidationError, match="bank account"):
make_fact(content="계좌번호: 123-456-789012")
# Engineering uses of the same common words are not personal data labels.
engineering = make_fact(content="사진 처리 서비스의 장애 대응을 자동화했다.")
assert engineering.sensitive_category is None
def test_contact_location_allows_region_but_rejects_detailed_address() -> None:
assert ContactInfo(email="a@example.com", city="서울특별시").city == "서울특별시"
assert ContactInfo(email="a@example.com", city="New York, NY").city == "New York, NY"
with pytest.raises(ValidationError, match="coarse"):
ContactInfo(
email="a@example.com",
city="서울특별시 강남구 테헤란로 123",
)
with pytest.raises(ValidationError, match="coarse"):
ContactInfo(email="a@example.com", city="강남구")
@pytest.mark.parametrize(
("overrides", "message"),
[
(
{"source_reference": "원본 문서 900101-1234567"},
"national ID",
),
(
{"keywords": ["Python", "담당자 010-1234-5678"]},
"contact details",
),
(
{"metrics": {"client_secret": "do-not-store"}},
"authentication secrets",
),
(
{
"source_reference": (
"https://private.example/source?token=sk-live-secret"
)
},
"secret values",
),
(
{"metrics": {"note": "Bearer abcdefghijklmnop"}},
"secret values",
),
(
{"metrics": {"authorization": "Basic dXNlcjpwYXNzd29yZA=="}},
"authentication secrets",
),
(
{"metrics": {"session_cookie": "session-value-123"}},
"authentication secrets",
),
(
{"metrics": {"jwt": "eyJheader.payload.signature"}},
"authentication secrets",
),
(
{"metrics": {"x_amz_signature": "signed-value-123"}},
"authentication secrets",
),
(
{"metrics": {"birth_date": "1990-01-01"}},
"birth_date",
),
(
{"metrics": {"gender": "남성"}},
"gender",
),
(
{"metrics": {"current_salary": "8000만원"}},
"compensation",
),
(
{"metrics": {"passport_number": "M12345678"}},
"passport",
),
(
{"keywords": ["Python", "종교: 기독교"]},
"religion",
),
],
)
def test_evidence_auxiliary_fields_reject_pii_and_secrets(
overrides: dict[str, object], message: str
) -> None:
with pytest.raises(ValidationError, match=message):
make_fact(**overrides)
def test_candidate_identity_cannot_be_echoed_inside_evidence() -> None:
fact = make_fact(content="김하네스가 결제 API를 개선했다.")
with pytest.raises(ValidationError, match="candidate identity"):
make_profile(facts=[fact])
def test_short_korean_name_does_not_match_an_ordinary_verb() -> None:
profile = CandidateProfile(
candidate_id="candidate-short-name",
name="이수",
contact=ContactInfo(email="learner@example.com"),
facts=[make_fact(content="백엔드 교육 과정을 이수했다.")],
updated_at=NOW,
)
assert profile.name == "이수"
def test_job_posting_validates_source_and_date_order() -> None:
posting = JobPosting(
posting_id="posting-1",
company_name="하네스 주식회사",
title="백엔드 엔지니어",
raw_text="Python 서비스 개발자를 채용합니다.",
source_url="https://jobs.example.com/1",
posted_on=date(2026, 7, 1),
closes_on=date(2026, 7, 31),
collected_at=NOW,
)
assert posting.posting_id == "posting-1"
with pytest.raises(ValidationError, match="closing date"):
JobPosting(
posting_id="posting-1",
company_name="하네스 주식회사",
title="백엔드 엔지니어",
raw_text="Python 서비스 개발자를 채용합니다.",
posted_on=date(2026, 7, 2),
closes_on=date(2026, 7, 1),
collected_at=NOW,
)
def test_job_analysis_requires_unique_requirements_and_keywords() -> None:
requirement = make_analysis().requirements[0]
with pytest.raises(ValidationError, match="duplicate requirement_id"):
JobAnalysis(
analysis_id="analysis-1",
posting_id="posting-1",
target_role="백엔드 엔지니어",
summary="채용 공고 분석",
requirements=[requirement, requirement],
analysed_at=NOW,
)
with pytest.raises(ValidationError, match="keywords"):
JobRequirement.model_validate(
{
**requirement.model_dump(),
"keywords": ["Python", "python"],
}
)
def test_job_analysis_requirement_must_share_meaningful_source_anchor() -> None:
posting = JobPosting(
posting_id="posting-1",
company_name="하네스 주식회사",
title="백엔드 엔지니어",
raw_text="필수 요건\nPython 10년 경력",
collected_at=NOW,
)
analysis = JobAnalysis(
analysis_id="analysis-1",
posting_id="posting-1",
target_role="백엔드 엔지니어",
summary="공고 분석",
requirements=[
JobRequirement(
requirement_id="req-hallucinated",
text="C++ 컴파일러 개발 10년 경력 필수",
kind=RequirementKind.REQUIRED,
category=RequirementCategory.EXPERIENCE,
priority=5,
source_quote="Python 10년 경력",
classification_quote="필수 요건\nPython 10년 경력",
keywords=["C++", "컴파일러"],
)
],
analysed_at=NOW,
)
with pytest.raises(ValueError, match="meaningful anchor"):
analysis.assert_matches_posting(posting)
def test_job_analysis_cannot_promote_preferred_requirement_to_required() -> None:
posting = JobPosting(
posting_id="posting-1",
company_name="하네스 주식회사",
title="백엔드 엔지니어",
raw_text="Kafka 운영 경험 우대",
collected_at=NOW,
)
with pytest.raises(ValidationError, match="classification_quote"):
JobRequirement(
requirement_id="req-kafka",
text="Kafka 운영 경험",
kind=RequirementKind.REQUIRED,
category=RequirementCategory.SKILL,
priority=5,
source_quote="Kafka 운영 경험 우대",
classification_quote="Kafka 운영 경험 우대",
keywords=["Kafka"],
)
def test_required_marker_cannot_cross_into_a_responsibility_section() -> None:
for classification_quote in (
"필수 요건\nPython 개발 경험\n주요업무\nKafka 운영 경험",
"필수 요건/Python 개발 경험/주요업무/Kafka 운영 경험",
):
with pytest.raises(ValidationError, match="same posting section"):
JobRequirement(
requirement_id="req-kafka",
text="Kafka 운영 경험",
kind=RequirementKind.REQUIRED,
category=RequirementCategory.SKILL,
source_quote="Kafka 운영 경험",
classification_quote=classification_quote,
)
requirement = JobRequirement(
requirement_id="req-kafka",
text="Kafka 운영 경험",
kind=RequirementKind.REQUIRED,
category=RequirementCategory.SKILL,
source_quote="Kafka 운영 경험",
classification_quote="필수 요건\nPython 개발 경험\nKafka 운영 경험",
)
assert requirement.kind is RequirementKind.REQUIRED
with pytest.raises(ValidationError, match="same posting section"):
JobRequirement(
requirement_id="req-reversed",
text="Kafka 운영 경험",
kind=RequirementKind.REQUIRED,
category=RequirementCategory.SKILL,
source_quote="Kafka 운영 경험",
classification_quote="Kafka 운영 경험\n필수 요건",
)
def test_short_ascii_source_quote_requires_token_boundaries() -> None:
posting = JobPosting(
posting_id="posting-1",
company_name="하네스 주식회사",
title="개발자",
raw_text="필수 요건\nCAPITAL markets 경험",
collected_at=NOW,
)
analysis = JobAnalysis(
analysis_id="analysis-1",
posting_id="posting-1",
target_role="개발자",
summary="공고 분석",
requirements=[
JobRequirement(
requirement_id="req-api",
text="API 경험",
kind=RequirementKind.REQUIRED,
category=RequirementCategory.SKILL,
source_quote="API",
classification_quote="필수 요건\nAPI",
)
],
analysed_at=NOW,
)
with pytest.raises(ValueError, match="source quotes absent"):
analysis.assert_matches_posting(posting)
def test_source_quote_cannot_anchor_unquoted_requirement_details() -> None:
posting = JobPosting(
posting_id="posting-1",
company_name="하네스 주식회사",
title="개발자",
raw_text="필수 요건\nAPI",
collected_at=NOW,
)
analysis = JobAnalysis(
analysis_id="analysis-1",
posting_id="posting-1",
target_role="개발자",
summary="공고 분석",
requirements=[
JobRequirement(
requirement_id="req-inflated",
text="API를 활용해 글로벌 결제 조직을 총괄한 경험",
kind=RequirementKind.REQUIRED,
category=RequirementCategory.EXPERIENCE,
source_quote="API",
classification_quote="필수 요건\nAPI",
)
],
analysed_at=NOW,
)
with pytest.raises(ValueError, match="meaningful anchor"):
analysis.assert_matches_posting(posting)
def test_posting_constraints_preserve_institution_specific_blind_rules() -> None:
rule = PostingConstraint(
constraint_id="blind-school",
kind=ConstraintKind.BLIND_FIELD,
description="평가 본문에 학교명을 쓰지 않는다.",
source_quote="출신학교를 유추할 수 있는 학교명 기재 금지",
fields=["학교명", "학교 이메일 도메인"],
)
analysis = make_analysis().model_copy(update={"constraints": [rule]})
assert analysis.constraints[0].fields == ["학교명", "학교 이메일 도메인"]
with pytest.raises(ValidationError, match="require fields"):
PostingConstraint(
constraint_id="blind-missing",
kind=ConstraintKind.BLIND_FIELD,
description="블라인드 규칙",
source_quote="개인정보 기재 금지",
)
with pytest.raises(ValidationError, match="section and max_characters"):
PostingConstraint(
constraint_id="limit-missing",
kind=ConstraintKind.CHARACTER_LIMIT,
description="글자 수 제한",
source_quote="경력기술서 1,000자 이내",
)
def test_typed_output_constraint_values_must_match_the_posting_quote() -> None:
posting = JobPosting(
posting_id="posting-1",
company_name="하네스 주식회사",
title="백엔드 엔지니어",
raw_text=(
"필수 요건\nPython 기반 서비스 개발 역량 1\n"
"제출 형식: PDF\n자기소개는 500자 이하"
),
collected_at=NOW,
)
base = make_analysis()
wrong_format = base.model_copy(
update={
"constraints": [
PostingConstraint(
constraint_id="format",
kind=ConstraintKind.FILE_FORMAT,
description="PDF 파일 제출",
source_quote="제출 형식: PDF",
formats=["markdown"],
)
]
}
)
with pytest.raises(ValueError, match="constraint_payloads"):
wrong_format.assert_matches_posting(posting)
wrong_limit = base.model_copy(
update={
"constraints": [
PostingConstraint(
constraint_id="limit",
kind=ConstraintKind.CHARACTER_LIMIT,
description="자기소개 글자 수 제한",
source_quote="자기소개는 500자 이하",
section="자기소개",
max_characters=99_999,
)
]
}
)
with pytest.raises(ValueError, match="constraint_payloads"):
wrong_limit.assert_matches_posting(posting)
def test_typed_constraint_value_must_belong_to_the_named_subject() -> None:
posting = JobPosting(
posting_id="posting-1",
company_name="하네스 주식회사",
title="백엔드 엔지니어",
raw_text=(
"필수 요건\nPython 기반 서비스 개발 역량 1\n"
"자기소개 500자 / 경력기술서 1,000자\n"
"이력서 PDF / 블로그 Markdown"
),
collected_at=NOW,
)
base = make_analysis()
wrong_pair = base.model_copy(
update={
"constraints": [
PostingConstraint(
constraint_id="intro-limit",
kind=ConstraintKind.CHARACTER_LIMIT,
description="자기소개 1,000자 제한",
source_quote="자기소개 500자 / 경력기술서 1,000자",
section="자기소개",
max_characters=1_000,
)
]
}
)
with pytest.raises(ValueError, match="constraint_payloads"):
wrong_pair.assert_matches_posting(posting)
wrong_target = base.model_copy(
update={
"constraints": [
PostingConstraint(
constraint_id="resume-format",
kind=ConstraintKind.FILE_FORMAT,
description="이력서 Markdown 제출",
source_quote="이력서 PDF / 블로그 Markdown",
formats=["Markdown"],
)
]
}
)
with pytest.raises(ValueError, match="constraint_payloads"):
wrong_target.assert_matches_posting(posting)
def test_explicit_blocking_submission_constraint_cannot_be_omitted() -> None:
posting = JobPosting(
posting_id="posting-1",
company_name="하네스 주식회사",
title="백엔드 엔지니어",
raw_text=(
"필수 요건\nPython 기반 서비스 개발 역량 1\n"
"제출 형식: PDF"
),
collected_at=NOW,
)
with pytest.raises(ValueError, match="omitted an explicit blocking"):
make_analysis().assert_matches_posting(posting)
unrelated = make_analysis().model_copy(
update={
"constraints": [
PostingConstraint(
constraint_id="unrelated",
kind=ConstraintKind.OTHER,
description="필수 요건 안내",
source_quote="필수 요건",
)
]
}
)
with pytest.raises(ValueError, match="omitted an explicit blocking"):
unrelated.assert_matches_posting(posting)
non_blocking_format = make_analysis().model_copy(
update={
"constraints": [
PostingConstraint(
constraint_id="optional-format",
kind=ConstraintKind.FILE_FORMAT,
description="PDF 제출 형식",
source_quote="제출 형식: PDF",
formats=["PDF"],
blocking=False,
)
]
}
)
with pytest.raises(ValueError, match="omitted an explicit blocking"):
non_blocking_format.assert_matches_posting(posting)
posting_with_two_rules = posting.model_copy(
update={"raw_text": posting.raw_text + "\n자기소개 500자"}
)
extracted_only_format = make_analysis().model_copy(
update={
"constraints": [
PostingConstraint(
constraint_id="format",
kind=ConstraintKind.FILE_FORMAT,
description="PDF 제출 형식",
source_quote="제출 형식: PDF",
formats=["PDF"],
)
]
}
)
with pytest.raises(ValueError, match="character_limit"):
extracted_only_format.assert_matches_posting(posting_with_two_rules)
complete_analysis = make_analysis().model_copy(
update={
"constraints": [
extracted_only_format.constraints[0],
PostingConstraint(
constraint_id="intro-limit",
kind=ConstraintKind.CHARACTER_LIMIT,
description="자기소개 500자 제한",
source_quote="자기소개 500자",
section="자기소개",
max_characters=500,
),
]
}
)
assert (
complete_analysis.assert_matches_posting(posting_with_two_rules)
is complete_analysis
)
two_limit_posting = posting.model_copy(
update={
"raw_text": (
"필수 요건\nPython 기반 서비스 개발 역량 1\n"
"자기소개 500자\n경력기술서 1,000자"
)
}
)
broad_quote_constraint = make_analysis().model_copy(
update={
"constraints": [
PostingConstraint(
constraint_id="broad-intro-limit",
kind=ConstraintKind.CHARACTER_LIMIT,
description="자기소개 500자 제한",
source_quote="자기소개 500자\n경력기술서 1,000자",
section="자기소개",
max_characters=500,
)
]
}
)
with pytest.raises(ValueError, match="omitted an explicit blocking"):
broad_quote_constraint.assert_matches_posting(two_limit_posting)
non_blocking = posting.model_copy(
update={
"raw_text": (
"필수 요건\nPython 기반 서비스 개발 역량 1\n"
"PDF 제출 가능"
)
}
)
assert make_analysis().assert_matches_posting(non_blocking) is not None
def test_evidence_match_distinguishes_supported_matches_and_gaps() -> None:
direct = EvidenceMatch(
requirement_id="req-1",
evidence_ids=["ev-1"],
match_type=EvidenceMatchType.DIRECT,
relevance_score=0.9,
rationale="Python API 성과가 요구 역량을 직접 입증한다.",
)
assert direct.relevance_score == pytest.approx(0.9)
gap = EvidenceMatch(
requirement_id="req-2",
match_type=EvidenceMatchType.GAP,
relevance_score=0,
gap_reason="관련 증빙이 아직 제공되지 않았다.",
)
assert gap.evidence_ids == []
with pytest.raises(ValidationError, match="require evidence"):
EvidenceMatch(
requirement_id="req-1",
match_type=EvidenceMatchType.DIRECT,
relevance_score=0.5,
rationale="근거가 누락됨",
)
with pytest.raises(ValidationError, match="gap_reason"):
EvidenceMatch(
requirement_id="req-2",
match_type=EvidenceMatchType.GAP,
relevance_score=0,
)
def test_evidence_map_checks_cross_model_references_and_full_coverage() -> None:
profile = make_profile()
analysis = make_analysis(requirement_count=2)
evidence_map = EvidenceMap(
map_id="map-1",
posting_id="posting-1",
analysis_id="analysis-1",
matches=[
EvidenceMatch(
requirement_id="req-1",
evidence_ids=["ev-1"],
match_type=EvidenceMatchType.DIRECT,
relevance_score=0.9,
rationale="프로젝트 성과가 직접 대응한다.",
),
EvidenceMatch(
requirement_id="req-2",
match_type=EvidenceMatchType.GAP,
relevance_score=0,
gap_reason="증빙 없음",
),
],
generated_at=NOW,
)
assert evidence_map.assert_referential_integrity(profile, analysis) is evidence_map
incomplete = evidence_map.model_copy(update={"matches": evidence_map.matches[:1]})
with pytest.raises(ValueError, match="without a mapping"):
incomplete.assert_referential_integrity(profile, analysis)
unknown_evidence = evidence_map.model_copy(
update={
"matches": [
evidence_map.matches[0].model_copy(
update={"evidence_ids": ["ev-missing"]}
),
evidence_map.matches[1],
]
}
)
with pytest.raises(ValueError, match="unknown evidence"):
unknown_evidence.assert_referential_integrity(profile, analysis)
def test_evidence_map_rejects_semantically_unrelated_direct_match() -> None:
profile = make_profile(
facts=[
make_fact(
content="고객 인터뷰를 수행했다.",
metrics={},
keywords=[],
)
]
)
analysis = make_analysis()
evidence_map = EvidenceMap(
map_id="map-unrelated",
posting_id="posting-1",
analysis_id="analysis-1",
matches=[
EvidenceMatch(
requirement_id="req-1",
evidence_ids=["ev-1"],
match_type=EvidenceMatchType.DIRECT,
relevance_score=1,
rationale="모델이 직접 근거라고 분류함",
)
],
generated_at=NOW,
)
with pytest.raises(ValueError, match="lacks a semantic anchor"):
evidence_map.assert_referential_integrity(profile, analysis)
def test_direct_match_requires_more_than_one_generic_shared_noun() -> None:
profile = make_profile(
facts=[
make_fact(
content="고객 명단을 정리했다.",
metrics={},
keywords=[],
)
]
)
requirement = JobRequirement(
requirement_id="req-support",
text="고객 상담 경험",
kind=RequirementKind.REQUIRED,
category=RequirementCategory.EXPERIENCE,
source_quote="고객 상담 경험",
classification_quote="필수 요건\n고객 상담 경험",
)
analysis = make_analysis().model_copy(update={"requirements": [requirement]})
evidence_map = EvidenceMap(
map_id="map-customer-list",
posting_id="posting-1",
analysis_id="analysis-1",
matches=[
EvidenceMatch(
requirement_id="req-support",
evidence_ids=["ev-1"],
match_type=EvidenceMatchType.DIRECT,
relevance_score=1,
rationale="고객 단어가 같다.",
)
],
generated_at=NOW,
)
with pytest.raises(ValueError, match="lacks a semantic anchor"):
evidence_map.assert_referential_integrity(profile, analysis)
diluted_profile = make_profile(
facts=[
make_fact(
content="Python으로 고객 명단을 정리했다.",
metrics={},
keywords=[],
)
]
)
diluted_requirement = requirement.model_copy(
update={
"text": "Python 기반 고객 상담 경험",
"source_quote": "Python 기반 고객 상담 경험",
"classification_quote": "필수 요건\nPython 기반 고객 상담 경험",
}
)
diluted_analysis = analysis.model_copy(
update={"requirements": [diluted_requirement]}
)
with pytest.raises(ValueError, match="lacks a semantic anchor"):
evidence_map.assert_referential_integrity(
diluted_profile, diluted_analysis
)
technical_profile = make_profile(
facts=[make_fact(content="Python으로 자동화했다.", metrics={}, keywords=[])]
)
technical_requirement = JobRequirement(
requirement_id="req-python",
text="Python 경험",
kind=RequirementKind.REQUIRED,
category=RequirementCategory.SKILL,
source_quote="Python 경험",
classification_quote="필수 요건\nPython 경험",
)
technical_analysis = make_analysis().model_copy(
update={"requirements": [technical_requirement]}
)
technical_map = evidence_map.model_copy(
update={
"matches": [
evidence_map.matches[0].model_copy(
update={"requirement_id": "req-python"}
)
]
}
)
assert (
technical_map.assert_referential_integrity(
technical_profile, technical_analysis
)
is technical_map
)
def test_every_draft_claim_requires_evidence() -> None:
with pytest.raises(ValidationError, match="supporting evidence"):
DraftClaim(
claim_id="claim-1",
text="대규모 시스템 전문가",
kind=ClaimKind.FACTUAL,
)
with pytest.raises(ValidationError, match="every draft claim"):
DraftClaim(
claim_id="claim-goal",
text="신뢰도 높은 금융 서비스를 만들고자 합니다.",
kind=ClaimKind.POSITIONING,
)
def test_content_plan_bounds_prompt_and_checks_references() -> None:
planned = PlannedSection(
section_id="planned-projects",
section_type=SectionType.PROJECTS,
heading="주요 프로젝트",
evidence_ids=["ev-1"],
requirement_ids=["req-1"],
bullet_budget=3,
order=0,
)
plan = ContentPlan(
plan_id="plan-1",
candidate_id="candidate-1",
posting_id="posting-1",
mode=ResumeMode.PRIVATE_MODERN,
sections=[planned],
created_at=NOW,
)
assert plan.assert_referential_integrity(make_profile(), make_analysis()) is plan
unknown = plan.model_copy(
update={
"sections": [
planned.model_copy(update={"evidence_ids": ["ev-unknown"]})
]
}
)
with pytest.raises(ValueError, match="unknown evidence"):
unknown.assert_referential_integrity(make_profile(), make_analysis())
with pytest.raises(ValidationError):
PlannedSection(
section_id="planned-projects",
section_type=SectionType.PROJECTS,
heading="주요 프로젝트",
bullet_budget=0,
order=0,
)
def test_content_plan_cannot_promote_gap_or_unmapped_evidence_pair() -> None:
evidence_map = EvidenceMap(
map_id="map-1",
posting_id="posting-1",
analysis_id="analysis-1",
matches=[
EvidenceMatch(
requirement_id="req-1",
evidence_ids=["ev-1"],
match_type=EvidenceMatchType.DIRECT,
relevance_score=0.9,
rationale="직접 근거",
),
EvidenceMatch(
requirement_id="req-2",
match_type=EvidenceMatchType.GAP,
relevance_score=0,
gap_reason="근거 없음",
),
],
generated_at=NOW,
)
gap_plan = ContentPlan(
plan_id="plan-gap",
candidate_id="candidate-1",
posting_id="posting-1",
sections=[
PlannedSection(
section_id="section-gap",
section_type=SectionType.PROJECTS,
heading="프로젝트",
evidence_ids=["ev-1"],
requirement_ids=["req-2"],
bullet_budget=1,
order=0,
)
],
created_at=NOW,
)
with pytest.raises(ValueError, match="uses gap|outside mapped"):
gap_plan.assert_matches_evidence_map(evidence_map)
def test_plan_and_draft_require_the_same_requirement_evidence_pair() -> None:
evidence_map = EvidenceMap(
map_id="map-pairs",
posting_id="posting-1",
analysis_id="analysis-1",
matches=[
EvidenceMatch(
requirement_id="req-1",
evidence_ids=["ev-1"],
match_type=EvidenceMatchType.DIRECT,
relevance_score=1,
rationale="첫 번째 근거",
),
EvidenceMatch(
requirement_id="req-2",
evidence_ids=["ev-2"],
match_type=EvidenceMatchType.DIRECT,
relevance_score=1,
rationale="두 번째 근거",
),
],
generated_at=NOW,
)
plan = ContentPlan(
plan_id="plan-pairs",
candidate_id="candidate-1",
posting_id="posting-1",
sections=[
PlannedSection(
section_id="section-projects",
section_type=SectionType.PROJECTS,
heading="프로젝트",
evidence_ids=["ev-1"],
requirement_ids=["req-1", "req-2"],
bullet_budget=1,
order=0,
)
],
created_at=NOW,
)
draft = ResumeDraft(
draft_id="draft-pairs",
candidate_id="candidate-1",
posting_id="posting-1",
title="백엔드 이력서",
sections=[
DraftSection(
section_id="section-projects",
section_type=SectionType.PROJECTS,
heading="프로젝트",
claims=[
make_claim(
text="API를 개선",
evidence_ids=["ev-1"],
requirement_ids=["req-2"],
)
],
order=0,
)
],
generated_at=NOW,
)
with pytest.raises(ValueError, match="no evidence mapped to requirement"):
plan.assert_matches_evidence_map(evidence_map)
with pytest.raises(ValueError, match="no evidence mapped to requirement"):
draft.assert_matches_evidence_map(evidence_map)
def test_public_blind_plan_rejects_military_detail_section() -> None:
with pytest.raises(ValidationError, match="public blind"):
ContentPlan(
plan_id="plan-blind",
candidate_id="candidate-1",
mode=ResumeMode.PUBLIC_BLIND,
sections=[
PlannedSection(
section_id="planned-military",
section_type=SectionType.MILITARY_SERVICE,
heading="병역",
bullet_budget=1,
order=0,
)
],
created_at=NOW,
)
def test_resume_draft_enforces_global_reference_integrity() -> None:
draft = ResumeDraft(
draft_id="draft-1",
candidate_id="candidate-1",
posting_id="posting-1",
title="백엔드 엔지니어 이력서",
mode=ResumeMode.PRIVATE_MODERN,
sections=[make_section()],
generated_at=NOW,
)
assert draft.assert_referential_integrity(make_profile(), make_analysis()) is draft
broken = draft.model_copy(
update={
"sections": [
make_section(claims=[make_claim(evidence_ids=["ev-unknown"])])
]
}
)
with pytest.raises(ValueError, match="unknown evidence"):
broken.assert_referential_integrity(make_profile(), make_analysis())
def test_resume_draft_must_stay_within_content_plan() -> None:
plan = ContentPlan(
plan_id="plan-1",
candidate_id="candidate-1",
posting_id="posting-1",
sections=[
PlannedSection(
section_id="section-projects",
section_type=SectionType.PROJECTS,
heading="주요 프로젝트",
evidence_ids=["ev-1"],
requirement_ids=["req-1"],
bullet_budget=1,
order=0,
)
],
created_at=NOW,
)
draft = ResumeDraft(
draft_id="draft-1",
candidate_id="candidate-1",
posting_id="posting-1",
title="백엔드 엔지니어 이력서",
sections=[make_section()],
generated_at=NOW,
)
assert draft.assert_matches_plan(plan) is draft
escaped = draft.model_copy(
update={
"sections": [
make_section(
claims=[make_claim(evidence_ids=["ev-unplanned"])]
)
]
}
)
with pytest.raises(ValueError, match="unplanned evidence"):
escaped.assert_matches_plan(plan)
def test_blind_draft_rejects_sensitive_claims() -> None:
sensitive_claim = make_claim(
sensitive_categories={SensitiveDataCategory.BIRTH_DATE}
)
with pytest.raises(ValidationError, match="blind"):
ResumeDraft(
draft_id="draft-blind",
candidate_id="candidate-1",
title="블라인드 이력서",
mode=ResumeMode.PUBLIC_BLIND,
sections=[make_section(claims=[sensitive_claim])],
generated_at=NOW,
)
def test_quality_report_derives_pass_state_from_gates_and_blocking_findings() -> None:
warning = QualityFinding(
finding_id="finding-1",
code="STYLE_LONG_SENTENCE",
severity=QualitySeverity.WARNING,
category=QualityCategory.READABILITY,
message="문장이 다소 깁니다.",
)
report = QualityReport(
report_id="report-1",
draft_id="draft-1",
draft_fingerprint="0" * 64,
overall_score=93,
evidence_coverage=1,
requirement_coverage=0.9,
findings=[warning],
evaluated_at=NOW,
)
assert report.passed is True
assert report.blocking_count == 0
assert report.model_dump()["passed"] is True
blocking = warning.model_copy(
update={
"finding_id": "finding-2",
"severity": QualitySeverity.ERROR,
"category": QualityCategory.EVIDENCE,
}
)
failed = report.model_copy(update={"findings": [blocking]})
assert failed.passed is False
assert failed.blocking_count == 1
def test_quality_report_validates_scores_and_unique_findings() -> None:
finding = QualityFinding(
finding_id="finding-1",
code="PRIVACY_CHECK",
severity=QualitySeverity.INFO,
category=QualityCategory.PRIVACY,
message="민감정보가 없습니다.",
)
with pytest.raises(ValidationError, match="category scores"):
QualityReport(
report_id="report-1",
draft_id="draft-1",
draft_fingerprint="0" * 64,
overall_score=90,
evidence_coverage=1,
requirement_coverage=1,
category_scores={QualityCategory.PRIVACY: 101},
evaluated_at=NOW,
)
with pytest.raises(ValidationError, match="duplicate finding_id"):
QualityReport(
report_id="report-1",
draft_id="draft-1",
draft_fingerprint="0" * 64,
overall_score=90,
evidence_coverage=1,
requirement_coverage=1,
findings=[finding, finding],
evaluated_at=NOW,
)
def test_generation_config_enforces_blind_and_photo_privacy_rules() -> None:
with pytest.raises(ValidationError, match="PHOTO"):
GenerationConfig(include_photo=True)
photo_config = GenerationConfig(
resume_mode=ResumeMode.EMPLOYER_FORM,
include_photo=True,
allowed_sensitive_categories={SensitiveDataCategory.PHOTO},
employer_required_sensitive_categories={SensitiveDataCategory.PHOTO},
as_of_date=date(2026, 7, 1),
)
with pytest.raises(ValueError, match="active consent"):
photo_config.assert_profile_compatible(make_profile())
with pytest.raises(ValidationError, match="blind mode"):
GenerationConfig(
resume_mode=ResumeMode.PUBLIC_BLIND,
allowed_sensitive_categories={SensitiveDataCategory.BIRTH_DATE},
)
with pytest.raises(ValidationError, match="never be enabled"):
GenerationConfig(
allowed_sensitive_categories={SensitiveDataCategory.NATIONAL_ID}
)
with pytest.raises(ValidationError, match="employer_form"):
GenerationConfig(
allowed_sensitive_categories={SensitiveDataCategory.BIRTH_DATE}
)
with pytest.raises(ValidationError, match="employer requirement"):
GenerationConfig(
resume_mode=ResumeMode.EMPLOYER_FORM,
allowed_sensitive_categories={SensitiveDataCategory.PHOTO},
)
def test_generation_config_accepts_only_actively_consented_sensitive_fields() -> None:
consent = SensitiveDataConsent(
consent_id="consent-photo",
category=SensitiveDataCategory.PHOTO,
purpose="지원용 이력서 사진 포함",
granted_at=datetime(2026, 1, 1, tzinfo=UTC),
expires_at=datetime(2027, 1, 1, tzinfo=UTC),
)
photo = make_fact(
evidence_id="ev-photo",
sensitive_category=SensitiveDataCategory.PHOTO,
consent_id="consent-photo",
)
profile = make_profile(facts=[photo], consents=[consent])
config = GenerationConfig(
resume_mode=ResumeMode.EMPLOYER_FORM,
include_photo=True,
allowed_sensitive_categories={SensitiveDataCategory.PHOTO},
employer_required_sensitive_categories={SensitiveDataCategory.PHOTO},
as_of_date=date(2026, 7, 1),
)
assert config.assert_profile_compatible(profile) is config
def test_models_reject_unknown_fields_and_validate_assignment() -> None:
with pytest.raises(ValidationError, match="Extra inputs"):
ResumeDate(year=2024, invented=True) # type: ignore[call-arg]
config = GenerationConfig()
with pytest.raises(ValidationError):
config.max_pages = 99