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