"""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"]