Files

1079 lines
34 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.032025.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.082024.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.032025.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.082024.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.122023.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)