189 lines
6.4 KiB
Python
189 lines
6.4 KiB
Python
"""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"]
|