from __future__ import annotations from datetime import date, datetime, timezone import pytest from resume_harness.models import ( CandidateProfile, ConstraintKind, ContactInfo, DraftClaim, DraftSection, EvidenceCategory, EvidenceItem, EvidenceSource, GenerationConfig, JobAnalysis, JobRequirement, PostingConstraint, QualityFinding, RequirementCategory, RequirementKind, ResumeDraft, ResumeMode, SectionType, SensitiveDataCategory, SensitiveDataConsent, ) from resume_harness.validators import validate_draft, validate_resume_draft from resume_harness.records import ( CareerRecord, EmploymentType, ExperienceRecord, ExperienceType, RecordDate, RecordPeriod, ResumeRecords, ) UTC = timezone.utc NOW = datetime(2026, 7, 1, 12, tzinfo=UTC) def make_fact( evidence_id: str = "ev-1", *, content: str = "결제 API 응답 시간을 40% 단축했다.", metrics: dict[str, str | int | float] | None = None, **overrides: object, ) -> EvidenceItem: values: dict[str, object] = { "evidence_id": evidence_id, "category": EvidenceCategory.PROJECT, "content": content, "source": EvidenceSource.PORTFOLIO, "metrics": metrics if metrics is not None else {"latency_reduction": "40%"}, } values.update(overrides) return EvidenceItem.model_validate(values) def make_profile( fact: EvidenceItem | None = None, *, facts: list[EvidenceItem] | None = None, consents: list[SensitiveDataConsent] | None = None, ) -> CandidateProfile: return CandidateProfile( candidate_id="candidate-1", name="김하네스", contact=ContactInfo(email="identity@example.com", phone="010-1111-2222"), facts=facts if facts is not None else [fact or make_fact()], consents=consents or [], updated_at=NOW, ) def make_claim( claim_id: str = "claim-1", *, text: str = "결제 API 응답 시간을 40% 단축", evidence_ids: list[str] | None = None, order: int = 0, **overrides: object, ) -> DraftClaim: values: dict[str, object] = { "claim_id": claim_id, "text": text, "evidence_ids": evidence_ids if evidence_ids is not None else ["ev-1"], "order": order, } values.update(overrides) return DraftClaim.model_validate(values) def make_draft( *, claims: list[DraftClaim] | None = None, mode: ResumeMode = ResumeMode.PRIVATE_MODERN, posting_id: str | None = None, ) -> ResumeDraft: section = DraftSection( section_id="section-projects", section_type=SectionType.PROJECTS, heading="주요 프로젝트", claims=claims or [make_claim()], order=0, ) return ResumeDraft( draft_id="draft-1", candidate_id="candidate-1", posting_id=posting_id, title="백엔드 엔지니어 이력서", mode=mode, sections=[section], generated_at=NOW, ) def inject_unvalidated_claim(claim: DraftClaim) -> ResumeDraft: """Bypass nested Pydantic revalidation to exercise the final hard gate.""" draft = make_draft() section = draft.sections[0].model_copy(update={"claims": [claim]}) return draft.model_copy(update={"sections": [section]}) def make_analysis(*, constraints: list[PostingConstraint] | None = None) -> JobAnalysis: return JobAnalysis( analysis_id="analysis-1", posting_id="posting-1", target_role="백엔드 엔지니어", summary="검증 가능한 서비스 개발 경험을 중시한다.", requirements=[ JobRequirement( requirement_id="req-1", text="Python 서비스 개발", kind=RequirementKind.REQUIRED, category=RequirementCategory.SKILL, source_quote="Python 서비스 개발", classification_quote="필수\nPython 서비스 개발", ) ], constraints=constraints or [], analysed_at=NOW, ) def finding_codes(findings: list[QualityFinding]) -> set[str]: return {finding.code for finding in findings} def test_valid_resume_has_no_findings_and_alias_matches() -> None: profile = make_profile() draft = make_draft() config = GenerationConfig(as_of_date=date(2026, 7, 1)) assert validate_resume_draft(profile, draft, config) == [] assert validate_draft(profile, draft, config) == [] def test_cross_model_reference_and_mode_failures_are_findings() -> None: claim = make_claim().model_copy(update={"evidence_ids": ["ev-missing"]}) draft = make_draft(claims=[claim]).model_copy( update={"candidate_id": "candidate-other"} ) config = GenerationConfig( resume_mode=ResumeMode.EMPLOYER_FORM, as_of_date=date(2026, 7, 1), ) codes = finding_codes(validate_resume_draft(make_profile(), draft, config)) assert { "REFERENCE.CANDIDATE_MISMATCH", "REFERENCE.UNKNOWN_EVIDENCE", "CONFIG.MODE_MISMATCH", } <= codes def test_missing_evidence_is_reported_even_for_an_invalid_model_copy() -> None: ungrounded = make_claim().model_copy(update={"evidence_ids": []}) findings = validate_resume_draft( make_profile(), inject_unvalidated_claim(ungrounded), GenerationConfig(as_of_date=date(2026, 7, 1)), ) finding = next(item for item in findings if item.code == "GROUNDING.MISSING_EVIDENCE") assert finding.claim_id == "claim-1" assert finding.blocking is True @pytest.mark.parametrize( ("text", "expected_code"), [ ("연락 이메일은 applicant@example.com", "PRIVACY.EMAIL_IN_BODY"), ("연락 전화는 010-2345-6789", "PRIVACY.PHONE_IN_BODY"), ("식별 정보 900101-1234567", "PRIVACY.RESIDENT_ID"), ], ) def test_direct_pii_in_claim_body_is_detected_without_echoing_value( text: str, expected_code: str, ) -> None: # DraftClaim itself rejects a resident ID, so model_copy simulates an LLM # response that reached the deterministic gate before schema repair. claim = make_claim().model_copy(update={"text": text}) findings = validate_resume_draft( make_profile(), inject_unvalidated_claim(claim), GenerationConfig(as_of_date=date(2026, 7, 1)), ) finding = next(item for item in findings if item.code == expected_code) assert finding.claim_id == "claim-1" assert text.split()[-1] not in finding.message def test_public_blind_detects_school_birth_age_origin_and_identity() -> None: text = ( "성명: 김하네스, 서울대학교에서 수료했으며 부산 출신으로 " "1990년생, 만 36세입니다." ) # Bypass intake checks to exercise the final defensive gate as if a # malformed object had crossed an adapter boundary. fact = make_fact().model_copy( update={"content": text, "metrics": {"age": 36, "birth_year": 1990}} ) claim = make_claim(text=text) profile = make_profile().model_copy(update={"facts": [fact]}) draft = make_draft(claims=[claim], mode=ResumeMode.PUBLIC_BLIND) config = GenerationConfig( resume_mode=ResumeMode.PUBLIC_BLIND, as_of_date=date(2026, 7, 1), ) codes = finding_codes(validate_resume_draft(profile, draft, config)) assert { "PRIVACY.BLIND_SCHOOL", "PRIVACY.BLIND_SENSITIVE_CONTENT", "PRIVACY.BLIND_AGE", "PRIVACY.BLIND_ORIGIN", "PRIVACY.BLIND_IDENTITY", } <= codes @pytest.mark.parametrize( "text", [ "고향은 대전이며 Python API를 개발했다.", "고향 대전, Python API를 개발했다.", "대전에서 자랐고 Python API를 개발했다.", "출신 지역이 부산이고 데이터 파이프라인을 운영했다.", "출신지: 제주, 백엔드 서비스를 개발했다.", ], ) def test_public_blind_detects_explicit_origin_label_variants(text: str) -> None: fact = make_fact(content=text, metrics={}) profile = make_profile(fact) draft = make_draft( claims=[make_claim(text=text)], mode=ResumeMode.PUBLIC_BLIND ) config = GenerationConfig( resume_mode=ResumeMode.PUBLIC_BLIND, as_of_date=date(2026, 7, 1), ) codes = finding_codes(validate_resume_draft(profile, draft, config)) assert "PRIVACY.BLIND_ORIGIN" in codes @pytest.mark.parametrize( "text", [ "대전 지역 고객을 위한 Python API를 개발했다.", "대전에서 Python API를 개발했다.", "고향사랑기부제 서비스를 위한 API를 개발했다.", ], ) def test_public_blind_origin_rule_preserves_job_related_regions(text: str) -> None: fact = make_fact(content=text, metrics={}) profile = make_profile(fact) draft = make_draft( claims=[make_claim(text=text)], mode=ResumeMode.PUBLIC_BLIND ) config = GenerationConfig( resume_mode=ResumeMode.PUBLIC_BLIND, as_of_date=date(2026, 7, 1), ) codes = finding_codes(validate_resume_draft(profile, draft, config)) assert "PRIVACY.BLIND_ORIGIN" not in codes @pytest.mark.parametrize( ("title", "claim_text"), [ ("김하네스 이력서", "결제 API 응답 시간을 40% 단축"), ("백엔드 엔지니어 이력서", "김하네스는 결제 API 응답 시간을 40% 단축"), ], ) def test_public_blind_detects_candidate_name_without_identity_label( title: str, claim_text: str ) -> None: draft = make_draft( claims=[make_claim(text=claim_text)], mode=ResumeMode.PUBLIC_BLIND ).model_copy(update={"title": title}) config = GenerationConfig( resume_mode=ResumeMode.PUBLIC_BLIND, as_of_date=date(2026, 7, 1), ) codes = finding_codes(validate_resume_draft(make_profile(), draft, config)) assert "PRIVACY.BLIND_IDENTITY" in codes def test_sensitive_word_patterns_avoid_engineering_false_positives() -> None: text = "B2B 사진 처리 서비스의 장애 대응 자동화와 HTTP/2 적용" fact = make_fact(content=text, metrics={}) profile = make_profile(fact) draft = make_draft(claims=[make_claim(text=text)], mode=ResumeMode.PUBLIC_BLIND) config = GenerationConfig( resume_mode=ResumeMode.PUBLIC_BLIND, as_of_date=date(2026, 7, 1), ) assert validate_resume_draft(profile, draft, config) == [] def test_structured_profile_rejects_skeletal_resume_despite_grounded_claims() -> None: career_api = make_fact( "ev-career-api", content=( "2023.03–2025.06 하네스테크 백엔드 엔지니어로 근무하며 " "Python API 오류를 35% 줄였다." ), category=EvidenceCategory.CAREER, source=EvidenceSource.EMPLOYMENT_RECORD, keywords=["Python", "API"], date_range={ "start": {"year": 2023, "month": 3}, "end": {"year": 2025, "month": 6}, }, ) career_delivery = make_fact( "ev-career-delivery", content="하네스테크 백엔드 엔지니어로 CI/CD 배포를 자동화했다.", category=EvidenceCategory.CAREER, source=EvidenceSource.EMPLOYMENT_RECORD, metrics={}, keywords=["CI/CD", "자동화"], ) project = make_fact( "ev-project", content=( "2024.08–2024.11 개인 프로젝트 개발자로 FastAPI 모니터링을 구현했다." ), metrics={}, keywords=["FastAPI", "모니터링"], date_range={ "start": {"year": 2024, "month": 8}, "end": {"year": 2024, "month": 11}, }, ) profile = make_profile( facts=[career_api, career_delivery, project] ).model_copy( update={ "records": ResumeRecords( careers=[ CareerRecord( record_id="career-1", organization="하네스테크", role="백엔드 엔지니어", period=RecordPeriod( start=RecordDate(year=2023, month=3), end=RecordDate(year=2025, month=6), ), employment_type=EmploymentType.FULL_TIME, evidence_ids=["ev-career-api", "ev-career-delivery"], ) ], experiences=[ ExperienceRecord( record_id="project-1", role="개인 프로젝트 개발자", period=RecordPeriod( start=RecordDate(year=2024, month=8), end=RecordDate(year=2024, month=11), ), experience_type=ExperienceType.PROJECT, evidence_ids=["ev-project"], ) ], ) } ) draft = ResumeDraft( draft_id="thin-draft", candidate_id="candidate-1", title="백엔드 엔지니어 이력서", sections=[ DraftSection( section_id="summary", section_type=SectionType.SUMMARY, heading="핵심 요약", claims=[ make_claim( "summary-1", text="Python API 개선, CI/CD 자동화, FastAPI 모니터링", evidence_ids=[ "ev-career-api", "ev-career-delivery", "ev-project", ], ) ], order=0, ), DraftSection( section_id="experience", section_type=SectionType.EXPERIENCE, heading="경력", claims=[ make_claim( "career-header", text="2023.03–2025.06 하네스테크 백엔드 엔지니어", evidence_ids=["ev-career-api"], order=0, ), make_claim( "career-api", text="Python API 오류를 35% 줄였다", evidence_ids=["ev-career-api"], order=1, ), make_claim( "career-delivery", text="CI/CD 배포를 자동화했다", evidence_ids=["ev-career-delivery"], order=2, ), ], order=1, ), DraftSection( section_id="projects", section_type=SectionType.PROJECTS, heading="프로젝트", claims=[ make_claim( "project-only", text=( "2024.08–2024.11 개인 프로젝트 개발자로 " "FastAPI 모니터링을 구현했다" ), evidence_ids=["ev-project"], ) ], order=2, ), ], generated_at=NOW, ) codes = finding_codes( validate_resume_draft( profile, draft, GenerationConfig(as_of_date=date(2026, 7, 1)), ) ) assert { "CONTENT.THIN_SUMMARY", "CONTENT.MISSING_COMPETENCIES_SECTION", "CONTENT.THIN_EXPERIENCE_RECORD", } <= codes def test_placeholder_and_normalised_duplicate_claim_text_are_detected() -> None: first = make_claim(text="결제 API 응답 시간을 40% 단축", order=0) duplicate = make_claim( "claim-2", text=" 결제 API 응답 시간을 40% 단축. ", order=1, ) placeholder = make_claim( "claim-3", text="[확인 필요] 프로젝트 성과", order=2, ) findings = validate_resume_draft( make_profile(), make_draft(claims=[first, duplicate, placeholder]), GenerationConfig(as_of_date=date(2026, 7, 1)), ) codes = finding_codes(findings) assert "CONTENT.PLACEHOLDER" in codes duplicate_finding = next( item for item in findings if item.code == "CONTENT.DUPLICATE_CLAIM" ) assert duplicate_finding.claim_id == "claim-2" assert duplicate_finding.blocking is False def test_number_must_exist_in_referenced_content_or_metrics() -> None: claim = make_claim(text="결제 API 응답 시간을 42% 단축") findings = validate_resume_draft( make_profile(), make_draft(claims=[claim]), GenerationConfig(as_of_date=date(2026, 7, 1)), ) finding = next( item for item in findings if item.code == "GROUNDING.UNSUPPORTED_NUMBER" ) assert finding.claim_id == "claim-1" assert "42" in finding.message def test_unsupported_technical_terms_are_blocking_grounding_findings() -> None: claim = make_claim(text="Kubernetes 클러스터와 Kafka 파이프라인을 운영") findings = validate_resume_draft( make_profile(), make_draft(claims=[claim]), GenerationConfig(as_of_date=date(2026, 7, 1)), ) finding = next( item for item in findings if item.code == "GROUNDING.UNSUPPORTED_TECH_TERM" ) assert finding.blocking is True assert "Kubernetes" in finding.message assert "Kafka" in finding.message def test_java_evidence_does_not_support_javascript_claim() -> None: fact = make_fact( content="Java로 결제 API를 개발했다.", metrics={}, keywords=["Java", "API"], ) claim = make_claim(text="JavaScript로 결제 API를 개발") findings = validate_resume_draft( make_profile(fact), make_draft(claims=[claim]), GenerationConfig(as_of_date=date(2026, 7, 1)), ) finding = next( item for item in findings if item.code == "GROUNDING.UNSUPPORTED_TECH_TERM" ) assert finding.blocking is True assert "JavaScript" in finding.message def test_metric_value_with_milliseconds_does_not_support_people_count() -> None: fact = make_fact( content="응답 지연 시간을 측정했다.", metrics={"latency_ms": 35}, ) claim = make_claim(text="35명의 조직을 운영") findings = validate_resume_draft( make_profile(fact), make_draft(claims=[claim]), GenerationConfig(as_of_date=date(2026, 7, 1)), ) assert "GROUNDING.UNSUPPORTED_NUMBER" in finding_codes(findings) def test_metric_value_supports_claim_with_matching_millisecond_unit() -> None: fact = make_fact( content="응답 지연 시간을 측정했다.", metrics={"latency_ms": 35}, ) claim = make_claim(text="응답 지연 시간 35ms를 달성") findings = validate_resume_draft( make_profile(fact), make_draft(claims=[claim]), GenerationConfig(as_of_date=date(2026, 7, 1)), ) assert "GROUNDING.UNSUPPORTED_NUMBER" not in finding_codes(findings) def test_unsupported_high_risk_korean_claim_is_blocking() -> None: fact = make_fact(content="결제 API를 개발했다.", metrics={}) claim = make_claim(text="대규모 분산 시스템 아키텍처를 총괄") findings = validate_resume_draft( make_profile(fact), make_draft(claims=[claim]), GenerationConfig(as_of_date=date(2026, 7, 1)), ) finding = next( item for item in findings if item.code == "GROUNDING.UNSUPPORTED_TECH_TERM" ) assert finding.blocking is True assert "대규모" in finding.message assert "분산 시스템" in finding.message assert "총괄" in finding.message def test_percentage_is_not_supported_by_same_non_percentage_value() -> None: fact = make_fact(content="요청 35건을 처리했다.", metrics={}) claim = make_claim(text="오류율을 35% 개선") findings = validate_resume_draft( make_profile(fact), make_draft(claims=[claim]), GenerationConfig(as_of_date=date(2026, 7, 1)), ) assert "GROUNDING.UNSUPPORTED_NUMBER" in finding_codes(findings) def test_year_month_numbers_are_supported_by_evidence_date_range() -> None: fact = make_fact( content="결제 API를 개발했다.", metrics={}, date_range={ "start": {"year": 2024, "month": 1}, "end": {"year": 2025, "month": 6}, }, ) claim = make_claim(text="2024.01~2025.06 결제 API 개발") findings = validate_resume_draft( make_profile(fact), make_draft(claims=[claim]), GenerationConfig(as_of_date=date(2026, 7, 1)), ) assert "GROUNDING.UNSUPPORTED_NUMBER" not in finding_codes(findings) def test_reversed_year_month_range_is_blocking() -> None: fact = make_fact( content="결제 API를 개발했다.", metrics={}, date_range={ "start": {"year": 2023, "month": 1}, "end": {"year": 2024, "month": 12}, }, ) claim = make_claim(text="2024.12–2023.01 결제 API 개발") findings = validate_resume_draft( make_profile(fact), make_draft(claims=[claim]), GenerationConfig(as_of_date=date(2026, 7, 1)), ) assert "CHRONOLOGY.REVERSED_RANGE" in finding_codes(findings) def test_public_blind_detects_abbreviated_school_name() -> None: text = "서울대에서 데이터베이스 과목을 이수" fact = make_fact(content=text, metrics={}) claim = make_claim(text=text) config = GenerationConfig( resume_mode=ResumeMode.PUBLIC_BLIND, as_of_date=date(2026, 7, 1), ) findings = validate_resume_draft( make_profile(fact), make_draft(claims=[claim], mode=ResumeMode.PUBLIC_BLIND), config, ) finding = next(item for item in findings if item.code == "PRIVACY.BLIND_SCHOOL") assert finding.blocking is True assert finding.claim_id == "claim-1" @pytest.mark.parametrize( "text", [ "서울대 컴퓨터공학과 졸업", "SNU 컴퓨터공학 과정 졸업", ], ) def test_public_blind_detects_school_alias_with_intervening_major( text: str, ) -> None: fact = make_fact(content=text, metrics={}) claim = make_claim(text=text) config = GenerationConfig( resume_mode=ResumeMode.PUBLIC_BLIND, as_of_date=date(2026, 7, 1), ) findings = validate_resume_draft( make_profile(fact), make_draft(claims=[claim], mode=ResumeMode.PUBLIC_BLIND), config, ) assert "PRIVACY.BLIND_SCHOOL" in finding_codes(findings) def test_public_blind_detects_spaced_candidate_name_and_birthplace_phrase() -> None: fact = make_fact( content="서울 지역 서비스에서 API를 운영했다.", metrics={} ) claims = [ make_claim(claim_id="claim-name", text="김 하네스는 API를 운영"), make_claim( claim_id="claim-origin", text="서울에서 태어나 API를 운영", order=1, ), ] config = GenerationConfig( resume_mode=ResumeMode.PUBLIC_BLIND, as_of_date=date(2026, 7, 1), ) findings = validate_resume_draft( make_profile(fact), make_draft(claims=claims, mode=ResumeMode.PUBLIC_BLIND), config, ) codes = finding_codes(findings) assert "PRIVACY.BLIND_IDENTITY" in codes assert "PRIVACY.BLIND_ORIGIN" in codes def test_claim_cannot_reference_confidential_evidence() -> None: fact = make_fact( content="비공개 고객사의 내부 결제 API를 개발했다.", metrics={}, confidential=True, ) claim = make_claim(text="내부 결제 API를 개발") findings = validate_resume_draft( make_profile(fact), make_draft(claims=[claim]), GenerationConfig(as_of_date=date(2026, 7, 1)), ) finding = next( item for item in findings if item.code == "GROUNDING.CONFIDENTIAL_EVIDENCE" ) assert finding.blocking is True assert finding.claim_id == "claim-1" assert finding.evidence_ids == ["ev-1"] def test_unrelated_korean_hallucination_has_low_lexical_support() -> None: fact = make_fact( content="Python 결제 API 응답 시간을 개선했다.", metrics={} ) claim = make_claim( text="고객 만족도를 혁신적으로 높이고 조직 문화를 획기적으로 개선했다" ) findings = validate_resume_draft( make_profile(fact), make_draft(claims=[claim]), GenerationConfig(as_of_date=date(2026, 7, 1)), ) finding = next( item for item in findings if item.code == "GROUNDING.LOW_LEXICAL_SUPPORT" ) assert finding.blocking def test_grounded_prefix_cannot_dilute_an_invented_award_clause() -> None: fact = make_fact(content="Python API를 개발했다.", metrics={}) claim = make_claim( text="Python API를 개발하고 대회 최우수상을 수상했다" ) findings = validate_resume_draft( make_profile(fact), make_draft(claims=[claim]), GenerationConfig(as_of_date=date(2026, 7, 1)), ) assert "GROUNDING.LOW_LEXICAL_SUPPORT" in finding_codes(findings) def test_single_invented_high_risk_award_term_is_blocking() -> None: fact = make_fact(content="Python API를 개발했다.", metrics={}) claim = make_claim(text="Python API 개발 수상") findings = validate_resume_draft( make_profile(fact), make_draft(claims=[claim]), GenerationConfig(as_of_date=date(2026, 7, 1)), ) assert "GROUNDING.UNSUPPORTED_TECH_TERM" in finding_codes(findings) def test_single_invented_win_term_is_blocking_in_strict_evidence_mode() -> None: fact = make_fact(content="Python API를 개발했다.", metrics={}) claim = make_claim(text="Python API 개발, 우승") findings = validate_resume_draft( make_profile(fact), make_draft(claims=[claim]), GenerationConfig(strict_evidence=True, as_of_date=date(2026, 7, 1)), ) finding = next( item for item in findings if item.code == "GROUNDING.UNSUPPORTED_TECH_TERM" ) assert finding.blocking def test_hidden_confidential_text_cannot_be_copied_under_a_safe_reference() -> None: safe = make_fact( "ev-safe", content="Python 결제 API 응답 시간을 개선했다.", metrics={}, ) confidential = make_fact( "ev-hidden", content="경쟁사 인수 계획은 다음 달 확정된다.", metrics={}, confidential=True, ) claim = make_claim( text="경쟁사 인수 계획은 다음 달 확정된다", evidence_ids=["ev-safe"], ) findings = validate_resume_draft( make_profile(facts=[safe, confidential]), make_draft(claims=[claim]), GenerationConfig(as_of_date=date(2026, 7, 1)), ) finding = next( item for item in findings if item.code == "PRIVACY.HIDDEN_EVIDENCE_ECHO" ) assert finding.blocking assert "ev-hidden" not in finding.evidence_ids def test_numeric_comparison_normalises_grouping_and_ratio_metrics() -> None: fact = make_fact( content="대량 요청 처리와 오류율 개선을 수행했다.", metrics={"request_count": "1,200", "error_rate": 0.4}, ) claim = make_claim(text="요청 1200건을 처리하고 오류율을 40% 개선") findings = validate_resume_draft( make_profile(fact), make_draft(claims=[claim]), GenerationConfig(as_of_date=date(2026, 7, 1)), ) assert "GROUNDING.UNSUPPORTED_NUMBER" not in finding_codes(findings) def test_allowed_employer_form_sensitive_claim_requires_evidence_and_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( content="채용사 지정 양식용 증명사진", metrics={}, sensitive_category=SensitiveDataCategory.PHOTO, consent_id="consent-photo", ) profile = make_profile(photo, consents=[consent]) claim = make_claim( text="채용사 지정 양식용 증명사진", sensitive_categories={SensitiveDataCategory.PHOTO}, ) draft = make_draft(claims=[claim], mode=ResumeMode.EMPLOYER_FORM) 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 validate_resume_draft(profile, draft, config) == [] missing_consent_config = config.model_copy( update={"allowed_sensitive_categories": {SensitiveDataCategory.BIRTH_DATE}} ) codes = finding_codes( validate_resume_draft(profile, draft, missing_consent_config) ) assert "CONFIG.SENSITIVE_CONSENT" in codes def test_job_specific_blind_constraint_uses_declared_fields() -> None: constraint = PostingConstraint( constraint_id="blind-employer", kind=ConstraintKind.BLIND_FIELD, description="평가 본문의 근무기관을 비식별화한다.", source_quote="회사명 및 근무기관명 기재 금지", fields=["회사명", "근무기관명"], ) analysis = make_analysis(constraints=[constraint]) fact = make_fact(content="회사명: 하네스 주식회사에서 API를 개발했다.") claim = make_claim( text="회사명: 하네스 주식회사에서 API를 개발", requirement_ids=["req-1"], ) draft = make_draft(claims=[claim], posting_id="posting-1") findings = validate_resume_draft( make_profile(fact), draft, GenerationConfig(as_of_date=date(2026, 7, 1)), analysis=analysis, ) finding = next( item for item in findings if item.code == "PRIVACY.POSTING_FIELD_LEAK" ) assert finding.claim_id == "claim-1" assert finding.blocking is True def test_job_specific_employer_rule_detects_unlabelled_company_name() -> None: constraint = PostingConstraint( constraint_id="blind-employer-natural", kind=ConstraintKind.BLIND_FIELD, description="평가 본문의 회사명을 비식별화한다.", source_quote="회사명 기재 금지", fields=["회사명"], ) analysis = make_analysis(constraints=[constraint]) fact = make_fact(content="가상페이에서 Python API를 개발했다.") claim = make_claim( text="가상페이에서 Python API를 개발", requirement_ids=["req-1"], ) findings = validate_resume_draft( make_profile(fact), make_draft(claims=[claim], posting_id="posting-1"), GenerationConfig(as_of_date=date(2026, 7, 1)), analysis=analysis, ) assert "PRIVACY.POSTING_FIELD_LEAK" in finding_codes(findings) def test_job_specific_employer_rule_detects_unknown_company_with_career_records() -> None: constraint = PostingConstraint( constraint_id="blind-employer-with-records", kind=ConstraintKind.BLIND_FIELD, description="평가 본문의 회사명을 비식별화한다.", source_quote="회사명 기재 금지", fields=["회사명"], ) analysis = make_analysis(constraints=[constraint]) career_fact = make_fact( "ev-career", content="2024년부터 기록회사 백엔드 엔지니어로 근무했다.", metrics={}, category=EvidenceCategory.CAREER, source=EvidenceSource.EMPLOYMENT_RECORD, date_range={"start": {"year": 2024}, "ongoing": True}, ) project_fact = make_fact( content="가상페이에서 Python API를 개발했다.", metrics={} ) profile = make_profile(facts=[career_fact, project_fact]).model_copy( update={ "records": ResumeRecords( careers=[ CareerRecord( record_id="career-1", organization="기록회사", role="백엔드 엔지니어", period=RecordPeriod( start=RecordDate(year=2024), ongoing=True ), employment_type=EmploymentType.FULL_TIME, evidence_ids=["ev-career"], ) ] ) } ) claim = make_claim( text="가상페이에서 Python API를 개발", requirement_ids=["req-1"], ) findings = validate_resume_draft( profile, make_draft(claims=[claim], posting_id="posting-1"), GenerationConfig(as_of_date=date(2026, 7, 1)), analysis=analysis, ) assert "PRIVACY.POSTING_FIELD_LEAK" in finding_codes(findings) @pytest.mark.parametrize( "text", [ "프로젝트에서 Python API를 개발했다.", "대전에서 Python API를 개발했다.", "Python에서 비동기 API를 개발했다.", "데이터베이스에서 쿼리 병목을 제거했다.", ], ) def test_job_specific_employer_rule_preserves_non_company_contexts( text: str, ) -> None: constraint = PostingConstraint( constraint_id="blind-employer-context", kind=ConstraintKind.BLIND_FIELD, description="평가 본문의 회사명을 비식별화한다.", source_quote="회사명 기재 금지", fields=["회사명"], ) fact = make_fact(content=text, metrics={}) findings = validate_resume_draft( make_profile(fact), make_draft(claims=[make_claim(text=text)], posting_id="posting-1"), GenerationConfig(as_of_date=date(2026, 7, 1)), analysis=make_analysis(constraints=[constraint]), ) assert "PRIVACY.POSTING_FIELD_LEAK" not in finding_codes(findings) def test_analysis_references_and_finding_order_are_deterministic() -> None: claim = make_claim(requirement_ids=["req-missing"]) draft = make_draft(claims=[claim], posting_id="posting-other") analysis = make_analysis() config = GenerationConfig(as_of_date=date(2026, 7, 1)) first = validate_resume_draft( make_profile(), draft, config, analysis=analysis ) second = validate_resume_draft( make_profile(), draft, config, analysis=analysis ) assert [item.model_dump() for item in first] == [ item.model_dump() for item in second ] assert { "REFERENCE.POSTING_MISMATCH", "REFERENCE.UNKNOWN_REQUIREMENT", } <= finding_codes(first)