1079 lines
34 KiB
Python
1079 lines
34 KiB
Python
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)
|