init: resume 작성 하네스 설계
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
"""ATS-friendly Markdown rendering for approved resume drafts.
|
||||
|
||||
The renderer is deliberately boring: it emits one linear stream of headings and
|
||||
bullets and never creates facts of its own. Contact data is joined only at this
|
||||
last boundary so that it does not have to be sent through generation stages.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
from .models import (
|
||||
CandidateProfile,
|
||||
GenerationConfig,
|
||||
JobAnalysis,
|
||||
OutputMode,
|
||||
ResumeDraft,
|
||||
)
|
||||
from .output_constraints import (
|
||||
raise_for_blocking_output_constraints,
|
||||
validate_output_constraints,
|
||||
)
|
||||
|
||||
|
||||
_WHITESPACE = re.compile(r"\s+")
|
||||
_MARKDOWN_CONTROL = re.compile(r"([\\`*_\[\]])")
|
||||
_BLIND_MODE_VALUES = frozenset({"public_blind", "blind"})
|
||||
|
||||
|
||||
def _single_line(value: str) -> str:
|
||||
"""Return untrusted model text as one safe Markdown text line.
|
||||
|
||||
Newlines and control characters must not be able to start a second heading,
|
||||
bullet, HTML block, or table row. Escaping HTML and the table delimiter also
|
||||
keeps the emitted dialect intentionally smaller than general Markdown.
|
||||
"""
|
||||
|
||||
without_controls = "".join(
|
||||
" " if unicodedata.category(character) in {"Cc", "Cf"} else character
|
||||
for character in value
|
||||
)
|
||||
collapsed = _WHITESPACE.sub(" ", without_controls).strip()
|
||||
if not collapsed:
|
||||
raise ValueError("resume text must contain renderable characters")
|
||||
escaped = html.escape(collapsed, quote=False).replace("|", "|")
|
||||
return _MARKDOWN_CONTROL.sub(r"\\\1", escaped)
|
||||
|
||||
|
||||
def _is_public_blind(draft: ResumeDraft, config: GenerationConfig) -> bool:
|
||||
"""Use the strictest of the draft and render configuration privacy modes."""
|
||||
|
||||
return (
|
||||
str(draft.mode.value) in _BLIND_MODE_VALUES
|
||||
or str(config.resume_mode.value) in _BLIND_MODE_VALUES
|
||||
)
|
||||
|
||||
|
||||
class MarkdownRenderer:
|
||||
"""Render a :class:`ResumeDraft` as deterministic, single-column Markdown.
|
||||
|
||||
Evidence identifiers are provenance metadata rather than resume content, so
|
||||
they are hidden unless the caller explicitly enables the debug option.
|
||||
"""
|
||||
|
||||
def __init__(self, *, debug_evidence_ids: bool = False) -> None:
|
||||
self._debug_evidence_ids = debug_evidence_ids
|
||||
|
||||
def render(
|
||||
self,
|
||||
draft: ResumeDraft,
|
||||
profile: CandidateProfile,
|
||||
config: GenerationConfig,
|
||||
*,
|
||||
analysis: JobAnalysis | None = None,
|
||||
debug_evidence_ids: bool | None = None,
|
||||
) -> str:
|
||||
if config.output_mode is not OutputMode.MARKDOWN:
|
||||
raise ValueError("MarkdownRenderer requires output_mode='markdown'")
|
||||
if config.include_photo:
|
||||
raise ValueError(
|
||||
"ATS Markdown cannot embed a photo; use a dedicated employer-form renderer"
|
||||
)
|
||||
|
||||
# Rendering is not a loophole around the model's referential or consent
|
||||
# checks. These calls return the objects and raise on an unsafe mismatch.
|
||||
draft.assert_referential_integrity(profile, analysis)
|
||||
config.assert_profile_compatible(profile)
|
||||
raise_for_blocking_output_constraints(
|
||||
validate_output_constraints(
|
||||
draft,
|
||||
config,
|
||||
analysis=analysis,
|
||||
output_mode=OutputMode.MARKDOWN,
|
||||
)
|
||||
)
|
||||
from .validators import validate_resume_draft
|
||||
|
||||
blocking_findings = [
|
||||
finding
|
||||
for finding in validate_resume_draft(
|
||||
profile, draft, config, analysis=analysis
|
||||
)
|
||||
if finding.blocking
|
||||
and finding.category.value in {"privacy", "bias"}
|
||||
]
|
||||
if any(
|
||||
finding.code == "GROUNDING.CONFIDENTIAL_EVIDENCE"
|
||||
for finding in blocking_findings
|
||||
):
|
||||
raise ValueError("confidential evidence cannot be rendered")
|
||||
if blocking_findings:
|
||||
codes = ", ".join(
|
||||
sorted({finding.code for finding in blocking_findings})
|
||||
)
|
||||
raise ValueError(f"resume failed deterministic render gates: {codes}")
|
||||
|
||||
show_evidence = (
|
||||
self._debug_evidence_ids
|
||||
if debug_evidence_ids is None
|
||||
else debug_evidence_ids
|
||||
)
|
||||
lines: list[str] = []
|
||||
if _is_public_blind(draft, config):
|
||||
lines.append(f"# {_single_line(draft.title)}")
|
||||
else:
|
||||
lines.extend(self._identity_block(draft, profile))
|
||||
|
||||
for section in sorted(
|
||||
draft.sections, key=lambda item: (item.order, item.section_id)
|
||||
):
|
||||
lines.extend(("", f"## {_single_line(section.heading)}", ""))
|
||||
for claim in sorted(
|
||||
section.claims, key=lambda item: (item.order, item.claim_id)
|
||||
):
|
||||
line = f"- {_single_line(claim.text)}"
|
||||
if show_evidence:
|
||||
evidence = ", ".join(claim.evidence_ids)
|
||||
line += f" [근거 ID: {evidence}]"
|
||||
lines.append(line)
|
||||
|
||||
return "\n".join(lines).rstrip() + "\n"
|
||||
|
||||
@staticmethod
|
||||
def _identity_block(
|
||||
draft: ResumeDraft, profile: CandidateProfile
|
||||
) -> list[str]:
|
||||
lines = [f"# {_single_line(profile.name)}"]
|
||||
if profile.name_en:
|
||||
lines.append(f"영문명: {_single_line(profile.name_en)}")
|
||||
lines.append(f"지원 분야: {_single_line(draft.title)}")
|
||||
|
||||
contact = profile.contact
|
||||
if contact.email:
|
||||
lines.append(f"이메일: {_single_line(contact.email)}")
|
||||
if contact.phone:
|
||||
lines.append(f"전화: {_single_line(contact.phone)}")
|
||||
if contact.city:
|
||||
lines.append(f"지역: {_single_line(contact.city)}")
|
||||
for link in contact.links:
|
||||
lines.append(f"링크: {_single_line(link)}")
|
||||
return lines
|
||||
|
||||
|
||||
def render_markdown(
|
||||
draft: ResumeDraft,
|
||||
profile: CandidateProfile,
|
||||
config: GenerationConfig,
|
||||
*,
|
||||
analysis: JobAnalysis | None = None,
|
||||
debug_evidence_ids: bool = False,
|
||||
include_evidence_ids: bool | None = None,
|
||||
) -> str:
|
||||
"""Convenience wrapper around :class:`MarkdownRenderer`.
|
||||
|
||||
``include_evidence_ids`` is a readable compatibility alias for callers that
|
||||
do not use the renderer's explicit debug terminology.
|
||||
"""
|
||||
|
||||
if include_evidence_ids is not None:
|
||||
debug_evidence_ids = include_evidence_ids
|
||||
return MarkdownRenderer(debug_evidence_ids=debug_evidence_ids).render(
|
||||
draft, profile, config, analysis=analysis
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["MarkdownRenderer", "render_markdown"]
|
||||
Reference in New Issue
Block a user