1421 lines
46 KiB
Python
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
|