init: resume 작성 하네스 설계
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, timezone
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from resume_harness.models import (
|
||||
CandidateProfile,
|
||||
ContactInfo,
|
||||
DraftClaim,
|
||||
DraftSection,
|
||||
EvidenceCategory,
|
||||
EvidenceItem,
|
||||
EvidenceSource,
|
||||
GenerationConfig,
|
||||
OutputMode,
|
||||
ResumeDraft,
|
||||
ResumeMode,
|
||||
SectionType,
|
||||
)
|
||||
from resume_harness.renderer import MarkdownRenderer, render_markdown
|
||||
|
||||
|
||||
NOW = datetime(2026, 7, 1, 12, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _profile() -> CandidateProfile:
|
||||
facts = [
|
||||
EvidenceItem(
|
||||
evidence_id=evidence_id,
|
||||
category=EvidenceCategory.PROJECT,
|
||||
content=content,
|
||||
source=EvidenceSource.PORTFOLIO,
|
||||
)
|
||||
for evidence_id, content in (
|
||||
("ev-latency", "결제 API 응답 시간을 단축했다."),
|
||||
("ev-tests", "회귀 테스트를 자동화했다."),
|
||||
("ev-python", "Python 서비스를 개발했다."),
|
||||
)
|
||||
]
|
||||
return CandidateProfile(
|
||||
candidate_id="candidate-1",
|
||||
name="김하네스",
|
||||
name_en="Harness Kim",
|
||||
contact=ContactInfo(
|
||||
email="harness@example.com",
|
||||
phone="010-1234-5678",
|
||||
city="서울",
|
||||
links=["https://example.com/portfolio"],
|
||||
),
|
||||
facts=facts,
|
||||
updated_at=NOW,
|
||||
)
|
||||
|
||||
|
||||
def _claim(
|
||||
claim_id: str, text: str, evidence_id: str, order: int
|
||||
) -> DraftClaim:
|
||||
return DraftClaim(
|
||||
claim_id=claim_id,
|
||||
text=text,
|
||||
evidence_ids=[evidence_id],
|
||||
order=order,
|
||||
)
|
||||
|
||||
|
||||
def _draft(*, mode: ResumeMode = ResumeMode.PRIVATE_MODERN) -> ResumeDraft:
|
||||
experience = DraftSection(
|
||||
section_id="section-experience",
|
||||
section_type=SectionType.EXPERIENCE,
|
||||
heading="경력",
|
||||
order=0,
|
||||
claims=[
|
||||
_claim("claim-tests", "회귀 테스트 자동화", "ev-tests", 1),
|
||||
_claim("claim-latency", "결제 API 응답 시간 단축", "ev-latency", 0),
|
||||
],
|
||||
)
|
||||
skills = DraftSection(
|
||||
section_id="section-skills",
|
||||
section_type=SectionType.SKILLS,
|
||||
heading="기술",
|
||||
order=1,
|
||||
claims=[_claim("claim-python", "Python 서비스 개발", "ev-python", 0)],
|
||||
)
|
||||
return ResumeDraft(
|
||||
draft_id="draft-1",
|
||||
candidate_id="candidate-1",
|
||||
title="백엔드 엔지니어",
|
||||
mode=mode,
|
||||
# Input order is intentionally different from canonical ``order``.
|
||||
sections=[skills, experience],
|
||||
generated_at=NOW,
|
||||
)
|
||||
|
||||
|
||||
def _config(*, mode: ResumeMode = ResumeMode.PRIVATE_MODERN) -> GenerationConfig:
|
||||
return GenerationConfig(resume_mode=mode, as_of_date=date(2026, 7, 1))
|
||||
|
||||
|
||||
def test_private_markdown_renders_identity_first_and_uses_stable_order() -> None:
|
||||
output = render_markdown(_draft(), _profile(), _config())
|
||||
|
||||
assert output.startswith(
|
||||
"# 김하네스\n"
|
||||
"영문명: Harness Kim\n"
|
||||
"지원 분야: 백엔드 엔지니어\n"
|
||||
"이메일: harness@example.com\n"
|
||||
)
|
||||
assert output.index("## 경력") < output.index("## 기술")
|
||||
assert output.index("결제 API 응답 시간 단축") < output.index(
|
||||
"회귀 테스트 자동화"
|
||||
)
|
||||
assert re.search(r"^\|", output, re.MULTILINE) is None
|
||||
assert "<table" not in output.casefold()
|
||||
assert output.endswith("\n")
|
||||
|
||||
|
||||
def test_public_blind_omits_the_complete_identity_and_contact_block() -> None:
|
||||
mode = ResumeMode.PUBLIC_BLIND
|
||||
output = render_markdown(
|
||||
_draft(mode=mode), _profile(), _config(mode=mode)
|
||||
)
|
||||
|
||||
assert output.startswith("# 백엔드 엔지니어\n")
|
||||
for private_value in (
|
||||
"김하네스",
|
||||
"Harness Kim",
|
||||
"harness@example.com",
|
||||
"010-1234-5678",
|
||||
"서울",
|
||||
"https://example.com/portfolio",
|
||||
):
|
||||
assert private_value not in output
|
||||
|
||||
|
||||
def test_public_blind_renderer_fails_closed_on_origin_disclosure() -> None:
|
||||
mode = ResumeMode.PUBLIC_BLIND
|
||||
draft = _draft(mode=mode)
|
||||
origin_claim = next(
|
||||
claim
|
||||
for section in draft.sections
|
||||
for claim in section.claims
|
||||
if claim.evidence_ids == ["ev-python"]
|
||||
)
|
||||
origin_claim.text = "고향은 대전이며 Python 서비스를 개발했다."
|
||||
|
||||
with pytest.raises(ValueError, match="PRIVACY.BLIND_ORIGIN"):
|
||||
render_markdown(draft, _profile(), _config(mode=mode))
|
||||
|
||||
|
||||
def test_evidence_ids_are_hidden_by_default_and_available_only_for_debug() -> None:
|
||||
draft = _draft()
|
||||
profile = _profile()
|
||||
config = _config()
|
||||
|
||||
normal = MarkdownRenderer().render(draft, profile, config)
|
||||
debug = MarkdownRenderer(debug_evidence_ids=True).render(
|
||||
draft, profile, config
|
||||
)
|
||||
alias = render_markdown(
|
||||
draft, profile, config, include_evidence_ids=True
|
||||
)
|
||||
|
||||
assert "ev-latency" not in normal
|
||||
assert "[근거 ID: ev-latency]" in debug
|
||||
assert debug == alias
|
||||
|
||||
|
||||
def test_embedded_line_breaks_cannot_create_markdown_blocks() -> None:
|
||||
draft = _draft()
|
||||
draft.sections[0].claims[0].text = "Python 개발\r\n## 위조 섹션 | <table>"
|
||||
|
||||
output = render_markdown(draft, _profile(), _config())
|
||||
|
||||
assert "Python 개발 ## 위조 섹션 | <table>" in output
|
||||
assert "\n## 위조 섹션" not in output
|
||||
assert "<table>" not in output
|
||||
assert "|" not in output
|
||||
|
||||
|
||||
def test_claim_text_cannot_inject_links_images_or_inline_code() -> None:
|
||||
draft = _draft()
|
||||
draft.sections[0].claims[0].text = " `숨은 코드` **과장**"
|
||||
|
||||
output = render_markdown(draft, _profile(), _config())
|
||||
|
||||
assert " \`숨은 코드\` \*\*과장\*\*" in output
|
||||
|
||||
|
||||
def test_renderer_rejects_non_markdown_output_mode() -> None:
|
||||
config = GenerationConfig(
|
||||
output_mode=OutputMode.JSON,
|
||||
resume_mode=ResumeMode.PRIVATE_MODERN,
|
||||
as_of_date=date(2026, 7, 1),
|
||||
)
|
||||
with pytest.raises(ValueError, match="output_mode"):
|
||||
render_markdown(_draft(), _profile(), config)
|
||||
|
||||
|
||||
def test_markdown_renderer_does_not_silently_drop_required_photo() -> None:
|
||||
from resume_harness.models import SensitiveDataCategory
|
||||
|
||||
config = GenerationConfig(
|
||||
output_mode=OutputMode.MARKDOWN,
|
||||
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),
|
||||
)
|
||||
draft = _draft(mode=ResumeMode.EMPLOYER_FORM)
|
||||
|
||||
with pytest.raises(ValueError, match="cannot embed a photo"):
|
||||
render_markdown(draft, _profile(), config)
|
||||
|
||||
|
||||
def test_renderer_checks_evidence_references_before_output() -> None:
|
||||
draft = _draft()
|
||||
draft.sections[0].claims[0].evidence_ids = ["ev-unknown"]
|
||||
|
||||
with pytest.raises(ValueError, match="unknown evidence"):
|
||||
render_markdown(draft, _profile(), _config())
|
||||
|
||||
|
||||
def test_renderer_blocks_claims_backed_by_confidential_evidence() -> None:
|
||||
profile = _profile()
|
||||
profile = profile.model_copy(
|
||||
update={
|
||||
"facts": [
|
||||
fact.model_copy(update={"confidential": fact.evidence_id == "ev-python"})
|
||||
for fact in profile.facts
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="confidential"):
|
||||
render_markdown(_draft(), profile, _config())
|
||||
Reference in New Issue
Block a user