Files
resume-haness/build/lib/resume_harness/renderer.py
T

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