init: resume 작성 하네스 설계
This commit is contained in:
@@ -0,0 +1,510 @@
|
||||
"""Deterministic output-contract checks for resume drafts.
|
||||
|
||||
The language model may extract application rules, but it is not trusted to
|
||||
decide whether its own output follows them. This module keeps the measurable
|
||||
rules independent from prose-quality validation so the same checks can run in
|
||||
the pipeline, CLI, and renderer.
|
||||
|
||||
Character limits use NFC-normalised Unicode code points and count spaces plus
|
||||
one newline between bullets; section headings are excluded. That convention
|
||||
is deterministic, but an employer portal with a different counting convention
|
||||
still needs a dedicated adapter.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import unicodedata
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from typing import Iterable
|
||||
|
||||
from .models import (
|
||||
ConstraintKind,
|
||||
DraftClaim,
|
||||
DraftSection,
|
||||
GenerationConfig,
|
||||
JobAnalysis,
|
||||
OutputMode,
|
||||
QualityCategory,
|
||||
QualityFinding,
|
||||
QualitySeverity,
|
||||
ResumeDraft,
|
||||
SectionType,
|
||||
)
|
||||
|
||||
|
||||
_NON_WORD = re.compile(r"[^0-9a-z가-힣]+", flags=re.I)
|
||||
_FORMAT_SPLIT = re.compile(
|
||||
r"\s*(?:,|/|\||\ub610\ub294|\ud639\uc740|\bor\b)\s*", flags=re.I
|
||||
)
|
||||
_NUMERIC_DATE = re.compile(
|
||||
r"(?<!\d)(?P<year>(?:19|20)\d{2})(?P<sep>[./-])"
|
||||
r"(?P<month>\d{1,2})(?:(?P=sep)(?P<day>\d{1,2}))?(?!\d)"
|
||||
)
|
||||
_KOREAN_MONTH_DATE = re.compile(
|
||||
r"(?<!\d)(?P<year>(?:19|20)\d{2})\s*\ub144\s*"
|
||||
r"(?P<month>\d{1,2})\s*\uc6d4(?:\s*(?P<day>\d{1,2})\s*\uc77c)?"
|
||||
)
|
||||
|
||||
_SECTION_ALIASES: dict[SectionType, frozenset[str]] = {
|
||||
SectionType.SUMMARY: frozenset(
|
||||
{
|
||||
"summary",
|
||||
"profile",
|
||||
"\uc694\uc57d",
|
||||
"\ud575\uc2ec\uc694\uc57d",
|
||||
"\ud504\ub85c\ud544",
|
||||
"\uc9c0\uc6d0\uc790\uc694\uc57d",
|
||||
}
|
||||
),
|
||||
SectionType.CORE_COMPETENCIES: frozenset(
|
||||
{
|
||||
"corecompetencies",
|
||||
"competencies",
|
||||
"\ud575\uc2ec\uc5ed\ub7c9",
|
||||
"\uc9c1\ubb34\uc5ed\ub7c9",
|
||||
"\uc5ed\ub7c9",
|
||||
}
|
||||
),
|
||||
SectionType.EXPERIENCE: frozenset(
|
||||
{
|
||||
"experience",
|
||||
"workexperience",
|
||||
"\uacbd\ub825",
|
||||
"\uacbd\ub825\uc0ac\ud56d",
|
||||
"\uc5c5\ubb34\uacbd\ub825",
|
||||
"\uc9c1\uc7a5\uacbd\ub825",
|
||||
}
|
||||
),
|
||||
SectionType.PROJECTS: frozenset(
|
||||
{
|
||||
"projects",
|
||||
"project",
|
||||
"\ud504\ub85c\uc81d\ud2b8",
|
||||
"\uc8fc\uc694\ud504\ub85c\uc81d\ud2b8",
|
||||
"\ud504\ub85c\uc81d\ud2b8\uacbd\ud5d8",
|
||||
}
|
||||
),
|
||||
SectionType.EDUCATION: frozenset(
|
||||
{"education", "\ud559\ub825", "\ud559\ub825\uc0ac\ud56d", "\uad50\uc721", "\uad50\uc721\uc0ac\ud56d"}
|
||||
),
|
||||
SectionType.SKILLS: frozenset(
|
||||
{
|
||||
"skills",
|
||||
"skill",
|
||||
"\uae30\uc220",
|
||||
"\uae30\uc220\uc2a4\ud0dd",
|
||||
"\ubcf4\uc720\uae30\uc220",
|
||||
"\uc9c1\ubb34\uae30\uc220",
|
||||
}
|
||||
),
|
||||
SectionType.CERTIFICATIONS: frozenset(
|
||||
{
|
||||
"certifications",
|
||||
"certificates",
|
||||
"\uc790\uaca9",
|
||||
"\uc790\uaca9\uc99d",
|
||||
"\uc790\uaca9\uc0ac\ud56d",
|
||||
}
|
||||
),
|
||||
SectionType.AWARDS: frozenset(
|
||||
{"awards", "honors", "\uc218\uc0c1", "\uc218\uc0c1\uacbd\ub825", "\uc218\uc0c1\ub0b4\uc5ed"}
|
||||
),
|
||||
SectionType.LANGUAGES: frozenset(
|
||||
{"languages", "language", "\uc5b4\ud559", "\uc678\uad6d\uc5b4", "\uc5b4\ud559\ub2a5\ub825"}
|
||||
),
|
||||
SectionType.MILITARY_SERVICE: frozenset(
|
||||
{"militaryservice", "\ubcd1\uc5ed", "\ubcd1\uc5ed\uc0ac\ud56d"}
|
||||
),
|
||||
SectionType.OTHER: frozenset({"other", "\uae30\ud0c0"}),
|
||||
}
|
||||
|
||||
_FORMAT_ALIASES: dict[OutputMode, frozenset[str]] = {
|
||||
OutputMode.MARKDOWN: frozenset(
|
||||
{"md", "markdown", "textmarkdown", "\ub9c8\ud06c\ub2e4\uc6b4"}
|
||||
),
|
||||
OutputMode.JSON: frozenset({"json", "applicationjson"}),
|
||||
OutputMode.HTML: frozenset({"html", "htm", "texthtml"}),
|
||||
OutputMode.DOCX: frozenset(
|
||||
{"docx", "word", "msword", "wordprocessingml", "\uc6cc\ub4dc"}
|
||||
),
|
||||
OutputMode.PDF: frozenset({"pdf", "applicationpdf"}),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OutputConstraintIssue:
|
||||
"""One deterministic violation or unsupported blocking requirement."""
|
||||
|
||||
code: str
|
||||
message: str
|
||||
category: QualityCategory = QualityCategory.FORMATTING
|
||||
location: str | None = None
|
||||
claim_id: str | None = None
|
||||
evidence_ids: tuple[str, ...] = ()
|
||||
suggestion: str | None = None
|
||||
blocking: bool = True
|
||||
|
||||
|
||||
class OutputConstraintError(ValueError):
|
||||
"""Raised when a renderer would emit a contract-breaking document."""
|
||||
|
||||
def __init__(self, issues: Iterable[OutputConstraintIssue]) -> None:
|
||||
self.issues = tuple(issue for issue in issues if issue.blocking)
|
||||
details = "; ".join(f"{issue.code}: {issue.message}" for issue in self.issues)
|
||||
super().__init__(details or "output constraint validation failed")
|
||||
|
||||
|
||||
def _normalise_key(value: str) -> str:
|
||||
return _NON_WORD.sub("", unicodedata.normalize("NFKC", value).casefold())
|
||||
|
||||
|
||||
def _section_keys(section: DraftSection) -> frozenset[str]:
|
||||
aliases = _SECTION_ALIASES.get(section.section_type, frozenset())
|
||||
return frozenset(
|
||||
{
|
||||
_normalise_key(section.heading),
|
||||
_normalise_key(section.section_type.value),
|
||||
*(_normalise_key(alias) for alias in aliases),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _matching_sections(draft: ResumeDraft, reference: str) -> list[DraftSection]:
|
||||
target = _normalise_key(reference)
|
||||
if not target:
|
||||
return []
|
||||
return [section for section in draft.sections if target in _section_keys(section)]
|
||||
|
||||
|
||||
def count_section_characters(sections: Iterable[DraftSection]) -> int:
|
||||
"""Count semantic section content using the documented portal-neutral rule."""
|
||||
|
||||
texts = [
|
||||
unicodedata.normalize("NFC", claim.text).replace("\r\n", "\n").replace("\r", "\n")
|
||||
for section in sections
|
||||
for claim in sorted(section.claims, key=lambda item: (item.order, item.claim_id))
|
||||
]
|
||||
return len("\n".join(texts))
|
||||
|
||||
|
||||
def _last_claim(sections: Iterable[DraftSection]) -> DraftClaim | None:
|
||||
claims = [claim for section in sections for claim in section.claims]
|
||||
if not claims:
|
||||
return None
|
||||
return max(claims, key=lambda item: (item.order, item.claim_id))
|
||||
|
||||
|
||||
def _format_tokens(values: Iterable[str]) -> frozenset[str]:
|
||||
tokens: set[str] = set()
|
||||
for value in values:
|
||||
for part in _FORMAT_SPLIT.split(value):
|
||||
token = _normalise_key(part.removeprefix("."))
|
||||
if token.endswith("\ud30c\uc77c"):
|
||||
token = token[: -len("\ud30c\uc77c")]
|
||||
if token:
|
||||
tokens.add(token)
|
||||
return frozenset(tokens)
|
||||
|
||||
|
||||
def _mode_format_tokens(mode: OutputMode) -> frozenset[str]:
|
||||
return frozenset({_normalise_key(mode.value), *_FORMAT_ALIASES[mode]})
|
||||
|
||||
|
||||
def _date_targets(draft: ResumeDraft) -> Iterable[tuple[str, str, DraftClaim | None]]:
|
||||
yield "title", draft.title, None
|
||||
for section in draft.sections:
|
||||
yield f"sections.{section.section_id}.heading", section.heading, None
|
||||
for claim in section.claims:
|
||||
yield f"claims.{claim.claim_id}.text", claim.text, claim
|
||||
|
||||
|
||||
def _expected_date(year: int, month: int, day: int | None, pattern: str) -> str | None:
|
||||
try:
|
||||
if pattern == "YYYY.MM":
|
||||
if day is not None:
|
||||
return None
|
||||
date(year, month, 1)
|
||||
return f"{year:04d}.{month:02d}"
|
||||
if day is None:
|
||||
return None
|
||||
return date(year, month, day).strftime("%Y.%m.%d")
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _date_issues(draft: ResumeDraft, config: GenerationConfig) -> list[OutputConstraintIssue]:
|
||||
issues: list[OutputConstraintIssue] = []
|
||||
for location, text, claim in _date_targets(draft):
|
||||
claim_kwargs = {
|
||||
"claim_id": claim.claim_id if claim else None,
|
||||
"evidence_ids": tuple(claim.evidence_ids) if claim else (),
|
||||
}
|
||||
for match in _NUMERIC_DATE.finditer(text):
|
||||
day = int(match.group("day")) if match.group("day") else None
|
||||
expected = _expected_date(
|
||||
int(match.group("year")), int(match.group("month")), day, config.date_format
|
||||
)
|
||||
if expected == match.group(0):
|
||||
continue
|
||||
issues.append(
|
||||
OutputConstraintIssue(
|
||||
code="OUTPUT.DATE_FORMAT",
|
||||
message=(
|
||||
f"\ub0a0\uc9dc {match.group(0)!r}\uc774(\uac00) \uc124\uc815 {config.date_format}\uc640 "
|
||||
"\uc77c\uce58\ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4."
|
||||
),
|
||||
category=QualityCategory.CHRONOLOGY,
|
||||
location=location,
|
||||
suggestion=f"\ub0a0\uc9dc\ub97c {config.date_format} \ud615\uc2dd\uc73c\ub85c \ud1b5\uc77c\ud558\uc138\uc694.",
|
||||
**claim_kwargs,
|
||||
)
|
||||
)
|
||||
for match in _KOREAN_MONTH_DATE.finditer(text):
|
||||
day = int(match.group("day")) if match.group("day") else None
|
||||
expected = _expected_date(
|
||||
int(match.group("year")), int(match.group("month")), day, config.date_format
|
||||
)
|
||||
issues.append(
|
||||
OutputConstraintIssue(
|
||||
code="OUTPUT.DATE_FORMAT",
|
||||
message=(
|
||||
f"\ub0a0\uc9dc {match.group(0)!r}\uc774(\uac00) \uc124\uc815 {config.date_format}\uc640 "
|
||||
"\uc77c\uce58\ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4."
|
||||
),
|
||||
category=QualityCategory.CHRONOLOGY,
|
||||
location=location,
|
||||
suggestion=(
|
||||
f"\ub0a0\uc9dc\ub97c {expected or config.date_format} \ud615\uc2dd\uc73c\ub85c \ud1b5\uc77c\ud558\uc138\uc694."
|
||||
),
|
||||
**claim_kwargs,
|
||||
)
|
||||
)
|
||||
return issues
|
||||
|
||||
|
||||
def _section_order_issues(
|
||||
draft: ResumeDraft, config: GenerationConfig
|
||||
) -> list[OutputConstraintIssue]:
|
||||
rank = {section_type: index for index, section_type in enumerate(config.section_order)}
|
||||
ordered = sorted(draft.sections, key=lambda item: (item.order, item.section_id))
|
||||
previous: DraftSection | None = None
|
||||
previous_rank = -1
|
||||
for section in ordered:
|
||||
current_rank = rank.get(section.section_type)
|
||||
if current_rank is None:
|
||||
continue
|
||||
if current_rank < previous_rank and previous is not None:
|
||||
return [
|
||||
OutputConstraintIssue(
|
||||
code="OUTPUT.SECTION_ORDER",
|
||||
message=(
|
||||
f"\uc139\uc158 {section.heading!r}\uc774(\uac00) \uc124\uc815\ub41c section_order\uc0c1 "
|
||||
f"{previous.heading!r} \ub4a4\uc5d0 \uc62c \uc218 \uc5c6\uc2b5\ub2c8\ub2e4."
|
||||
),
|
||||
location=f"sections.{section.section_id}.order",
|
||||
suggestion="\ucf58\ud150\uce20 \uacc4\ud68d\uacfc \ucd08\uc548\uc758 \uc139\uc158 \uc21c\uc11c\ub97c \uc124\uc815\uacfc \ub9de\ucd94\uc138\uc694.",
|
||||
)
|
||||
]
|
||||
previous = section
|
||||
previous_rank = current_rank
|
||||
return []
|
||||
|
||||
|
||||
def _posting_issues(
|
||||
draft: ResumeDraft,
|
||||
analysis: JobAnalysis,
|
||||
output_mode: OutputMode,
|
||||
) -> list[OutputConstraintIssue]:
|
||||
issues: list[OutputConstraintIssue] = []
|
||||
for constraint in analysis.constraints:
|
||||
severity_blocking = constraint.blocking
|
||||
location = f"analysis.constraints.{constraint.constraint_id}"
|
||||
|
||||
if constraint.kind is ConstraintKind.REQUIRED_SECTION:
|
||||
references = [constraint.section] if constraint.section else list(constraint.fields)
|
||||
references = [reference for reference in references if reference]
|
||||
if not references:
|
||||
issues.append(
|
||||
OutputConstraintIssue(
|
||||
code="OUTPUT.CONSTRAINT_MALFORMED",
|
||||
message=(
|
||||
f"\ud544\uc218 \uc139\uc158 \uc81c\uc57d {constraint.constraint_id!r}\uc5d0 section \ub610\ub294 "
|
||||
"fields\uac00 \uc5c6\uc2b5\ub2c8\ub2e4."
|
||||
),
|
||||
location=location,
|
||||
blocking=severity_blocking,
|
||||
suggestion="\uacf5\uace0 \uc6d0\ubb38\uc5d0\uc11c \ud544\uc218 \uc139\uc158\uba85\uc744 \ub2e4\uc2dc \ucd94\ucd9c\ud558\uc138\uc694.",
|
||||
)
|
||||
)
|
||||
continue
|
||||
for reference in references:
|
||||
if _matching_sections(draft, reference):
|
||||
continue
|
||||
issues.append(
|
||||
OutputConstraintIssue(
|
||||
code="OUTPUT.REQUIRED_SECTION",
|
||||
message=(
|
||||
f"\uacf5\uace0\uac00 \uc694\uad6c\ud55c \uc139\uc158 {reference!r}\uc774(\uac00) \ucd08\uc548\uc5d0 \uc5c6\uc2b5\ub2c8\ub2e4 "
|
||||
f"({constraint.constraint_id})."
|
||||
),
|
||||
location=location,
|
||||
blocking=severity_blocking,
|
||||
suggestion="\uadfc\uac70\uac00 \uc788\ub294 \ud574\ub2f9 \uc139\uc158\uc744 \ucf58\ud150\uce20 \uacc4\ud68d\uc5d0 \ucd94\uac00\ud558\uc138\uc694.",
|
||||
)
|
||||
)
|
||||
|
||||
elif constraint.kind is ConstraintKind.CHARACTER_LIMIT:
|
||||
# PostingConstraint validation guarantees both values, but the
|
||||
# defensive guard keeps this module safe for future schema changes.
|
||||
if not constraint.section or constraint.max_characters is None:
|
||||
continue
|
||||
sections = _matching_sections(draft, constraint.section)
|
||||
if not sections:
|
||||
issues.append(
|
||||
OutputConstraintIssue(
|
||||
code="OUTPUT.CONSTRAINT_SECTION_UNKNOWN",
|
||||
message=(
|
||||
f"\uae00\uc790 \uc218 \uc81c\uc57d\uc758 \uc139\uc158 {constraint.section!r}\uc744(\ub97c) "
|
||||
f"\ucd08\uc548\uc5d0\uc11c \ud655\uc778\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4 ({constraint.constraint_id})."
|
||||
),
|
||||
location=location,
|
||||
blocking=severity_blocking,
|
||||
suggestion="\uacf5\uace0\uc758 \uc139\uc158\uba85\uacfc \ucd08\uc548 heading\uc744 \uc77c\uce58\uc2dc\ud0a4\uc138\uc694.",
|
||||
)
|
||||
)
|
||||
continue
|
||||
actual = count_section_characters(sections)
|
||||
if actual <= constraint.max_characters:
|
||||
continue
|
||||
claim = _last_claim(sections)
|
||||
issues.append(
|
||||
OutputConstraintIssue(
|
||||
code="OUTPUT.CHARACTER_LIMIT",
|
||||
message=(
|
||||
f"{constraint.section!r} \uc139\uc158\uc774 {actual}\uc790\ub85c \ucd5c\ub300 "
|
||||
f"{constraint.max_characters}\uc790\ub97c \ucd08\uacfc\ud569\ub2c8\ub2e4 "
|
||||
"(NFC, \uacf5\ubc31\u00b7\uc904\ubc14\uafc8 \ud3ec\ud568)."
|
||||
),
|
||||
location=location,
|
||||
claim_id=claim.claim_id if claim else None,
|
||||
evidence_ids=tuple(claim.evidence_ids) if claim else (),
|
||||
blocking=severity_blocking,
|
||||
suggestion="\uc0ac\uc2e4 \uadfc\uac70\ub97c \uc720\uc9c0\ud558\uba74\uc11c \uc911\ubcf5\uacfc \uc218\uc2dd\uc5b4\ub97c \uc904\uc774\uc138\uc694.",
|
||||
)
|
||||
)
|
||||
|
||||
elif constraint.kind is ConstraintKind.FILE_FORMAT:
|
||||
allowed = _format_tokens(constraint.formats)
|
||||
if allowed & _mode_format_tokens(output_mode):
|
||||
continue
|
||||
issues.append(
|
||||
OutputConstraintIssue(
|
||||
code="OUTPUT.FILE_FORMAT",
|
||||
message=(
|
||||
f"\ucd9c\ub825 \ud615\uc2dd {output_mode.value!r}\uc774(\uac00) \uacf5\uace0 \ud5c8\uc6a9 \ud615\uc2dd "
|
||||
f"{constraint.formats!r}\uc5d0 \ud3ec\ud568\ub418\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4 "
|
||||
f"({constraint.constraint_id})."
|
||||
),
|
||||
location=location,
|
||||
blocking=severity_blocking,
|
||||
suggestion="\uacf5\uace0\uac00 \ud5c8\uc6a9\ud55c \ud30c\uc77c \ud615\uc2dd\uc758 \uc804\uc6a9 \ub80c\ub354\ub7ec\ub97c \uc0ac\uc6a9\ud558\uc138\uc694.",
|
||||
)
|
||||
)
|
||||
|
||||
elif constraint.kind is ConstraintKind.EMPLOYER_TEMPLATE:
|
||||
issues.append(
|
||||
OutputConstraintIssue(
|
||||
code="OUTPUT.EMPLOYER_TEMPLATE_UNVERIFIED",
|
||||
message=(
|
||||
f"\uc9c0\uc815 \uc591\uc2dd \uc81c\uc57d {constraint.constraint_id!r}\uc740 \ubc94\uc6a9 \ucd08\uc548\uc73c\ub85c "
|
||||
"\uac80\uc99d\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4."
|
||||
),
|
||||
location=location,
|
||||
blocking=severity_blocking,
|
||||
suggestion="\uae30\uad00\uc774 \uc81c\uacf5\ud55c \uc6d0\ubcf8 \uc591\uc2dd \uc804\uc6a9 \uc5b4\ub311\ud130\ub85c \uac80\uc99d\ud558\uc138\uc694.",
|
||||
)
|
||||
)
|
||||
|
||||
elif constraint.kind is ConstraintKind.OTHER:
|
||||
issues.append(
|
||||
OutputConstraintIssue(
|
||||
code="OUTPUT.UNSUPPORTED_BLOCKING_CONSTRAINT",
|
||||
message=(
|
||||
f"제약 {constraint.constraint_id!r}은 결정적으로 검증할 "
|
||||
"수 있는 유형으로 구조화되지 않았습니다."
|
||||
),
|
||||
location=location,
|
||||
blocking=severity_blocking,
|
||||
suggestion=(
|
||||
"공고 원문에서 지원되는 제약 유형으로 다시 추출하거나 "
|
||||
"전용 검증기를 연결하세요."
|
||||
),
|
||||
)
|
||||
)
|
||||
return issues
|
||||
|
||||
|
||||
def validate_output_constraints(
|
||||
draft: ResumeDraft,
|
||||
config: GenerationConfig,
|
||||
*,
|
||||
analysis: JobAnalysis | None = None,
|
||||
output_mode: OutputMode | None = None,
|
||||
) -> list[OutputConstraintIssue]:
|
||||
"""Return deterministic draft/output contract issues in stable order.
|
||||
|
||||
``max_pages`` is intentionally not estimated here. Markdown, HTML, and
|
||||
JSON have no physical pagination, and guessing pages from character counts
|
||||
would create a false release guarantee. A DOCX/PDF renderer must measure
|
||||
the laid-out artifact and enforce ``max_pages`` in its own postflight.
|
||||
"""
|
||||
|
||||
effective_output_mode = output_mode or config.output_mode
|
||||
issues = [
|
||||
*_section_order_issues(draft, config),
|
||||
*_date_issues(draft, config),
|
||||
]
|
||||
if analysis is not None:
|
||||
issues.extend(_posting_issues(draft, analysis, effective_output_mode))
|
||||
return issues
|
||||
|
||||
|
||||
def as_quality_findings(
|
||||
issues: Iterable[OutputConstraintIssue],
|
||||
) -> list[QualityFinding]:
|
||||
"""Adapt output issues to the pipeline's repair and release-gate contract."""
|
||||
|
||||
return [
|
||||
QualityFinding(
|
||||
finding_id=f"output-constraint-{index:04d}",
|
||||
code=issue.code,
|
||||
severity=(QualitySeverity.ERROR if issue.blocking else QualitySeverity.WARNING),
|
||||
category=issue.category,
|
||||
message=issue.message,
|
||||
location=issue.location,
|
||||
claim_id=issue.claim_id,
|
||||
evidence_ids=list(issue.evidence_ids),
|
||||
suggestion=issue.suggestion,
|
||||
)
|
||||
for index, issue in enumerate(issues, start=1)
|
||||
]
|
||||
|
||||
|
||||
def raise_for_blocking_output_constraints(
|
||||
issues: Iterable[OutputConstraintIssue],
|
||||
) -> None:
|
||||
blocking = [issue for issue in issues if issue.blocking]
|
||||
if blocking:
|
||||
raise OutputConstraintError(blocking)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"OutputConstraintError",
|
||||
"OutputConstraintIssue",
|
||||
"as_quality_findings",
|
||||
"count_section_characters",
|
||||
"raise_for_blocking_output_constraints",
|
||||
"validate_output_constraints",
|
||||
]
|
||||
Reference in New Issue
Block a user