init: resume 작성 하네스 설계

This commit is contained in:
DongHyeonka
2026-07-24 13:55:11 +09:00
parent eb1141a767
commit f22cf5aff2
113 changed files with 21992 additions and 1 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+222
View File
@@ -0,0 +1,222 @@
from __future__ import annotations
import json
from pathlib import Path
import pytest
from resume_harness.cli import main
from resume_harness.io import InputError, load_mapping
ROOT = Path(__file__).resolve().parents[1]
def test_validate_examples_without_exposing_contact(capsys: pytest.CaptureFixture[str]) -> None:
exit_code = main(
[
"validate",
"--candidate",
str(ROOT / "examples/candidate.sample.yaml"),
"--job",
str(ROOT / "examples/job.sample.yaml"),
"--config",
str(ROOT / "examples/config.sample.yaml"),
]
)
captured = capsys.readouterr()
payload = json.loads(captured.out)
assert exit_code == 0
assert payload["status"] == "valid"
assert payload["evidence_count"] == 8
assert "haneul.kim@example.com" not in captured.out
assert "010-1234-5678" not in captured.out
def test_schema_command_emits_json_schema(capsys: pytest.CaptureFixture[str]) -> None:
assert main(["schema", "candidate"]) == 0
payload = json.loads(capsys.readouterr().out)
assert payload["title"] == "CandidateProfile"
def test_render_emits_only_quality_gated_markdown(
capsys: pytest.CaptureFixture[str],
) -> None:
exit_code = main(
[
"render",
"--candidate",
str(ROOT / "examples/candidate.sample.yaml"),
"--job",
str(ROOT / "examples/job.sample.yaml"),
"--draft",
str(ROOT / "examples/draft.sample.yaml"),
"--config",
str(ROOT / "examples/config.sample.yaml"),
"--analysis",
str(ROOT / "examples/job-analysis.sample.yaml"),
"--evidence-map",
str(ROOT / "examples/evidence-map.sample.yaml"),
"--content-plan",
str(ROOT / "examples/content-plan.sample.yaml"),
"--quality-report",
str(ROOT / "examples/quality-report.sample.yaml"),
]
)
output = capsys.readouterr().out
assert exit_code == 0
assert output.startswith("# 김하늘\n")
assert "## 경력" in output
assert "근거 ID" not in output
def test_render_rejects_stale_quality_report_after_draft_changes(
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
draft = load_mapping(ROOT / "examples/draft.sample.yaml")
draft["sections"][0]["claims"][0]["text"] += " 변경"
changed_draft = tmp_path / "changed-draft.json"
changed_draft.write_text(
json.dumps(
draft,
ensure_ascii=False,
default=lambda value: value.isoformat(),
),
encoding="utf-8",
)
exit_code = main(
[
"render",
"--candidate",
str(ROOT / "examples/candidate.sample.yaml"),
"--job",
str(ROOT / "examples/job.sample.yaml"),
"--draft",
str(changed_draft),
"--config",
str(ROOT / "examples/config.sample.yaml"),
"--analysis",
str(ROOT / "examples/job-analysis.sample.yaml"),
"--evidence-map",
str(ROOT / "examples/evidence-map.sample.yaml"),
"--content-plan",
str(ROOT / "examples/content-plan.sample.yaml"),
"--quality-report",
str(ROOT / "examples/quality-report.sample.yaml"),
]
)
captured = capsys.readouterr()
assert exit_code == 2
assert "현재 초안 내용과 일치하지 않습니다" in captured.err
assert captured.out == ""
def test_render_rejects_quality_report_when_analysis_context_changes(
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
analysis = load_mapping(ROOT / "examples/job-analysis.sample.yaml")
analysis["summary"] = "내용은 같아 보여도 다른 평가 컨텍스트"
changed_analysis = tmp_path / "changed-analysis.json"
# YAML timestamps are loaded as datetime objects; serialise them exactly as
# the CLI's JSON input contract expects.
changed_analysis.write_text(
json.dumps(
analysis,
ensure_ascii=False,
default=lambda value: value.isoformat(),
),
encoding="utf-8",
)
exit_code = main(
[
"render",
"--candidate",
str(ROOT / "examples/candidate.sample.yaml"),
"--job",
str(ROOT / "examples/job.sample.yaml"),
"--draft",
str(ROOT / "examples/draft.sample.yaml"),
"--config",
str(ROOT / "examples/config.sample.yaml"),
"--analysis",
str(changed_analysis),
"--evidence-map",
str(ROOT / "examples/evidence-map.sample.yaml"),
"--content-plan",
str(ROOT / "examples/content-plan.sample.yaml"),
"--quality-report",
str(ROOT / "examples/quality-report.sample.yaml"),
]
)
captured = capsys.readouterr()
assert exit_code == 2
assert "평가 컨텍스트와 일치하지 않습니다" in captured.err
assert captured.out == ""
def test_render_rejects_quality_report_when_candidate_context_changes(
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
candidate = load_mapping(ROOT / "examples/candidate.sample.yaml")
candidate["headline"] = "평가 이후에 변경된 프로필 정보"
changed_candidate = tmp_path / "changed-candidate.json"
changed_candidate.write_text(
json.dumps(
candidate,
ensure_ascii=False,
default=lambda value: value.isoformat(),
),
encoding="utf-8",
)
exit_code = main(
[
"render",
"--candidate",
str(changed_candidate),
"--job",
str(ROOT / "examples/job.sample.yaml"),
"--draft",
str(ROOT / "examples/draft.sample.yaml"),
"--config",
str(ROOT / "examples/config.sample.yaml"),
"--analysis",
str(ROOT / "examples/job-analysis.sample.yaml"),
"--evidence-map",
str(ROOT / "examples/evidence-map.sample.yaml"),
"--content-plan",
str(ROOT / "examples/content-plan.sample.yaml"),
"--quality-report",
str(ROOT / "examples/quality-report.sample.yaml"),
]
)
captured = capsys.readouterr()
assert exit_code == 2
assert "평가 컨텍스트와 일치하지 않습니다" in captured.err
assert captured.out == ""
def test_load_mapping_rejects_non_mapping(tmp_path: Path) -> None:
path = tmp_path / "input.yaml"
path.write_text("- item\n", encoding="utf-8")
with pytest.raises(InputError, match="mapping"):
load_mapping(path)
def test_load_mapping_rejects_unknown_extension(tmp_path: Path) -> None:
path = tmp_path / "input.txt"
path.write_text("value: 1\n", encoding="utf-8")
with pytest.raises(InputError, match="지원 형식"):
load_mapping(path)
+78
View File
@@ -0,0 +1,78 @@
from __future__ import annotations
from collections.abc import Mapping
from pathlib import Path
from typing import Any
from pydantic import BaseModel
from resume_harness.io import load_model
from resume_harness.models import (
CandidateProfile,
ContentPlan,
EvidenceMap,
GenerationConfig,
JobAnalysis,
JobPosting,
QualityReport,
ResumeDraft,
)
from resume_harness.pipeline import PipelineStatus, ResumePipeline
from resume_harness.renderer import render_markdown
ROOT = Path(__file__).resolve().parents[1]
class GoldenFixtureBackend:
def __init__(self, responses: list[tuple[str, BaseModel]]) -> None:
self._responses = list(responses)
def complete_json(
self,
*,
stage: str,
system_prompt: str,
task_prompt: str,
user_payload: Mapping[str, Any],
output_model: type[BaseModel],
) -> BaseModel:
assert system_prompt and task_prompt and user_payload
expected_stage, response = self._responses.pop(0)
assert stage == expected_stage
assert isinstance(response, output_model)
return response
def test_professional_golden_fixture_passes_full_pipeline_and_renders() -> None:
profile = load_model(ROOT / "examples/candidate.sample.yaml", CandidateProfile)
posting = load_model(ROOT / "examples/job.sample.yaml", JobPosting)
config = load_model(ROOT / "examples/config.sample.yaml", GenerationConfig)
analysis = load_model(ROOT / "examples/job-analysis.sample.yaml", JobAnalysis)
evidence_map = load_model(ROOT / "examples/evidence-map.sample.yaml", EvidenceMap)
plan = load_model(ROOT / "examples/content-plan.sample.yaml", ContentPlan)
draft = load_model(ROOT / "examples/draft.sample.yaml", ResumeDraft)
report = load_model(ROOT / "examples/quality-report.sample.yaml", QualityReport)
backend = GoldenFixtureBackend(
[
("analyze-job", analysis),
("map-evidence", evidence_map),
("plan-content", plan),
("draft-resume", draft),
("evaluate-resume", report),
]
)
result = ResumePipeline(backend).run(profile, posting, config)
assert result.status is PipelineStatus.PASSED
assert result.deterministic_findings == []
assert result.quality_report is not None
assert result.quality_report.evidence_coverage == 1.0
assert result.quality_report.requirement_coverage == 1.0
markdown = render_markdown(result.draft, profile, config, analysis=analysis)
assert "## 핵심 역량" in markdown
assert "## 경력" in markdown
assert "## 주요 프로젝트" in markdown
assert "## 자격" in markdown
assert markdown.count("\n- ") >= 16
+1420
View File
File diff suppressed because it is too large Load Diff
+360
View File
@@ -0,0 +1,360 @@
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,
OutputMode,
PostingConstraint,
RequirementCategory,
RequirementKind,
ResumeDraft,
SectionType,
)
from resume_harness.output_constraints import (
OutputConstraintError,
as_quality_findings,
count_section_characters,
validate_output_constraints,
)
from resume_harness.renderer import render_markdown
NOW = datetime(2026, 7, 1, 12, tzinfo=timezone.utc)
def _claim(claim_id: str, text: str, order: int = 0) -> DraftClaim:
return DraftClaim(
claim_id=claim_id,
text=text,
evidence_ids=["ev-1"],
requirement_ids=["req-1"],
order=order,
)
def _section(
section_id: str,
section_type: SectionType,
heading: str,
order: int,
*texts: str,
) -> DraftSection:
return DraftSection(
section_id=section_id,
section_type=section_type,
heading=heading,
claims=[
_claim(f"claim-{section_id}-{index}", text, index)
for index, text in enumerate(texts or ("근거 기반 내용",))
],
order=order,
)
def _draft(*sections: DraftSection) -> ResumeDraft:
return ResumeDraft(
draft_id="draft-1",
candidate_id="candidate-1",
posting_id="posting-1",
title="백엔드 엔지니어",
sections=list(sections),
generated_at=NOW,
)
def _analysis(*constraints: PostingConstraint) -> JobAnalysis:
return JobAnalysis(
analysis_id="analysis-1",
posting_id="posting-1",
target_role="백엔드 엔지니어",
summary="API 개발 경험을 확인한다.",
requirements=[
JobRequirement(
requirement_id="req-1",
text="API 개발 경험",
kind=RequirementKind.REQUIRED,
category=RequirementCategory.SKILL,
source_quote="API 개발 경험",
classification_quote="필수\nAPI 개발 경험",
)
],
constraints=list(constraints),
analysed_at=NOW,
)
def _config(**updates: object) -> GenerationConfig:
values = {"as_of_date": date(2026, 7, 1), **updates}
return GenerationConfig(**values)
def test_configured_section_order_is_a_blocking_contract() -> None:
draft = _draft(
_section("skills", SectionType.SKILLS, "기술", 0),
_section("experience", SectionType.EXPERIENCE, "경력", 1),
)
issues = validate_output_constraints(draft, _config())
assert [issue.code for issue in issues] == ["OUTPUT.SECTION_ORDER"]
assert issues[0].blocking
def test_unlisted_optional_section_does_not_disturb_relative_order() -> None:
draft = _draft(
_section("other", SectionType.OTHER, "기타", 0),
_section("experience", SectionType.EXPERIENCE, "경력", 1),
_section("skills", SectionType.SKILLS, "기술", 2),
)
assert validate_output_constraints(draft, _config()) == []
def test_required_section_accepts_korean_alias_and_rejects_missing_section() -> None:
constraint = PostingConstraint(
constraint_id="required-career",
kind=ConstraintKind.REQUIRED_SECTION,
description="경력사항 필수",
source_quote="경력사항 필수",
section="경력사항",
)
present = _draft(_section("experience", SectionType.EXPERIENCE, "경력", 0))
missing = _draft(_section("skills", SectionType.SKILLS, "기술", 0))
assert validate_output_constraints(
present, _config(), analysis=_analysis(constraint)
) == []
issues = validate_output_constraints(
missing, _config(), analysis=_analysis(constraint)
)
assert [issue.code for issue in issues] == ["OUTPUT.REQUIRED_SECTION"]
def test_required_section_without_a_reference_fails_closed() -> None:
constraint = PostingConstraint(
constraint_id="required-unknown",
kind=ConstraintKind.REQUIRED_SECTION,
description="지정 항목 필수",
source_quote="지정 항목 필수",
)
draft = _draft(_section("skills", SectionType.SKILLS, "기술", 0))
issues = validate_output_constraints(
draft, _config(), analysis=_analysis(constraint)
)
assert [issue.code for issue in issues] == ["OUTPUT.CONSTRAINT_MALFORMED"]
def test_character_limit_has_a_documented_deterministic_count() -> None:
section = _section("intro", SectionType.OTHER, "자기소개", 0, "가 나", "")
constraint = PostingConstraint(
constraint_id="intro-limit",
kind=ConstraintKind.CHARACTER_LIMIT,
description="자기소개 4자 이내",
source_quote="자기소개 4자 이내",
section="자기소개",
max_characters=4,
)
assert count_section_characters([section]) == 5
issues = validate_output_constraints(
_draft(section), _config(), analysis=_analysis(constraint)
)
assert [issue.code for issue in issues] == ["OUTPUT.CHARACTER_LIMIT"]
assert "5자" in issues[0].message
assert issues[0].claim_id == "claim-intro-1"
@pytest.mark.parametrize("allowed", [[".md"], ["Markdown"], ["text/markdown"]])
def test_file_format_recognises_markdown_aliases(allowed: list[str]) -> None:
constraint = PostingConstraint(
constraint_id="format",
kind=ConstraintKind.FILE_FORMAT,
description="마크다운 제출",
source_quote="마크다운 제출",
formats=allowed,
)
draft = _draft(_section("skills", SectionType.SKILLS, "기술", 0))
assert validate_output_constraints(
draft,
_config(output_mode=OutputMode.MARKDOWN),
analysis=_analysis(constraint),
) == []
def test_disallowed_file_format_is_blocking() -> None:
constraint = PostingConstraint(
constraint_id="format",
kind=ConstraintKind.FILE_FORMAT,
description="PDF 또는 DOCX 제출",
source_quote="PDF 또는 DOCX 제출",
formats=["PDF", "DOCX"],
)
draft = _draft(_section("skills", SectionType.SKILLS, "기술", 0))
issues = validate_output_constraints(
draft, _config(), analysis=_analysis(constraint)
)
assert [issue.code for issue in issues] == ["OUTPUT.FILE_FORMAT"]
def test_unknown_blocking_constraint_fails_closed() -> None:
constraint = PostingConstraint(
constraint_id="language-only",
kind=ConstraintKind.OTHER,
description="영문으로만 작성",
source_quote="영문으로만 작성",
blocking=True,
)
draft = _draft(_section("skills", SectionType.SKILLS, "기술", 0))
issues = validate_output_constraints(
draft, _config(), analysis=_analysis(constraint)
)
assert [issue.code for issue in issues] == [
"OUTPUT.UNSUPPORTED_BLOCKING_CONSTRAINT"
]
assert issues[0].blocking
@pytest.mark.parametrize(
("configured_format", "text", "expected_issue"),
[
("YYYY.MM", "재직 기간 2024.03~2025.07", False),
("YYYY.MM", "재직 기간 2024-3~2025-7", True),
("YYYY.MM.DD", "자격 취득 2025.07.01", False),
("YYYY.MM.DD", "자격 취득 2025년 7월 1일", True),
],
)
def test_date_format_is_enforced_in_rendered_content(
configured_format: str, text: str, expected_issue: bool
) -> None:
draft = _draft(_section("experience", SectionType.EXPERIENCE, "경력", 0, text))
issues = validate_output_constraints(
draft, _config(date_format=configured_format)
)
assert ("OUTPUT.DATE_FORMAT" in {issue.code for issue in issues}) is expected_issue
def test_nonblocking_posting_rule_stays_a_warning_in_quality_contract() -> None:
constraint = PostingConstraint(
constraint_id="optional-format",
kind=ConstraintKind.FILE_FORMAT,
description="PDF 권장",
source_quote="PDF 권장",
formats=["PDF"],
blocking=False,
)
issues = validate_output_constraints(
_draft(_section("skills", SectionType.SKILLS, "기술", 0)),
_config(),
analysis=_analysis(constraint),
)
finding = as_quality_findings(issues)[0]
assert not finding.blocking
assert finding.code == "OUTPUT.FILE_FORMAT"
def test_markdown_renderer_rechecks_posting_constraints() -> None:
constraint = PostingConstraint(
constraint_id="format",
kind=ConstraintKind.FILE_FORMAT,
description="PDF 제출",
source_quote="PDF 제출",
formats=["PDF"],
)
draft = _draft(_section("skills", SectionType.SKILLS, "기술", 0))
profile = CandidateProfile(
candidate_id="candidate-1",
name="김지원",
contact=ContactInfo(email="apply@example.com"),
facts=[
EvidenceItem(
evidence_id="ev-1",
category=EvidenceCategory.SKILL,
content="API 개발 경험",
source=EvidenceSource.PORTFOLIO,
)
],
updated_at=NOW,
)
with pytest.raises(OutputConstraintError, match="OUTPUT.FILE_FORMAT"):
render_markdown(
draft,
profile,
_config(),
analysis=_analysis(constraint),
)
def test_markdown_renderer_rechecks_posting_blind_fields() -> None:
constraint = PostingConstraint(
constraint_id="blind-school",
kind=ConstraintKind.BLIND_FIELD,
description="학교명 기재 금지",
source_quote="학교명 기재 금지",
fields=["학교명"],
)
draft = _draft(
_section(
"education",
SectionType.EDUCATION,
"교육",
0,
"학교명: 합성대학교에서 API 과목을 이수",
)
)
profile = CandidateProfile(
candidate_id="candidate-1",
name="김지원",
contact=ContactInfo(email="apply@example.com"),
facts=[
EvidenceItem(
evidence_id="ev-1",
category=EvidenceCategory.EDUCATION,
content="API 과목을 이수했다.",
source=EvidenceSource.DOCUMENT,
)
],
updated_at=NOW,
)
with pytest.raises(ValueError, match="PRIVACY.POSTING_FIELD_LEAK"):
render_markdown(
draft,
profile,
_config(),
analysis=_analysis(constraint),
)
def test_markdown_does_not_pretend_to_measure_physical_pages() -> None:
draft = _draft(
_section("experience", SectionType.EXPERIENCE, "경력", 0, "" * 10_000)
)
issues = validate_output_constraints(draft, _config(max_pages=1))
assert all("PAGE" not in issue.code for issue in issues)
+872
View File
@@ -0,0 +1,872 @@
from __future__ import annotations
import json
from collections.abc import Mapping
from datetime import date, datetime, timezone
from typing import Any
import pytest
from pydantic import BaseModel
from resume_harness.backend import LLMBackend
from resume_harness.models import (
CandidateProfile,
ContactInfo,
ContentPlan,
ConstraintKind,
DraftClaim,
DraftSection,
EvidenceCategory,
EvidenceItem,
EvidenceMap,
EvidenceMatch,
EvidenceMatchType,
EvidenceSource,
GenerationConfig,
JobAnalysis,
JobPosting,
JobRequirement,
PlannedSection,
PostingConstraint,
QualityCategory,
QualityFinding,
QualityReport,
QualitySeverity,
RequirementCategory,
RequirementKind,
ResumeDraft,
ResumeMode,
SectionType,
SensitiveDataCategory,
SensitiveDataConsent,
)
from resume_harness.pipeline import PipelineError, PipelineStatus, ResumePipeline
from resume_harness.records import (
CareerRecord,
EmploymentType,
RecordDate,
RecordPeriod,
ResumeRecords,
)
UTC = timezone.utc
NOW = datetime(2026, 7, 1, 12, tzinfo=UTC)
class FakeBackend:
"""Strict FIFO fake: an unexpected stage fails the integration test."""
def __init__(self, responses: list[tuple[str, BaseModel | Mapping[str, Any]]]):
self.responses = list(responses)
self.calls: list[dict[str, Any]] = []
def complete_json(
self,
*,
stage: str,
system_prompt: str,
task_prompt: str,
user_payload: Mapping[str, Any],
output_model: type[BaseModel],
) -> BaseModel | Mapping[str, Any]:
assert system_prompt
assert task_prompt
assert self.responses, f"unexpected backend call for {stage}"
expected_stage, response = self.responses.pop(0)
assert stage == expected_stage
assert isinstance(response, output_model) or isinstance(response, Mapping)
self.calls.append(
{
"stage": stage,
"payload": user_payload,
"output_model": output_model,
}
)
return response
def _inputs() -> tuple[CandidateProfile, JobPosting, GenerationConfig]:
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),
)
facts = [
EvidenceItem(
evidence_id="ev-api",
category=EvidenceCategory.PROJECT,
content="Python 결제 API 응답 시간을 40% 단축해 35ms로 개선했다.",
source=EvidenceSource.PORTFOLIO,
source_reference="https://private.example/internal/source",
metrics={"latency_reduction": "40%", "latency": "35ms"},
keywords=["Python", "API"],
),
EvidenceItem(
evidence_id="ev-photo",
category=EvidenceCategory.OTHER,
content="지원자 증명사진 secret-photo-token",
source=EvidenceSource.DOCUMENT,
sensitive_category=SensitiveDataCategory.PHOTO,
consent_id="consent-photo",
),
EvidenceItem(
evidence_id="ev-confidential",
category=EvidenceCategory.PROJECT,
content="secret-company-project 내부 수치",
source=EvidenceSource.USER_STATEMENT,
metrics={"latency": "35ms"},
keywords=["Python"],
confidential=True,
),
]
profile = CandidateProfile(
candidate_id="candidate-1",
name="김하네스",
name_en="Harness Kim",
contact=ContactInfo(
email="harness@example.com",
phone="010-1234-5678",
city="서울",
links=["https://portfolio.example/harness"],
),
facts=facts,
consents=[consent],
updated_at=NOW,
)
posting = JobPosting(
posting_id="posting-1",
company_name="합성테크",
title="백엔드 엔지니어",
raw_text=(
"필수 요건\nPython 기반 API 개발 경험을 갖춘 "
"백엔드 엔지니어를 채용합니다."
),
collected_at=NOW,
)
config = GenerationConfig(as_of_date=date(2026, 7, 1))
return profile, posting, config
def _analysis() -> JobAnalysis:
return JobAnalysis(
analysis_id="analysis-1",
posting_id="posting-1",
target_role="백엔드 엔지니어",
summary="Python API 개발 경험을 중시한다.",
requirements=[
JobRequirement(
requirement_id="req-python",
text="Python 기반 API 개발 경험",
kind=RequirementKind.REQUIRED,
category=RequirementCategory.SKILL,
priority=5,
source_quote="Python 기반 API 개발 경험",
classification_quote="필수 요건\nPython 기반 API 개발 경험",
keywords=["Python", "API"],
)
],
keywords=["Python", "API"],
analysed_at=NOW,
)
def _evidence_map() -> EvidenceMap:
return EvidenceMap(
map_id="map-1",
posting_id="posting-1",
analysis_id="analysis-1",
matches=[
EvidenceMatch(
requirement_id="req-python",
evidence_ids=["ev-api"],
match_type=EvidenceMatchType.DIRECT,
relevance_score=0.95,
rationale="Python API 개선 경험이 직접 연결된다.",
)
],
generated_at=NOW,
)
def _plan(
mode: ResumeMode = ResumeMode.PRIVATE_MODERN,
) -> ContentPlan:
return ContentPlan(
plan_id="plan-1",
candidate_id="candidate-1",
posting_id="posting-1",
mode=mode,
sections=[
PlannedSection(
section_id="section-projects",
section_type=SectionType.PROJECTS,
heading="주요 프로젝트",
evidence_ids=["ev-api"],
requirement_ids=["req-python"],
bullet_budget=2,
order=0,
)
],
created_at=NOW,
)
def _draft(
text: str = "Python 결제 API 응답 시간을 40% 단축",
*,
mode: ResumeMode = ResumeMode.PRIVATE_MODERN,
) -> ResumeDraft:
return ResumeDraft(
draft_id="draft-1",
candidate_id="candidate-1",
posting_id="posting-1",
title="백엔드 엔지니어 이력서",
mode=mode,
sections=[
DraftSection(
section_id="section-projects",
section_type=SectionType.PROJECTS,
heading="주요 프로젝트",
claims=[
DraftClaim(
claim_id="claim-api",
text=text,
evidence_ids=["ev-api"],
requirement_ids=["req-python"],
order=0,
)
],
order=0,
)
],
generated_at=NOW,
)
def _good_report(draft: ResumeDraft | None = None) -> QualityReport:
bound_draft = draft or _draft()
return QualityReport(
report_id="report-good",
draft_id="draft-1",
draft_fingerprint=bound_draft.fingerprint(),
overall_score=94,
evidence_coverage=1.0,
requirement_coverage=1.0,
category_scores={
QualityCategory.EVIDENCE: 100,
QualityCategory.JOB_ALIGNMENT: 92,
QualityCategory.COMPLETENESS: 92,
QualityCategory.KOREAN_LANGUAGE: 91,
QualityCategory.READABILITY: 92,
QualityCategory.FORMATTING: 95,
QualityCategory.CONSISTENCY: 95,
QualityCategory.PRIVACY: 100,
},
findings=[],
evaluated_at=NOW,
)
def _bad_report(
report_id: str, draft: ResumeDraft | None = None
) -> QualityReport:
bound_draft = draft or _draft()
return QualityReport(
report_id=report_id,
draft_id="draft-1",
draft_fingerprint=bound_draft.fingerprint(),
overall_score=84,
evidence_coverage=1.0,
requirement_coverage=1.0,
category_scores={
QualityCategory.EVIDENCE: 100,
QualityCategory.JOB_ALIGNMENT: 76,
QualityCategory.COMPLETENESS: 82,
QualityCategory.KOREAN_LANGUAGE: 78,
QualityCategory.READABILITY: 80,
QualityCategory.FORMATTING: 90,
QualityCategory.CONSISTENCY: 88,
QualityCategory.PRIVACY: 100,
},
findings=[
QualityFinding(
finding_id=f"finding-{report_id}",
code="STYLE.ABSTRACT_ACTION",
severity=QualitySeverity.WARNING,
category=QualityCategory.KOREAN_LANGUAGE,
message="행동과 결과의 연결을 더 분명히 해야 한다.",
claim_id="claim-api",
evidence_ids=["ev-api"],
suggestion="근거 범위에서 행동을 명확히 한다.",
)
],
evaluated_at=NOW,
)
def _base_responses(
final_report: QualityReport | Mapping[str, Any],
) -> list[tuple[str, BaseModel | Mapping[str, Any]]]:
return [
("analyze-job", _analysis()),
("map-evidence", _evidence_map()),
("plan-content", _plan()),
("draft-resume", _draft()),
("evaluate-resume", final_report),
]
def test_success_orders_stages_and_never_sends_candidate_pii() -> None:
profile, posting, config = _inputs()
# A provider adapter may return a mapping produced from a model. Computed
# read-only fields in that mapping are tolerated and the writable fields are
# still validated strictly.
backend = FakeBackend(_base_responses(_good_report().model_dump(mode="json")))
assert isinstance(backend, LLMBackend)
result = ResumePipeline(backend).run(profile, posting, config)
assert result.status is PipelineStatus.PASSED
assert result.repair_attempts == 0
assert [call["stage"] for call in backend.calls] == [
"analyze-job",
"map-evidence",
"plan-content",
"draft-resume",
"evaluate-resume",
]
assert backend.responses == []
transmitted = json.dumps(
[call["payload"] for call in backend.calls], ensure_ascii=False
)
for forbidden in (
"김하네스",
"Harness Kim",
"harness@example.com",
"010-1234-5678",
"01012345678",
"https://portfolio.example/harness",
"서울",
"secret-photo-token",
"secret-company-project",
):
assert forbidden not in transmitted
# Shared technical and numeric values from an excluded fact are not privacy
# tokens and must remain usable in an allowed fact.
map_payload = backend.calls[1]["payload"]
assert map_payload["candidate_facts"][0]["keywords"] == ["Python", "API"]
assert map_payload["candidate_facts"][0]["metrics"]["latency"] == "35ms"
assert "source_reference" not in map_payload["candidate_facts"][0]
assert "consent_id" not in map_payload["candidate_facts"][0]
assert "confidential" not in map_payload["candidate_facts"][0]
def test_public_blind_school_fact_is_removed_before_llm_boundary() -> None:
profile, posting, _ = _inputs()
profile = profile.model_copy(
update={
"facts": [
*profile.facts,
EvidenceItem(
evidence_id="ev-school",
category=EvidenceCategory.EDUCATION,
content="서울대에서 컴퓨터공학을 전공했다.",
source=EvidenceSource.DOCUMENT,
keywords=["서울대", "컴퓨터공학"],
),
EvidenceItem(
evidence_id="ev-school-snu",
category=EvidenceCategory.EDUCATION,
content="SNU 컴퓨터공학 과정을 졸업했다.",
source=EvidenceSource.DOCUMENT,
keywords=["SNU", "컴퓨터공학"],
),
]
}
)
config = GenerationConfig(
resume_mode=ResumeMode.PUBLIC_BLIND,
as_of_date=date(2026, 7, 1),
)
blind_draft = _draft(mode=ResumeMode.PUBLIC_BLIND)
backend = FakeBackend(
[
("analyze-job", _analysis()),
("map-evidence", _evidence_map()),
("plan-content", _plan(ResumeMode.PUBLIC_BLIND)),
("draft-resume", blind_draft),
("evaluate-resume", _good_report(blind_draft)),
]
)
result = ResumePipeline(backend).run(profile, posting, config)
assert result.status is PipelineStatus.PASSED
transmitted = json.dumps(
[call["payload"] for call in backend.calls], ensure_ascii=False
)
assert "ev-school" not in transmitted
assert "서울대" not in transmitted
assert "ev-school-snu" not in transmitted
assert "SNU" not in transmitted
def test_public_blind_origin_fact_is_removed_before_llm_boundary() -> None:
profile, posting, _ = _inputs()
profile = profile.model_copy(
update={
"facts": [
*profile.facts,
EvidenceItem(
evidence_id="ev-origin",
category=EvidenceCategory.PROJECT,
content="고향은 대전이며 Python API를 개발했다.",
source=EvidenceSource.USER_STATEMENT,
keywords=["Python", "API"],
),
EvidenceItem(
evidence_id="ev-hometown-compact",
category=EvidenceCategory.PROJECT,
content="고향 대전, Python API를 개발했다.",
source=EvidenceSource.USER_STATEMENT,
keywords=["Python", "API"],
),
EvidenceItem(
evidence_id="ev-grown",
category=EvidenceCategory.PROJECT,
content="대전에서 자랐고 Python API를 개발했다.",
source=EvidenceSource.USER_STATEMENT,
keywords=["Python", "API"],
),
EvidenceItem(
evidence_id="ev-region-work",
category=EvidenceCategory.PROJECT,
content="대전 지역 고객을 위한 Python API를 개발했다.",
source=EvidenceSource.PORTFOLIO,
keywords=["Python", "API", "대전 지역"],
),
]
}
)
config = GenerationConfig(
resume_mode=ResumeMode.PUBLIC_BLIND,
as_of_date=date(2026, 7, 1),
)
blind_draft = _draft(mode=ResumeMode.PUBLIC_BLIND)
backend = FakeBackend(
[
("analyze-job", _analysis()),
("map-evidence", _evidence_map()),
("plan-content", _plan(ResumeMode.PUBLIC_BLIND)),
("draft-resume", blind_draft),
("evaluate-resume", _good_report(blind_draft)),
]
)
result = ResumePipeline(backend).run(profile, posting, config)
assert result.status is PipelineStatus.PASSED
transmitted = json.dumps(
[call["payload"] for call in backend.calls], ensure_ascii=False
)
assert "ev-origin" not in transmitted
assert "고향은 대전" not in transmitted
assert "ev-hometown-compact" not in transmitted
assert "고향 대전" not in transmitted
assert "ev-grown" not in transmitted
assert "대전에서 자랐고" not in transmitted
assert "ev-region-work" in transmitted
assert "대전 지역 고객" in transmitted
def test_korean_name_with_postposition_is_redacted_before_backend() -> None:
profile, posting, config = _inputs()
posting = posting.model_copy(
update={
"raw_text": (
"김하네스는 외부 입력입니다.\n필수 요건\n"
"Python 기반 API 개발 경험을 "
"갖춘 지원자를 찾습니다."
)
}
)
backend = FakeBackend(_base_responses(_good_report()))
ResumePipeline(backend).run(profile, posting, config)
analyze_payload = json.dumps(backend.calls[0]["payload"], ensure_ascii=False)
assert "김하네스" not in analyze_payload
assert "[REDACTED]는" in analyze_payload
def test_short_korean_name_does_not_redact_an_ordinary_verb() -> None:
profile, posting, config = _inputs()
profile = profile.model_copy(update={"name": "이수"})
posting = posting.model_copy(
update={
"raw_text": (
"교육 과정을 이수했다면 우대합니다.\n"
"필수 요건\n"
"Python 기반 API 개발 경험을 확인합니다."
)
}
)
backend = FakeBackend(_base_responses(_good_report()))
ResumePipeline(backend).run(profile, posting, config)
analyze_payload = json.dumps(backend.calls[0]["payload"], ensure_ascii=False)
assert "이수했다" in analyze_payload
assert "[REDACTED]했다" not in analyze_payload
def test_no_generation_safe_evidence_returns_early_needs_user_input() -> None:
profile, posting, config = _inputs()
profile = profile.model_copy(
update={
"facts": [
fact.model_copy(update={"confidential": True})
for fact in profile.facts
]
}
)
backend = FakeBackend([("analyze-job", _analysis())])
result = ResumePipeline(backend).run(profile, posting, config)
assert result.status is PipelineStatus.NEEDS_USER_INPUT
assert result.gate_failures == ["no_generation_safe_evidence"]
assert result.evidence_map is None
assert result.draft is None
assert [call["stage"] for call in backend.calls] == ["analyze-job"]
def test_all_gap_evidence_map_returns_before_impossible_draft() -> None:
profile, posting, config = _inputs()
gap_map = EvidenceMap(
map_id="map-gap",
posting_id="posting-1",
analysis_id="analysis-1",
matches=[
EvidenceMatch(
requirement_id="req-python",
match_type=EvidenceMatchType.GAP,
relevance_score=0,
gap_reason="직접 근거가 없다.",
)
],
generated_at=NOW,
)
backend = FakeBackend(
[("analyze-job", _analysis()), ("map-evidence", gap_map)]
)
result = ResumePipeline(backend).run(profile, posting, config)
assert result.status is PipelineStatus.NEEDS_USER_INPUT
assert result.gate_failures == ["all_requirements_gap"]
assert result.evidence_map == gap_map
assert result.content_plan is None
assert result.questions
assert [call["stage"] for call in backend.calls] == [
"analyze-job",
"map-evidence",
]
def test_posting_specific_company_name_fact_is_withheld_before_mapping() -> None:
profile, posting, config = _inputs()
profile = profile.model_copy(
update={
"facts": [
fact.model_copy(
update={
"content": "가상페이에서 Python 기반 API 개발 경험을 쌓았다."
}
)
if fact.evidence_id == "ev-api"
else fact
for fact in profile.facts
]
}
)
posting = posting.model_copy(
update={"raw_text": posting.raw_text + "\n회사명 기재 금지"}
)
analysis = _analysis().model_copy(
update={
"constraints": [
PostingConstraint(
constraint_id="blind-company",
kind=ConstraintKind.BLIND_FIELD,
description="회사명을 본문에 기재하지 않는다.",
source_quote="회사명 기재 금지",
fields=["회사명"],
)
]
}
)
backend = FakeBackend([("analyze-job", analysis)])
result = ResumePipeline(backend).run(profile, posting, config)
assert result.status is PipelineStatus.NEEDS_USER_INPUT
assert result.gate_failures == ["no_generation_safe_evidence"]
assert [call["stage"] for call in backend.calls] == ["analyze-job"]
def test_unknown_company_fact_is_withheld_even_with_structured_career() -> None:
profile, posting, config = _inputs()
facts = [
fact.model_copy(
update={"content": "가상페이에서 Python API를 개발했다."}
)
if fact.evidence_id == "ev-api"
else fact
for fact in profile.facts
]
facts.append(
EvidenceItem(
evidence_id="ev-career",
category=EvidenceCategory.CAREER,
content="2024년부터 기록회사 백엔드 엔지니어로 근무했다.",
source=EvidenceSource.EMPLOYMENT_RECORD,
date_range={"start": {"year": 2024}, "ongoing": True},
)
)
profile = profile.model_copy(
update={
"facts": facts,
"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"],
)
]
),
}
)
posting = posting.model_copy(
update={"raw_text": posting.raw_text + "\n회사명 기재 금지"}
)
analysis = _analysis().model_copy(
update={
"constraints": [
PostingConstraint(
constraint_id="blind-company-with-records",
kind=ConstraintKind.BLIND_FIELD,
description="회사명을 본문에 기재하지 않는다.",
source_quote="회사명 기재 금지",
fields=["회사명"],
)
]
}
)
backend = FakeBackend([("analyze-job", analysis)])
result = ResumePipeline(backend).run(profile, posting, config)
assert result.status is PipelineStatus.NEEDS_USER_INPUT
assert result.gate_failures == ["no_generation_safe_evidence"]
assert [call["stage"] for call in backend.calls] == ["analyze-job"]
def test_claim_finding_triggers_targeted_repair_and_re_evaluation() -> None:
profile, posting, config = _inputs()
repaired = _draft("Python 결제 API 응답 시간을 40% 단축")
backend = FakeBackend(
[
*_base_responses(_bad_report("report-before")),
("repair-resume", repaired),
("evaluate-resume", _good_report(repaired)),
]
)
result = ResumePipeline(backend).run(profile, posting, config)
assert result.status is PipelineStatus.PASSED
assert result.repair_attempts == 1
assert result.draft.sections[0].claims[0].text == repaired.sections[0].claims[0].text
assert [call["stage"] for call in backend.calls] == [
"analyze-job",
"map-evidence",
"plan-content",
"draft-resume",
"evaluate-resume",
"repair-resume",
"evaluate-resume",
]
repair_payload = backend.calls[5]["payload"]
assert [item["claim_id"] for item in repair_payload["approved_findings"]] == [
"claim-api"
]
def test_two_failed_repairs_return_needs_user_input_with_bounded_questions() -> None:
profile, posting, config = _inputs()
repaired_once = _draft("Python 결제 API 응답 시간을 40% 단축함")
repaired_twice = _draft("결제 API 응답 시간을 Python으로 40% 단축")
backend = FakeBackend(
[
*_base_responses(_bad_report("report-0")),
("repair-resume", repaired_once),
("evaluate-resume", _bad_report("report-1", repaired_once)),
("repair-resume", repaired_twice),
("evaluate-resume", _bad_report("report-2", repaired_twice)),
]
)
result = ResumePipeline(backend).run(profile, posting, config)
assert result.status is PipelineStatus.NEEDS_USER_INPUT
assert result.repair_attempts == 2
assert 1 <= len(result.questions) <= 3
assert result.gate_failures
assert [call["stage"] for call in backend.calls].count("repair-resume") == 2
assert [call["stage"] for call in backend.calls].count("evaluate-resume") == 3
assert backend.responses == []
def test_judge_cannot_inflate_deterministic_requirement_coverage() -> None:
profile, posting, config = _inputs()
uncovered = _draft()
uncovered.sections[0].claims[0].requirement_ids = []
backend = FakeBackend(
[
("analyze-job", _analysis()),
("map-evidence", _evidence_map()),
("plan-content", _plan()),
("draft-resume", uncovered),
("evaluate-resume", _good_report(uncovered)),
]
)
result = ResumePipeline(backend, max_repair_attempts=0).run(
profile, posting, config
)
assert result.status is PipelineStatus.NEEDS_USER_INPUT
assert result.quality_report is not None
assert result.quality_report.requirement_coverage == 0
assert any(
failure.startswith("requirement_coverage:0<")
for failure in result.gate_failures
)
def test_high_judge_score_cannot_release_unrelated_korean_claim() -> None:
profile, posting, config = _inputs()
hallucinated = _draft(
"고객 만족도를 혁신적으로 높이고 조직 문화를 획기적으로 개선했다"
)
backend = FakeBackend(
[
("analyze-job", _analysis()),
("map-evidence", _evidence_map()),
("plan-content", _plan()),
("draft-resume", hallucinated),
("evaluate-resume", _good_report(hallucinated)),
]
)
result = ResumePipeline(backend, max_repair_attempts=0).run(
profile, posting, config
)
assert result.status is PipelineStatus.NEEDS_USER_INPUT
assert "GROUNDING.LOW_LEXICAL_SUPPORT" in {
finding.code for finding in result.deterministic_findings
}
def test_release_pipeline_rejects_non_strict_evidence_mode() -> None:
profile, posting, config = _inputs()
config = config.model_copy(update={"strict_evidence": False})
backend = FakeBackend([])
with pytest.raises(PipelineError, match="strict_evidence=true"):
ResumePipeline(backend).run(profile, posting, config)
assert backend.calls == []
def test_job_analysis_constraints_participate_in_deterministic_gate() -> None:
profile, posting, config = _inputs()
posting = posting.model_copy(
update={"raw_text": posting.raw_text + " 학교명 기재 금지"}
)
analysis = _analysis().model_copy(
update={
"constraints": [
PostingConstraint(
constraint_id="constraint-school",
kind=ConstraintKind.BLIND_FIELD,
description="학교명을 본문에 기재하지 않는다.",
source_quote="학교명 기재 금지",
fields=["학교명"],
)
]
}
)
constrained_draft = _draft(
"학교명: 합성대학교에서 Python 결제 API 응답 시간을 40% 단축"
)
backend = FakeBackend(
[
("analyze-job", analysis),
("map-evidence", _evidence_map()),
("plan-content", _plan()),
("draft-resume", constrained_draft),
("evaluate-resume", _good_report(constrained_draft)),
]
)
result = ResumePipeline(backend, max_repair_attempts=0).run(
profile, posting, config
)
assert result.status is PipelineStatus.NEEDS_USER_INPUT
assert "PRIVACY.POSTING_FIELD_LEAK" in {
finding.code for finding in result.deterministic_findings
}
def test_consented_employer_form_sensitive_fact_stays_renderer_only() -> None:
profile, posting, _ = _inputs()
config = GenerationConfig(
resume_mode=ResumeMode.EMPLOYER_FORM,
as_of_date=date(2026, 7, 1),
include_photo=True,
allowed_sensitive_categories={SensitiveDataCategory.PHOTO},
employer_required_sensitive_categories={SensitiveDataCategory.PHOTO},
)
employer_draft = _draft(mode=ResumeMode.EMPLOYER_FORM)
backend = FakeBackend(
[
("analyze-job", _analysis()),
("map-evidence", _evidence_map()),
("plan-content", _plan(ResumeMode.EMPLOYER_FORM)),
("draft-resume", employer_draft),
("evaluate-resume", _good_report(employer_draft)),
]
)
result = ResumePipeline(backend).run(profile, posting, config)
assert result.status is PipelineStatus.PASSED
transmitted = json.dumps(
[call["payload"] for call in backend.calls], ensure_ascii=False
)
assert "ev-photo" not in transmitted
assert "secret-photo-token" not in transmitted
+133
View File
@@ -0,0 +1,133 @@
from __future__ import annotations
from pathlib import Path
import pytest
from resume_harness.prompts import (
DuplicatePromptIdError,
PromptFormatError,
PromptRepository,
PromptRepositoryError,
)
PROJECT_ROOT = Path(__file__).resolve().parents[1]
PACKAGE_PROMPT_ROOT = PROJECT_ROOT / "src" / "resume_harness" / "prompt_templates"
DEVELOPMENT_PROMPT_ROOT = PROJECT_ROOT / "prompts"
def _write_prompt(
root: Path,
filename: str,
*,
prompt_id: str = "draft-resume",
version: str = "1.2.3",
output_model: str | None = "ResumeDraft",
body: str = "검증된 사실만 사용한다.",
) -> Path:
output_line = "" if output_model is None else f"output_model: {output_model}\n"
path = root / filename
path.write_text(
"---\n"
f"id: {prompt_id}\n"
f"version: {version}\n"
f"{output_line}"
"---\n\n"
f"{body}\n",
encoding="utf-8",
)
return path
def test_repository_loads_checked_in_prompts_and_optional_output_model() -> None:
repository = PromptRepository()
draft = repository.get("draft-resume")
base = repository.load("base-system")
assert draft.version == "1.0.0"
assert draft.output_model == "ResumeDraft"
assert "근거" in draft.body
assert draft.content == draft.body
assert base.output_model is None
assert repository.list_ids() == tuple(repository)
assert repository.root == PACKAGE_PROMPT_ROOT.resolve()
def test_development_prompt_mirror_matches_packaged_templates() -> None:
packaged = {
path.name: path.read_bytes() for path in PACKAGE_PROMPT_ROOT.glob("*.md")
}
development = {
path.name: path.read_bytes() for path in DEVELOPMENT_PROMPT_ROOT.glob("*.md")
}
assert packaged
assert development == packaged
def test_repository_rejects_requested_path_traversal(tmp_path: Path) -> None:
_write_prompt(tmp_path, "safe.md")
repository = PromptRepository(tmp_path)
with pytest.raises(PromptRepositoryError, match="invalid requested prompt id"):
repository.get("../safe")
with pytest.raises(PromptRepositoryError):
repository.load("/etc/passwd")
def test_repository_rejects_duplicate_ids_case_insensitively(tmp_path: Path) -> None:
_write_prompt(tmp_path, "first.md", prompt_id="Draft-Resume")
_write_prompt(tmp_path, "second.md", prompt_id="draft-resume")
with pytest.raises(DuplicatePromptIdError, match="duplicate prompt id"):
PromptRepository(tmp_path)
@pytest.mark.parametrize(
"front_matter",
[
"id: safe\nid: replaced\nversion: 1.0.0\n",
"id: safe\nversion: 1.0.0\noutput_model: !!python/name:os.system\n",
"id: [safe]\nversion: 1.0.0\n",
],
)
def test_repository_uses_strict_safe_front_matter(
tmp_path: Path, front_matter: str
) -> None:
(tmp_path / "unsafe.md").write_text(
f"---\n{front_matter}---\n본문\n", encoding="utf-8"
)
with pytest.raises(PromptFormatError, match="front matter"):
PromptRepository(tmp_path)
def test_repository_validates_required_metadata_and_body(tmp_path: Path) -> None:
(tmp_path / "missing.md").write_text(
"---\nid: only-id\n---\n본문\n", encoding="utf-8"
)
with pytest.raises(PromptFormatError, match="requires id and version"):
PromptRepository(tmp_path)
def test_repository_rejects_symlinked_prompt_even_when_target_exists(
tmp_path: Path,
) -> None:
repository_root = tmp_path / "repository"
repository_root.mkdir()
outside = _write_prompt(tmp_path, "outside.md", prompt_id="outside")
(repository_root / "linked.md").symlink_to(outside)
with pytest.raises(PromptRepositoryError, match="symbolic links"):
PromptRepository(repository_root)
def test_repository_reports_unknown_but_well_formed_id(tmp_path: Path) -> None:
_write_prompt(tmp_path, "known.md", prompt_id="known")
repository = PromptRepository(tmp_path)
with pytest.raises(KeyError, match="unknown prompt id"):
repository.get("missing")
+341
View File
@@ -0,0 +1,341 @@
from __future__ import annotations
from datetime import datetime, timezone
import pytest
from resume_harness.models import (
CandidateProfile,
ContactInfo,
DraftClaim,
DraftSection,
EvidenceCategory,
EvidenceItem,
EvidenceMap,
EvidenceMatch,
EvidenceMatchType,
EvidenceSource,
JobAnalysis,
JobRequirement,
QualityCategory,
QualityFinding,
QualitySeverity,
RequirementCategory,
RequirementKind,
ResumeDraft,
SectionType,
)
from resume_harness.quality import (
apply_deterministic_score_caps,
compute_coverage,
compute_evaluation_policy_fingerprint,
compute_weighted_overall,
)
NOW = datetime(2026, 7, 1, 12, tzinfo=timezone.utc)
def test_deterministic_blocker_caps_inflated_subjective_score() -> None:
scores = {
QualityCategory.EVIDENCE: 100,
QualityCategory.JOB_ALIGNMENT: 95,
QualityCategory.COMPLETENESS: 99,
QualityCategory.KOREAN_LANGUAGE: 95,
QualityCategory.READABILITY: 95,
QualityCategory.FORMATTING: 95,
QualityCategory.CONSISTENCY: 95,
QualityCategory.PRIVACY: 100,
}
blocker = QualityFinding(
finding_id="thin-summary",
code="CONTENT.THIN_SUMMARY",
severity=QualitySeverity.ERROR,
category=QualityCategory.COMPLETENESS,
message="핵심 요약이 지나치게 얇다.",
)
capped = apply_deterministic_score_caps(scores, [blocker])
assert capped[QualityCategory.COMPLETENESS] == 59
assert capped[QualityCategory.EVIDENCE] == 100
assert compute_weighted_overall(capped) < compute_weighted_overall(scores)
def make_profile(
content: str = "Python API를 개선했다.",
*,
keywords: list[str] | None = None,
) -> CandidateProfile:
return CandidateProfile(
candidate_id="candidate-1",
name="김하네스",
contact=ContactInfo(email="harness@example.com"),
facts=[
EvidenceItem(
evidence_id="ev-1",
category=EvidenceCategory.PROJECT,
content=content,
source=EvidenceSource.PORTFOLIO,
verification_status="document_verified",
keywords=keywords or [],
)
],
updated_at=NOW,
)
def test_requirement_coverage_is_priority_weighted_and_excludes_context() -> None:
analysis = JobAnalysis(
analysis_id="analysis-1",
posting_id="posting-1",
target_role="백엔드 엔지니어",
summary="직무 기준",
requirements=[
JobRequirement(
requirement_id="req-covered",
text="Python API 경험",
kind=RequirementKind.REQUIRED,
category=RequirementCategory.SKILL,
priority=5,
source_quote="Python API 경험",
classification_quote="필수 요건\nPython API 경험",
),
JobRequirement(
requirement_id="req-missing",
text="Kafka 운영 경험",
kind=RequirementKind.PREFERRED,
category=RequirementCategory.SKILL,
priority=3,
source_quote="Kafka 운영 경험",
classification_quote="우대 요건\nKafka 운영 경험",
),
JobRequirement(
requirement_id="req-context",
text="글로벌 서비스 조직",
kind=RequirementKind.CONTEXT,
category=RequirementCategory.OTHER,
priority=5,
source_quote="글로벌 서비스 조직",
),
],
analysed_at=NOW,
)
draft = ResumeDraft(
draft_id="draft-1",
candidate_id="candidate-1",
posting_id="posting-1",
title="백엔드 이력서",
sections=[
DraftSection(
section_id="projects",
section_type=SectionType.PROJECTS,
heading="프로젝트",
claims=[
DraftClaim(
claim_id="claim-1",
text="Python API 개선",
evidence_ids=["ev-1"],
requirement_ids=["req-covered"],
)
],
order=0,
)
],
generated_at=NOW,
)
evidence_map = EvidenceMap(
map_id="map-1",
posting_id="posting-1",
analysis_id="analysis-1",
matches=[
EvidenceMatch(
requirement_id="req-covered",
evidence_ids=["ev-1"],
match_type=EvidenceMatchType.DIRECT,
relevance_score=1,
rationale="직접 근거",
),
EvidenceMatch(
requirement_id="req-missing",
match_type=EvidenceMatchType.GAP,
relevance_score=0,
gap_reason="근거 없음",
),
EvidenceMatch(
requirement_id="req-context",
match_type=EvidenceMatchType.GAP,
relevance_score=0,
gap_reason="평가 제외 맥락",
),
],
generated_at=NOW,
)
coverage = compute_coverage(draft, analysis, evidence_map, make_profile())
assert coverage.evidence == 1.0
assert coverage.requirements == pytest.approx(5 / 8)
def test_overall_score_is_a_deterministic_weighted_sum() -> None:
scores = {
QualityCategory.EVIDENCE: 100,
QualityCategory.JOB_ALIGNMENT: 92,
QualityCategory.COMPLETENESS: 90,
QualityCategory.KOREAN_LANGUAGE: 92,
QualityCategory.READABILITY: 92,
QualityCategory.FORMATTING: 90,
QualityCategory.CONSISTENCY: 95,
QualityCategory.PRIVACY: 100,
}
assert compute_weighted_overall(scores) == 94.15
with pytest.raises(ValueError, match="missing weighted"):
compute_weighted_overall({QualityCategory.EVIDENCE: 100})
def test_evaluation_policy_fingerprint_changes_with_judge_prompt() -> None:
first = compute_evaluation_policy_fingerprint(
system_prompt="system-v1", evaluator_prompt="judge-v1"
)
second = compute_evaluation_policy_fingerprint(
system_prompt="system-v1", evaluator_prompt="judge-v2"
)
assert first != second
def test_requirement_coverage_ignores_unrelated_claim_text() -> None:
analysis = JobAnalysis(
analysis_id="analysis-1",
posting_id="posting-1",
target_role="백엔드 엔지니어",
summary="직무 기준",
requirements=[
JobRequirement(
requirement_id="req-python",
text="Python 개발 경험",
kind=RequirementKind.REQUIRED,
category=RequirementCategory.SKILL,
source_quote="Python 개발 경험",
classification_quote="필수\nPython 개발 경험",
)
],
analysed_at=NOW,
)
draft = ResumeDraft(
draft_id="draft-unrelated",
candidate_id="candidate-1",
posting_id="posting-1",
title="백엔드 이력서",
sections=[
DraftSection(
section_id="experience",
section_type=SectionType.EXPERIENCE,
heading="경험",
claims=[
DraftClaim(
claim_id="claim-unrelated",
text="고객 인터뷰를 수행했다",
evidence_ids=["ev-1"],
requirement_ids=["req-python"],
)
],
order=0,
)
],
generated_at=NOW,
)
evidence_map = EvidenceMap(
map_id="map-unrelated",
posting_id="posting-1",
analysis_id="analysis-1",
matches=[
EvidenceMatch(
requirement_id="req-python",
evidence_ids=["ev-1"],
match_type=EvidenceMatchType.DIRECT,
relevance_score=1,
rationale="잘못된 연결",
)
],
generated_at=NOW,
)
assert (
compute_coverage(
draft,
analysis,
evidence_map,
make_profile("고객 인터뷰를 수행했다."),
).requirements
== 0.0
)
def test_requirement_coverage_rechecks_actual_evidence_semantics() -> None:
requirement = JobRequirement(
requirement_id="req-support",
text="고객 상담 경험",
kind=RequirementKind.REQUIRED,
category=RequirementCategory.EXPERIENCE,
priority=5,
source_quote="고객 상담 경험",
classification_quote="필수 요건\n고객 상담 경험",
)
analysis = JobAnalysis(
analysis_id="analysis-support",
posting_id="posting-1",
target_role="고객 상담원",
summary="고객 상담 직무 기준",
requirements=[requirement],
analysed_at=NOW,
)
draft = ResumeDraft(
draft_id="draft-support",
candidate_id="candidate-1",
posting_id="posting-1",
title="고객 상담 이력서",
sections=[
DraftSection(
section_id="experience",
section_type=SectionType.EXPERIENCE,
heading="경험",
claims=[
DraftClaim(
claim_id="claim-support",
text="고객 상담 경험",
evidence_ids=["ev-1"],
requirement_ids=["req-support"],
)
],
order=0,
)
],
generated_at=NOW,
)
evidence_map = EvidenceMap(
map_id="map-support",
posting_id="posting-1",
analysis_id="analysis-support",
matches=[
EvidenceMatch(
requirement_id="req-support",
evidence_ids=["ev-1"],
match_type=EvidenceMatchType.DIRECT,
relevance_score=1,
rationale="고객 단어가 같다.",
)
],
generated_at=NOW,
)
coverage = compute_coverage(
draft,
analysis,
evidence_map,
make_profile("고객 명단을 정리했다."),
)
assert coverage.requirements == 0.0
+238
View File
@@ -0,0 +1,238 @@
from __future__ import annotations
import json
from datetime import date, datetime, timezone
import pytest
from pydantic import ValidationError
from resume_harness.models import (
CandidateProfile,
ContactInfo,
EvidenceCategory,
EvidenceItem,
EvidenceSource,
GenerationConfig,
ResumeMode,
)
from resume_harness.pipeline import _candidate_facts_payload, _visible_facts
from resume_harness.records import (
CareerRecord,
CertificationRecord,
EducationRecord,
EducationStatus,
EmploymentType,
ExperienceRecord,
ExperienceType,
RecordDate,
RecordPeriod,
ResumeRecords,
)
NOW = datetime(2026, 7, 1, 12, tzinfo=timezone.utc)
def _period(start_year: int, end_year: int | None = None) -> RecordPeriod:
return RecordPeriod(
start=RecordDate(year=start_year),
end=RecordDate(year=end_year) if end_year is not None else None,
ongoing=end_year is None,
)
def _career(record_id: str, evidence_id: str, start: int, end: int | None) -> CareerRecord:
return CareerRecord(
record_id=record_id,
organization="하네스테크",
role="백엔드 엔지니어",
period=_period(start, end),
employment_type=EmploymentType.FULL_TIME,
evidence_ids=[evidence_id],
)
def test_paid_career_and_unpaid_experience_are_structurally_distinct() -> None:
career = _career("career-1", "ev-career", 2024, None)
experience = ExperienceRecord(
record_id="experience-1",
organization="오픈소스 커뮤니티",
role="기여자",
period=_period(2023, 2023),
experience_type=ExperienceType.COMMUNITY,
evidence_ids=["ev-project"],
)
assert career.paid is True
assert experience.paid is False
with pytest.raises(ValidationError):
CareerRecord.model_validate({**career.model_dump(), "paid": False})
with pytest.raises(ValidationError):
ExperienceRecord.model_validate({**experience.model_dump(), "paid": True})
def test_period_and_certification_chronology_are_validated() -> None:
with pytest.raises(ValidationError, match="requires an end date"):
RecordPeriod(start=RecordDate(year=2024))
with pytest.raises(ValidationError, match="earlier"):
RecordPeriod(
start=RecordDate(year=2025),
end=RecordDate(year=2024),
)
with pytest.raises(ValidationError, match="expiry"):
CertificationRecord(
record_id="cert-1",
name="정보처리기사",
issuer="한국산업인력공단",
issued_on=RecordDate(year=2025),
expires_on=RecordDate(year=2024),
evidence_ids=["ev-cert"],
)
def test_education_status_and_ongoing_period_cannot_contradict() -> None:
with pytest.raises(ValidationError, match="ongoing"):
EducationRecord(
record_id="education-1",
institution="하네스대학교",
degree="학사",
field_of_study="컴퓨터공학",
period=_period(2020, 2024),
status=EducationStatus.IN_PROGRESS,
evidence_ids=["ev-education"],
)
def test_records_validate_evidence_provenance_and_category() -> None:
records = ResumeRecords(
careers=[_career("career-1", "ev-career", 2024, None)]
)
assert records.assert_evidence_integrity({"ev-career": "career"}) is records
with pytest.raises(ValueError, match="unknown evidence"):
records.assert_evidence_integrity({})
with pytest.raises(ValueError, match="incompatible evidence"):
records.assert_evidence_integrity({"ev-career": "education"})
def test_candidate_profile_checks_record_evidence_links() -> None:
career_fact = EvidenceItem(
evidence_id="ev-career",
category=EvidenceCategory.CAREER,
content="2024년부터 하네스테크 백엔드 엔지니어로 결제 서비스를 운영했다.",
source=EvidenceSource.EMPLOYMENT_RECORD,
date_range={"start": {"year": 2024}, "ongoing": True},
)
profile = CandidateProfile(
candidate_id="candidate-1",
name="김하네스",
contact=ContactInfo(email="harness@example.com"),
facts=[career_fact],
records=ResumeRecords(
careers=[_career("career-1", "ev-career", 2024, None)]
),
updated_at=NOW,
)
assert profile.structured_record_by_evidence_id["ev-career"].record_id == "career-1"
with pytest.raises(ValidationError, match="unknown evidence"):
CandidateProfile(
candidate_id="candidate-1",
name="김하네스",
contact=ContactInfo(email="harness@example.com"),
facts=[career_fact],
records=ResumeRecords(
careers=[_career("career-1", "missing", 2024, None)]
),
updated_at=NOW,
)
def test_chronological_views_are_deterministic_and_newest_first() -> None:
records = ResumeRecords(
careers=[
_career("career-old", "ev-old", 2019, 2020),
_career("career-current", "ev-current", 2024, None),
_career("career-middle", "ev-middle", 2021, 2023),
]
)
assert [record.record_id for record in records.careers_chronological()] == [
"career-current",
"career-middle",
"career-old",
]
def test_structured_school_name_does_not_cross_generation_boundary() -> None:
education_fact = EvidenceItem(
evidence_id="ev-education",
category=EvidenceCategory.EDUCATION,
content="2020년부터 2024년까지 서울대학교 컴퓨터공학 학사 과정을 졸업했다.",
source=EvidenceSource.DOCUMENT,
date_range={
"start": {"year": 2020},
"end": {"year": 2024},
},
)
profile = CandidateProfile(
candidate_id="candidate-1",
name="김하네스",
contact=ContactInfo(email="harness@example.com"),
facts=[education_fact],
records=ResumeRecords(
educations=[
EducationRecord(
record_id="education-1",
institution="서울대학교",
degree="학사",
field_of_study="컴퓨터공학",
period=RecordPeriod(
start=RecordDate(year=2020),
end=RecordDate(year=2024),
),
status=EducationStatus.GRADUATED,
evidence_ids=["ev-education"],
)
]
),
updated_at=NOW,
)
config = GenerationConfig(
resume_mode=ResumeMode.PUBLIC_BLIND,
as_of_date=date(2026, 7, 1),
)
visible = _visible_facts(profile, config)
transmitted = json.dumps(
_candidate_facts_payload(visible), ensure_ascii=False
)
assert "서울대학교" not in transmitted
assert "institution" not in transmitted
assert "records" not in transmitted
def test_candidate_rejects_structured_values_absent_from_linked_evidence() -> None:
fact = EvidenceItem(
evidence_id="ev-career",
category=EvidenceCategory.CAREER,
content="2024년 API를 개발했다.",
source=EvidenceSource.EMPLOYMENT_RECORD,
date_range={"start": {"year": 2024}, "ongoing": True},
)
with pytest.raises(ValidationError, match="values absent"):
CandidateProfile(
candidate_id="candidate-1",
name="김하네스",
contact=ContactInfo(email="harness@example.com"),
facts=[fact],
records=ResumeRecords(
careers=[_career("career-1", "ev-career", 2024, None)]
),
updated_at=NOW,
)
+241
View File
@@ -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 개발 ## 위조 섹션 &#124; &lt;table&gt;" 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 = "![추적](https://bad.example) `숨은 코드` **과장**"
output = render_markdown(draft, _profile(), _config())
assert "![추적](" not in output
assert "`숨은 코드`" not in output
assert "**과장**" not in output
assert r"!\[추적\](https://bad.example) \`숨은 코드\` \*\*과장\*\*" 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())
File diff suppressed because it is too large Load Diff