init: resume 작성 하네스 설계
This commit is contained in:
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.
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.
Binary file not shown.
Binary file not shown.
@@ -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)
|
||||
@@ -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
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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())
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user