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

2298 lines
82 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Domain models for an evidence-grounded Korean resume generation harness.
The models deliberately keep source evidence, job requirements, generated claims,
and quality findings as separate concepts. That separation makes unsupported
claims and accidental use of sensitive personal data detectable before rendering.
"""
from __future__ import annotations
import calendar
import hashlib
import json
import re
import unicodedata
from datetime import date, datetime, timezone
from enum import StrEnum
from typing import Annotated, Literal, Self
from pydantic import (
AwareDatetime,
BaseModel,
ConfigDict,
Field,
StringConstraints,
computed_field,
field_validator,
model_validator,
)
from .records import EmploymentType, ResumeRecords, StructuredRecord
Identifier = Annotated[
str,
StringConstraints(
strip_whitespace=True,
min_length=1,
max_length=128,
pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]*$",
),
]
NonEmptyText = Annotated[
str, StringConstraints(strip_whitespace=True, min_length=1, max_length=20_000)
]
ShortText = Annotated[
str, StringConstraints(strip_whitespace=True, min_length=1, max_length=300)
]
def _utc_now() -> datetime:
return datetime.now(timezone.utc)
def _duplicates(values: list[str]) -> list[str]:
"""Return duplicates in stable order, comparing identifiers literally."""
seen: set[str] = set()
duplicates: list[str] = []
for value in values:
if value in seen and value not in duplicates:
duplicates.append(value)
seen.add(value)
return duplicates
def _normalised_duplicates(values: list[str]) -> list[str]:
seen: set[str] = set()
duplicates: list[str] = []
for value in values:
normalised = value.casefold()
if normalised in seen and normalised not in duplicates:
duplicates.append(normalised)
seen.add(normalised)
return duplicates
class DomainModel(BaseModel):
"""Strict base used by all externally exchanged harness data."""
model_config = ConfigDict(
extra="forbid",
str_strip_whitespace=True,
validate_assignment=True,
)
class OutputMode(StrEnum):
MARKDOWN = "markdown"
JSON = "json"
HTML = "html"
DOCX = "docx"
PDF = "pdf"
class ResumeMode(StrEnum):
PRIVATE_MODERN = "private_modern"
PUBLIC_BLIND = "public_blind"
EMPLOYER_FORM = "employer_form"
class EvidenceCategory(StrEnum):
CAREER = "career"
PROJECT = "project"
EDUCATION = "education"
SKILL = "skill"
CERTIFICATION = "certification"
AWARD = "award"
PUBLICATION = "publication"
LANGUAGE = "language"
VOLUNTEER = "volunteer"
MILITARY_SERVICE = "military_service"
OTHER = "other"
class EvidenceSource(StrEnum):
USER_STATEMENT = "user_statement"
DOCUMENT = "document"
PORTFOLIO = "portfolio"
CERTIFICATE = "certificate"
EMPLOYMENT_RECORD = "employment_record"
PUBLIC_URL = "public_url"
IMPORTED_RESUME = "imported_resume"
class VerificationStatus(StrEnum):
UNVERIFIED = "unverified"
SELF_REPORTED = "self_reported"
DOCUMENT_VERIFIED = "document_verified"
EXTERNALLY_VERIFIED = "externally_verified"
class SensitiveDataCategory(StrEnum):
PHOTO = "photo"
BIRTH_DATE = "birth_date"
GENDER = "gender"
FULL_ADDRESS = "full_address"
MARITAL_STATUS = "marital_status"
FAMILY_DETAILS = "family_details"
RELIGION = "religion"
DISABILITY = "disability"
HEALTH = "health"
MILITARY_DETAILS = "military_details"
COMPENSATION = "compensation"
POLITICAL_OPINION = "political_opinion"
PROPERTY = "property"
NATIONAL_ID = "national_id"
BANK_ACCOUNT = "bank_account"
PROHIBITED_SENSITIVE_CATEGORIES = frozenset(
{
SensitiveDataCategory.NATIONAL_ID,
SensitiveDataCategory.BANK_ACCOUNT,
SensitiveDataCategory.HEALTH,
SensitiveDataCategory.POLITICAL_OPINION,
SensitiveDataCategory.PROPERTY,
}
)
_KOREAN_RESIDENT_ID_PATTERN = re.compile(r"(?<!\d)\d{6}\s*-\s*[1-8]\d{6}(?!\d)")
_EVIDENCE_EMAIL_PATTERN = re.compile(
r"(?<![\w.+-])[A-Z0-9.!#$%&'*+/=?^_`{|}~-]+@"
r"(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+[A-Z]{2,63}",
re.IGNORECASE,
)
_EVIDENCE_PHONE_PATTERN = re.compile(
r"(?<!\d)(?:(?:\+|00)82[\s.-]?)?"
r"(?:0?(?:1[016789]|2|3[1-3]|4[1-4]|5[1-5]|6[1-4]|70))"
r"[\s.)-]?\d{3,4}[\s.-]?\d{4}(?!\d)"
)
_EVIDENCE_BANK_PATTERN = re.compile(
r"(?:(?:계좌(?:\s*번호)?|은행\s*계좌)\s*[:]?\s*\d[\d\s-]{7,}\d|"
r"bank[_\s-]?account(?:[_\s-]?(?:number|no))?\s*[:=_-]\s*[A-Z0-9-]{8,})",
re.IGNORECASE,
)
_EVIDENCE_PASSPORT_PATTERN = re.compile(
r"(?:passport|여권)(?:[_\s-]*(?:number|no|번호))"
r"\s*[:=_-]?\s*[A-Z][A-Z0-9]{6,11}",
re.IGNORECASE,
)
_EVIDENCE_SECRET_KEY_PATTERN = re.compile(
r"(?:^|[_\s.-])(?:api[_-]?key|access[_-]?token|auth[_-]?token|"
r"refresh[_-]?token|id[_-]?token|authorization|password|passwd|secret|"
r"private[_-]?key|client[_-]?secret|session[_-]?cookie|cookie|jwt|"
r"x[_-]?amz[_-]?signature|credential|signature)(?:$|[_\s.-])",
re.IGNORECASE,
)
_EVIDENCE_SECRET_VALUE_PATTERN = re.compile(
r"(?:\b(?:Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{8,}|"
r"(?:[?&]|\b)(?:api[_-]?key|access[_-]?token|auth[_-]?token|"
r"refresh[_-]?token|id[_-]?token|token|authorization|password|passwd|"
r"client[_-]?secret|session[_-]?cookie|cookie|jwt|x[_-]?amz[_-]?signature|"
r"credential|signature|secret)\s*(?:=|:)\s*[^\s&]{6,}|"
r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}|"
r"\b(?:sk-(?:live|test)-|sk-|gh[pousr]_)[A-Za-z0-9_-]{8,}|"
r"\bAKIA[A-Z0-9]{16}\b|"
r"-----BEGIN\s+[A-Z ]*PRIVATE KEY-----)",
re.IGNORECASE,
)
_EVIDENCE_HEALTH_TERM_PATTERN = re.compile(
r"(?:HIV|AIDS|후천성면역결핍|질병명|진단명|건강정보|"
r"health[_\s-]?status|medical[_\s-]?(?:condition|history)|diagnosis)",
re.IGNORECASE,
)
_SEMANTIC_TOKEN_PATTERN = re.compile(
r"[A-Za-z][A-Za-z0-9]*(?:[.+#/-][A-Za-z0-9]+)*|[가-힣]{2,}|\d+(?:\.\d+)?"
)
_HIGH_SIGNAL_ASCII_PATTERN = re.compile(
r"(?<![A-Za-z0-9_])(?:\.NET|[A-Za-z][A-Za-z0-9]*"
r"(?:\+\+|#)?(?:[./-][A-Za-z0-9+#]+)*)(?![A-Za-z0-9_])",
re.IGNORECASE,
)
_HIGH_SIGNAL_QUANTITY_PATTERN = re.compile(
r"(?<!\d)\d+(?:\.\d+)?\s*(?:년|개월|시간|회|급|점|자|명|건)(?![가-힣])"
)
_REQUIRED_MARKER_PATTERN = re.compile(
r"(?:필수|자격\s*요건|지원\s*자격|반드시|must|required)", re.IGNORECASE
)
_PREFERRED_MARKER_PATTERN = re.compile(
r"(?:우대|가점|preferred|nice\s+to\s+have|plus)", re.IGNORECASE
)
_CLASSIFICATION_SECTION_BOUNDARY_PATTERN = re.compile(
r"(?:^|[\n/|•])\s*(?:"
r"주요\s*업무|담당\s*업무|업무\s*내용|직무\s*내용|"
r"직무\s*소개|하는\s*일|필수\s*(?:사항|요건)|"
r"우대\s*(?:사항|요건)|자격\s*요건|지원\s*자격|"
r"기타\s*사항|근무\s*조건|전형\s*절차|제출\s*서류"
r")\s*(?:[:]|(?=[\n/|•])|$)",
re.IGNORECASE | re.MULTILINE,
)
_NON_BLOCKING_CONSTRAINT_MARKER_PATTERN = re.compile(
r"(?:권장|선택|가능|예정|예시|참고|추후\s*안내)"
)
_EXPLICIT_BLOCKING_SUBMISSION_PATTERNS: tuple[
tuple[str, re.Pattern[str]], ...
] = (
(
"character_limit",
re.compile(
r"(?<!\d)\d[\d,]*\s*(?:자|글자)\s*(?:이내|이하|미만|제한)"
),
),
(
"character_limit",
re.compile(
r"(?:자기소개(?:서)?|경력\s*기술서|지원\s*동기).{0,16}"
r"(?<!\d)\d[\d,]*\s*(?:자|글자)(?![가-힣])"
),
),
(
"file_format",
re.compile(
r"(?:제출|첨부)\s*(?:파일\s*)?(?:형식|포맷|확장자)\s*[:]?\s*"
r"(?:pdf|docx?|hwp[x]?|markdown|md|html|odt|rtf|txt|zip)\b",
re.IGNORECASE,
),
),
(
"file_format",
re.compile(
r"\b(?:pdf|docx?|hwp[x]?|markdown|md|html|odt|rtf|txt|zip)\b"
r"(?:\s*(?:또는|/|,)\s*\b(?:pdf|docx?|hwp[x]?|markdown|md|html|odt|rtf|txt|zip)\b)*"
r"\s*(?:파일|형식|포맷)?(?:로(?:만)?)?\s*(?:제출|첨부)",
re.IGNORECASE,
),
),
(
"employer_template",
re.compile(
r"(?:지정|첨부|소정)\s*(?:양식|서식).{0,20}"
r"(?:사용|작성|제출|필수|준수)"
),
),
(
"required_section",
re.compile(
r"(?:자기소개(?:서)?|경력\s*기술서|지원\s*동기).{0,20}"
r"(?:필수\s*(?:작성|기재)|반드시\s*(?:작성|기재))"
),
),
(
"privacy",
re.compile(
r"(?:학교명|출신\s*학교|성별|나이|연령|생년월일|사진|"
r"가족|출신지|성명|회사명|기관명).{0,12}"
r"(?:기재|작성|포함)\s*(?:금지|불가)"
),
),
)
_FORMAT_ALIASES: dict[str, str] = {
"pdf": "pdf",
"doc": "doc",
"docx": "docx",
"hwp": "hwp",
"hwpx": "hwpx",
"markdown": "markdown",
"md": "markdown",
"마크다운": "markdown",
"html": "html",
"txt": "txt",
"rtf": "rtf",
"odt": "odt",
"zip": "zip",
"한글": "hwp",
"워드": "docx",
}
_CONSTRAINT_SCOPE_STOPWORDS = frozenset(
{
"파일",
"형식",
"포맷",
"확장자",
"허용",
"지원",
"제한",
"조건",
"또는",
"및",
}
)
_TECH_REQUIREMENT_CONTEXT_TERMS = frozenset(
{
"기반",
"서비스",
"서버",
"시스템",
"플랫폼",
"파이프라인",
"프레임워크",
"도구",
"기술",
"사용",
"운영",
"개선",
"구축",
"설계",
"구현",
}
)
_SEMANTIC_STOPWORDS = frozenset(
{
"경력",
"경험",
"관련",
"역량",
"업무",
"담당",
"수행",
"개발",
"활용",
"이해",
"가능",
"필수",
"우대",
"이상",
"요구",
"사항",
"기재",
"금지",
"제출",
"required",
"preferred",
"experience",
"skill",
"and",
"or",
"the",
"with",
"using",
"years",
"year",
}
)
def _contains_identity_echo(text: str, identity: str) -> bool:
"""Detect an identity token without rejecting ordinary Korean morphology."""
if not identity:
return False
escaped = re.escape(identity)
if re.search(r"[가-힣]", identity):
compact_identity = re.sub(r"\s+", "", identity)
flexible_identity = r"\s*".join(
re.escape(character) for character in compact_identity
)
return (
re.search(
rf"(?<![가-힣A-Za-z0-9]){flexible_identity}"
rf"(?=(?:은|는|이|가|을|를|의|에게|께서|으로|입니다|이라고|"
rf"[\s,.)]|$))",
text,
re.IGNORECASE,
)
is not None
)
return re.search(rf"(?<!\w){escaped}(?!\w)", text, re.IGNORECASE) is not None
def _semantic_anchors(text: str) -> set[str]:
normalised = unicodedata.normalize("NFKC", text).casefold()
anchors: set[str] = set()
korean_suffixes = (
"에서는",
"으로는",
"에게서",
"께서는",
"에서",
"으로",
"에게",
"께서",
"부터",
"까지",
"처럼",
"보다",
"이나",
"이나마",
"은",
"는",
"이",
"가",
"을",
"를",
"의",
"에",
"와",
"과",
"도",
)
for token in _SEMANTIC_TOKEN_PATTERN.findall(normalised):
variants = {token}
if re.fullmatch(r"[가-힣]+", token):
for suffix in korean_suffixes:
if token.endswith(suffix) and len(token) - len(suffix) >= 2:
variants.add(token[: -len(suffix)])
break
anchors.update(
variant
for variant in variants
if variant not in _SEMANTIC_STOPWORDS and len(variant) >= 2
)
return anchors
def _high_signal_tokens(text: str) -> set[str]:
"""Extract exact technology/credential-like tokens and typed quantities."""
normalised = unicodedata.normalize("NFKC", text).casefold()
ascii_tokens = {
token.casefold()
for token in _HIGH_SIGNAL_ASCII_PATTERN.findall(normalised)
if token.casefold() not in _SEMANTIC_STOPWORDS
}
quantities = {
re.sub(r"\s+", "", token)
for token in _HIGH_SIGNAL_QUANTITY_PATTERN.findall(normalised)
}
return ascii_tokens | quantities
def _source_quote_occurs(source: str, quote: str) -> bool:
"""Match a contiguous quote without accepting ASCII token substrings."""
normalised_source = re.sub(
r"\s+", " ", unicodedata.normalize("NFKC", source)
).casefold()
normalised_quote = re.sub(
r"\s+", " ", unicodedata.normalize("NFKC", quote)
).strip().casefold()
escaped = re.escape(normalised_quote).replace(r"\ ", r"\s+")
prefix = r"(?<![a-z0-9_])" if normalised_quote[0].isascii() and normalised_quote[0].isalnum() else ""
suffix = r"(?![a-z0-9_])" if normalised_quote[-1].isascii() and normalised_quote[-1].isalnum() else ""
return re.search(prefix + escaped + suffix, normalised_source) is not None
def _phrase_spans(text: str, phrase: str) -> list[tuple[int, int]]:
"""Locate a phrase while tolerating Korean layout whitespace differences."""
normalised_text = unicodedata.normalize("NFKC", text).casefold()
compact_phrase = re.sub(
r"\s+", "", unicodedata.normalize("NFKC", phrase).casefold()
)
if not compact_phrase:
return []
pattern = r"\s*".join(re.escape(character) for character in compact_phrase)
if compact_phrase[0].isascii() and compact_phrase[0].isalnum():
pattern = r"(?<![a-z0-9_])" + pattern
if compact_phrase[-1].isascii() and compact_phrase[-1].isalnum():
pattern += r"(?![a-z0-9_])"
return [match.span() for match in re.finditer(pattern, normalised_text)]
def _span_distance(
first: tuple[int, int], second: tuple[int, int]
) -> int:
if first[1] < second[0]:
return second[0] - first[1]
if second[1] < first[0]:
return first[0] - second[1]
return 0
def _value_is_closest_to_scope(
scope_spans: list[tuple[int, int]],
selected_spans: list[tuple[int, int]],
alternative_spans: list[tuple[int, int]],
) -> bool:
"""Bind a typed value to its local subject, not another nearby subject."""
if not scope_spans or not selected_spans:
return False
selected_distance = min(
_span_distance(scope, value)
for scope in scope_spans
for value in selected_spans
)
if not alternative_spans:
return True
alternative_distance = min(
_span_distance(scope, value)
for scope in scope_spans
for value in alternative_spans
)
# A tie is ambiguous and therefore cannot support a blocking constraint.
return selected_distance < alternative_distance
def _classification_marker_is_local(
classification_quote: str,
source_quote: str,
marker: re.Pattern[str],
) -> bool:
"""Ensure a required/preferred heading does not cross another section."""
normalised = unicodedata.normalize("NFKC", classification_quote).casefold()
source_spans = _phrase_spans(normalised, source_quote)
marker_spans = [match.span() for match in marker.finditer(normalised)]
for source_span in source_spans:
for marker_span in marker_spans:
if marker_span[1] > source_span[0]:
continue
between = normalised[marker_span[1] : source_span[0]]
if _CLASSIFICATION_SECTION_BOUNDARY_PATTERN.search(between) is None:
return True
return False
def _posting_blocking_constraint_clauses(
raw_text: str,
) -> list[tuple[str, frozenset[str]]]:
"""Return explicit clauses and the constraint kinds each clause requires."""
normalised = unicodedata.normalize("NFKC", raw_text)
detected: list[tuple[str, frozenset[str]]] = []
clauses = re.split(r"[\n.;。]+", normalised)
for clause in clauses:
clause = clause.strip()
if not clause:
continue
non_blocking = _NON_BLOCKING_CONSTRAINT_MARKER_PATTERN.search(clause)
explicit_blocking = re.search(
r"(?:필수|반드시|이내|이하|미만|금지|불가|로만)",
clause,
)
if non_blocking is not None and explicit_blocking is None:
continue
expected_kinds = frozenset(
kind
for kind, pattern in _EXPLICIT_BLOCKING_SUBMISSION_PATTERNS
if pattern.search(clause)
)
if expected_kinds:
detected.append((clause, expected_kinds))
return detected
def _has_sufficient_source_anchors(text: str, quote: str) -> bool:
claimed = _semantic_anchors(text)
quoted = _semantic_anchors(quote)
if not claimed or not quoted:
return False
matched = claimed & quoted
minimum = 1 if len(claimed) <= 2 else max(2, (len(claimed) + 1) // 2)
return len(matched) >= minimum
_EVIDENCE_SENSITIVE_PATTERNS: tuple[
tuple[SensitiveDataCategory, tuple[re.Pattern[str], ...]], ...
] = (
(
SensitiveDataCategory.PHOTO,
(
re.compile(r"(?:증명|반명함|여권|프로필)\s*사진"),
re.compile(r"사진\s*(?:첨부|부착|제출)"),
),
),
(
SensitiveDataCategory.BIRTH_DATE,
(
re.compile(r"(?:생년월일|출생일?)\s*[:]?"),
re.compile(r"(?<!\d)(?:19|20)\d{2}\s*년생(?![가-힣])"),
re.compile(
r"(?:birth[_\s-]?date|date[_\s-]?of[_\s-]?birth|dob)"
r"(?=$|[\s:=_-])",
re.IGNORECASE,
),
),
),
(
SensitiveDataCategory.GENDER,
(
re.compile(r"성별\s*[:]\s*(?:남(?:성)?|여(?:성)?)"),
re.compile(r"(?:gender|sex)(?=$|[\s:=_-])", re.IGNORECASE),
),
),
(
SensitiveDataCategory.FULL_ADDRESS,
(
re.compile(r"(?:자택|상세)\s*주소\s*[:]"),
re.compile(r"거주지\s*[:]\s*\S+"),
),
),
(
SensitiveDataCategory.MARITAL_STATUS,
(re.compile(r"(?:혼인|결혼)\s*(?:여부|상태)\s*[:]?"),),
),
(
SensitiveDataCategory.FAMILY_DETAILS,
(
re.compile(r"가족\s*관계\s*[:]?"),
re.compile(r"(?:부친|모친|배우자|자녀)\s*[:]"),
),
),
(
SensitiveDataCategory.RELIGION,
(
re.compile(r"종교\s*[:]\s*\S+"),
re.compile(r"religion(?=$|[\s:=_-])", re.IGNORECASE),
),
),
(
SensitiveDataCategory.DISABILITY,
(
re.compile(r"장애\s*(?:여부|등급|유형)\s*[:]?"),
re.compile(
r"disab(?:ility|led)(?:[_\s-]*(?:status|type))?"
r"(?=$|[\s:=_-])",
re.IGNORECASE,
),
),
),
(
SensitiveDataCategory.HEALTH,
(
re.compile(r"혈액형\s*[:]"),
re.compile(r"(?:신장|키|체중)\s*[:]\s*\d"),
re.compile(r"건강\s*상태\s*[:]"),
),
),
(
SensitiveDataCategory.MILITARY_DETAILS,
(re.compile(r"병역\s*(?:사항|구분|여부|상태)\s*[:]?"),),
),
(
SensitiveDataCategory.COMPENSATION,
(
re.compile(r"(?:현재|희망)?\s*(?:연봉|급여)\s*[:]\s*\d"),
re.compile(
r"(?:current|desired|expected)[_\s-]*(?:salary|compensation)"
r"(?=$|[\s:=_-])",
re.IGNORECASE,
),
),
),
(
SensitiveDataCategory.POLITICAL_OPINION,
(
re.compile(
r"(?:정치적\s*견해|지지\s*정당|정당\s*가입\s*여부)\s*[:]?"
),
re.compile(
r"political[_\s-]?(?:opinion|affiliation)(?=$|[\s:=_-])",
re.IGNORECASE,
),
),
),
(
SensitiveDataCategory.PROPERTY,
(
re.compile(
r"(?:재산\s*(?:총액|보유액|내역)?|보유\s*부동산|자산\s*총액)"
r"\s*[:]\s*\S+"
),
re.compile(
r"(?:property|asset)[_\s-]?(?:value|holdings)"
r"(?=$|[\s:=_-])",
re.IGNORECASE,
),
),
),
(
SensitiveDataCategory.NATIONAL_ID,
(_EVIDENCE_PASSPORT_PATTERN,),
),
)
class ResumeDate(DomainModel):
"""A date with year, month, or day precision.
Korean career histories are commonly supplied as ``YYYY.MM``. Retaining the
original precision avoids inventing a day merely to satisfy a storage type.
"""
year: int = Field(ge=1900, le=2200)
month: int | None = Field(default=None, ge=1, le=12)
day: int | None = Field(default=None, ge=1, le=31)
@model_validator(mode="after")
def validate_calendar_date(self) -> Self:
if self.day is not None and self.month is None:
raise ValueError("day requires month")
if self.month is not None and self.day is not None:
try:
date(self.year, self.month, self.day)
except ValueError as exc:
raise ValueError("invalid calendar date") from exc
return self
@property
def precision(self) -> Literal["year", "month", "day"]:
if self.day is not None:
return "day"
if self.month is not None:
return "month"
return "year"
def earliest(self) -> date:
return date(self.year, self.month or 1, self.day or 1)
def latest(self) -> date:
month = self.month or 12
day = self.day or calendar.monthrange(self.year, month)[1]
return date(self.year, month, day)
def format_ko(self) -> str:
if self.day is not None:
return f"{self.year}.{self.month:02d}.{self.day:02d}"
if self.month is not None:
return f"{self.year}.{self.month:02d}"
return str(self.year)
class DateRange(DomainModel):
start: ResumeDate
end: ResumeDate | None = None
ongoing: bool = False
@model_validator(mode="after")
def validate_range(self) -> Self:
if self.ongoing and self.end is not None:
raise ValueError("ongoing date range cannot have an end date")
if self.end is not None and self.end.latest() < self.start.earliest():
raise ValueError("end date must not be earlier than start date")
return self
class ContactInfo(DomainModel):
email: str | None = Field(default=None, max_length=254)
phone: str | None = Field(default=None, max_length=30)
city: str | None = Field(default=None, max_length=100)
links: list[str] = Field(default_factory=list, max_length=10)
@field_validator("email")
@classmethod
def validate_email(cls, value: str | None) -> str | None:
if value is None:
return value
if not re.fullmatch(r"[^\s@]+@[^\s@]+\.[^\s@]+", value):
raise ValueError("invalid email address")
return value
@field_validator("phone")
@classmethod
def validate_phone(cls, value: str | None) -> str | None:
if value is None:
return value
compact = re.sub(r"[\s().-]", "", value)
if not re.fullmatch(r"\+?\d{8,15}", compact):
raise ValueError("phone must contain 8 to 15 digits")
return value
@field_validator("city")
@classmethod
def require_coarse_region(cls, value: str | None) -> str | None:
if value is None:
return value
compact = re.sub(r"\s+", " ", value).strip()
korean_top_regions = {
"서울",
"서울특별시",
"부산",
"부산광역시",
"대구",
"대구광역시",
"인천",
"인천광역시",
"광주",
"광주광역시",
"대전",
"대전광역시",
"울산",
"울산광역시",
"세종",
"세종특별자치시",
"경기",
"경기도",
"강원",
"강원특별자치도",
"충북",
"충청북도",
"충남",
"충청남도",
"전북",
"전북특별자치도",
"전남",
"전라남도",
"경북",
"경상북도",
"경남",
"경상남도",
"제주",
"제주특별자치도",
}
if compact in korean_top_regions:
return compact
if (
re.search(r"\d|번지|아파트|빌딩|오피스텔|우편번호", compact)
or re.search(
r"(?:^|\s)[가-힣]{2,}(?:시|구|군|읍|면|동|리)(?:\s|$)",
compact,
)
or len(compact) > 50
):
raise ValueError("city must be a coarse city/province-level region")
return compact
@field_validator("links")
@classmethod
def validate_links(cls, values: list[str]) -> list[str]:
for value in values:
if not re.fullmatch(r"https?://[^\s]+", value):
raise ValueError("contact links must be absolute HTTP(S) URLs")
if _normalised_duplicates(values):
raise ValueError("contact links must be unique")
return values
@model_validator(mode="after")
def require_contact_channel(self) -> Self:
if self.email is None and self.phone is None and not self.links:
raise ValueError("at least one contact channel is required")
return self
class SensitiveDataConsent(DomainModel):
"""Explicit, purpose-bound permission for one sensitive data category."""
consent_id: Identifier
category: SensitiveDataCategory
purpose: ShortText
granted: bool = True
granted_at: AwareDatetime
expires_at: AwareDatetime | None = None
revoked_at: AwareDatetime | None = None
@model_validator(mode="after")
def validate_consent_timeline(self) -> Self:
if self.expires_at is not None and self.expires_at <= self.granted_at:
raise ValueError("consent expiry must be later than grant time")
if self.revoked_at is not None and self.revoked_at < self.granted_at:
raise ValueError("consent cannot be revoked before it is granted")
return self
def is_active_at(self, instant: datetime) -> bool:
if instant.tzinfo is None or instant.utcoffset() is None:
raise ValueError("consent checks require a timezone-aware datetime")
return (
self.granted
and self.granted_at <= instant
and (self.expires_at is None or instant < self.expires_at)
and (self.revoked_at is None or instant < self.revoked_at)
)
class EvidenceItem(DomainModel):
"""Atomic candidate fact that may support one or more generated claims."""
evidence_id: Identifier
category: EvidenceCategory
content: NonEmptyText
source: EvidenceSource
source_reference: str | None = Field(default=None, max_length=2_000)
date_range: DateRange | None = None
verification_status: VerificationStatus = VerificationStatus.UNVERIFIED
metrics: dict[str, str | int | float] = Field(default_factory=dict, max_length=30)
keywords: list[ShortText] = Field(default_factory=list, max_length=50)
sensitive_category: SensitiveDataCategory | None = None
consent_id: Identifier | None = None
confidential: bool = False
@field_validator("content")
@classmethod
def reject_resident_registration_number(cls, value: str) -> str:
if _KOREAN_RESIDENT_ID_PATTERN.search(value):
raise ValueError("Korean resident registration numbers are prohibited")
return value
@field_validator("keywords")
@classmethod
def unique_keywords(cls, values: list[str]) -> list[str]:
if _normalised_duplicates(values):
raise ValueError("evidence keywords must be unique")
return values
@model_validator(mode="after")
def validate_sensitive_data_reference(self) -> Self:
auxiliary_values = [
self.source_reference or "",
*(str(key) for key in self.metrics),
*(str(value) for value in self.metrics.values()),
*self.keywords,
]
auxiliary_text = " ".join(auxiliary_values)
sensitive_scan_text = f"{self.content} {auxiliary_text}"
if (
_KOREAN_RESIDENT_ID_PATTERN.search(sensitive_scan_text)
or _EVIDENCE_BANK_PATTERN.search(sensitive_scan_text)
or _EVIDENCE_PASSPORT_PATTERN.search(sensitive_scan_text)
):
raise ValueError(
"national ID, passport, and bank account values are prohibited "
"in all evidence fields"
)
if _EVIDENCE_EMAIL_PATTERN.search(auxiliary_text) or _EVIDENCE_PHONE_PATTERN.search(
auxiliary_text
):
raise ValueError(
"contact details belong in ContactInfo and cannot enter evidence metadata"
)
if any(_EVIDENCE_SECRET_KEY_PATTERN.search(str(key)) for key in self.metrics):
raise ValueError("authentication secrets cannot enter evidence metrics")
if _EVIDENCE_SECRET_VALUE_PATTERN.search(
f"{self.content} {auxiliary_text}"
):
raise ValueError("authentication secret values cannot enter evidence")
if _EVIDENCE_HEALTH_TERM_PATTERN.search(sensitive_scan_text):
raise ValueError("health data must never enter evidence metadata")
if _EVIDENCE_BANK_PATTERN.search(self.content):
raise ValueError("bank account values must never enter candidate evidence")
if _EVIDENCE_EMAIL_PATTERN.search(self.content) or _EVIDENCE_PHONE_PATTERN.search(
self.content
):
raise ValueError(
"contact details belong in ContactInfo and cannot enter evidence content"
)
detected_categories = {
category
for category, patterns in _EVIDENCE_SENSITIVE_PATTERNS
if any(pattern.search(sensitive_scan_text) for pattern in patterns)
}
prohibited_detected = detected_categories & PROHIBITED_SENSITIVE_CATEGORIES
if prohibited_detected:
raise ValueError(
"prohibited health, political opinion, property, national ID, "
"or bank account data must never enter evidence"
)
if len(detected_categories) > 1:
raise ValueError(
"evidence contains multiple sensitive categories; split or remove it"
)
if detected_categories and self.sensitive_category not in detected_categories:
detected = next(iter(detected_categories)).value
raise ValueError(
f"detected sensitive content requires category {detected!r} and consent"
)
if self.sensitive_category in PROHIBITED_SENSITIVE_CATEGORIES:
raise ValueError(
"prohibited health, political opinion, property, national IDs, "
"and bank accounts must never enter a resume"
)
if self.sensitive_category is not None and self.consent_id is None:
raise ValueError("sensitive evidence requires an explicit consent_id")
if self.sensitive_category is None and self.consent_id is not None:
raise ValueError("consent_id is only valid for sensitive evidence")
return self
@property
def statement(self) -> str:
"""Readable compatibility name for the factual content."""
return self.content
class CandidateFact(EvidenceItem):
"""Semantic alias retained for callers that refer to candidate facts."""
class CandidateProfile(DomainModel):
candidate_id: Identifier
name: ShortText
name_en: str | None = Field(default=None, max_length=200)
contact: ContactInfo
headline: str | None = Field(default=None, max_length=300)
summary: str | None = Field(default=None, max_length=2_000)
facts: list[EvidenceItem] = Field(min_length=1, max_length=1_000)
records: ResumeRecords = Field(default_factory=ResumeRecords)
consents: list[SensitiveDataConsent] = Field(default_factory=list, max_length=100)
locale: Literal["ko-KR"] = "ko-KR"
updated_at: AwareDatetime = Field(default_factory=_utc_now)
@model_validator(mode="after")
def validate_evidence_and_consents(self) -> Self:
duplicate_evidence = _duplicates([fact.evidence_id for fact in self.facts])
if duplicate_evidence:
raise ValueError(f"duplicate evidence_id values: {duplicate_evidence}")
duplicate_consents = _duplicates([item.consent_id for item in self.consents])
if duplicate_consents:
raise ValueError(f"duplicate consent_id values: {duplicate_consents}")
consent_by_id = {item.consent_id: item for item in self.consents}
self.records.assert_evidence_integrity(
{fact.evidence_id: fact.category.value for fact in self.facts}
)
evidence_texts: dict[str, str] = {}
for fact in self.facts:
date_values: list[str] = []
if fact.date_range is not None:
date_values.append(fact.date_range.start.format_ko())
if fact.date_range.end is not None:
date_values.append(fact.date_range.end.format_ko())
evidence_texts[fact.evidence_id] = " ".join(
[
fact.content,
*fact.keywords,
*(str(key) for key in fact.metrics),
*(str(value) for value in fact.metrics.values()),
*date_values,
]
)
self.records.assert_value_grounding(evidence_texts)
for fact in self.facts:
identity_values = [self.name, self.name_en or ""]
transmitted_fact_text = " ".join(
[
fact.content,
*(str(key) for key in fact.metrics),
*(str(value) for value in fact.metrics.values()),
*fact.keywords,
]
)
if any(
len(value) >= 2
and _contains_identity_echo(transmitted_fact_text, value)
for value in identity_values
if value
):
raise ValueError(
f"evidence {fact.evidence_id!r} contains candidate identity; "
"keep identity separate from facts"
)
if fact.sensitive_category is None:
continue
consent = consent_by_id.get(fact.consent_id)
if consent is None:
raise ValueError(
f"evidence {fact.evidence_id!r} references unknown consent_id"
)
if consent.category != fact.sensitive_category:
raise ValueError(
f"evidence {fact.evidence_id!r} and consent category differ"
)
if not consent.is_active_at(self.updated_at):
raise ValueError(
f"evidence {fact.evidence_id!r} does not have active consent"
)
return self
@property
def evidence_by_id(self) -> dict[str, EvidenceItem]:
return {fact.evidence_id: fact for fact in self.facts}
@property
def structured_record_by_evidence_id(self) -> dict[str, StructuredRecord]:
"""Index typed records by the evidence item that proves each record."""
return {
evidence_id: record
for record in self.records.all_records()
for evidence_id in record.evidence_ids
}
class JobPosting(DomainModel):
posting_id: Identifier
company_name: ShortText
title: ShortText
raw_text: NonEmptyText
source_url: str | None = Field(default=None, max_length=2_000)
location: str | None = Field(default=None, max_length=200)
employment_type: EmploymentType | None = None
posted_on: date | None = None
closes_on: date | None = None
collected_at: AwareDatetime = Field(default_factory=_utc_now)
@field_validator("source_url")
@classmethod
def validate_source_url(cls, value: str | None) -> str | None:
if value is not None and not re.fullmatch(r"https?://[^\s]+", value):
raise ValueError("source_url must be an absolute HTTP(S) URL")
return value
@model_validator(mode="after")
def validate_posting_dates(self) -> Self:
if (
self.posted_on is not None
and self.closes_on is not None
and self.closes_on < self.posted_on
):
raise ValueError("job closing date must not precede posting date")
return self
class RequirementKind(StrEnum):
REQUIRED = "required"
PREFERRED = "preferred"
RESPONSIBILITY = "responsibility"
CONTEXT = "context"
class RequirementCategory(StrEnum):
EXPERIENCE = "experience"
SKILL = "skill"
EDUCATION = "education"
CERTIFICATION = "certification"
DOMAIN = "domain"
LANGUAGE = "language"
BEHAVIOUR = "behaviour"
OTHER = "other"
class JobRequirement(DomainModel):
requirement_id: Identifier
text: NonEmptyText
kind: RequirementKind
category: RequirementCategory
priority: int = Field(default=3, ge=1, le=5)
source_quote: NonEmptyText
classification_quote: str | None = Field(default=None, max_length=2_000)
keywords: list[ShortText] = Field(default_factory=list, max_length=50)
@field_validator("keywords")
@classmethod
def validate_keywords(cls, values: list[str]) -> list[str]:
if _normalised_duplicates(values):
raise ValueError("requirement keywords must be unique")
return values
@model_validator(mode="after")
def require_classification_provenance(self) -> Self:
if self.kind not in {RequirementKind.REQUIRED, RequirementKind.PREFERRED}:
return self
evidence = self.classification_quote or self.source_quote
marker = (
_REQUIRED_MARKER_PATTERN
if self.kind is RequirementKind.REQUIRED
else _PREFERRED_MARKER_PATTERN
)
if marker.search(evidence) is None:
raise ValueError(
f"{self.kind.value} requirement needs a matching "
"classification_quote from the posting"
)
opposite_marker = (
_PREFERRED_MARKER_PATTERN
if self.kind is RequirementKind.REQUIRED
else _REQUIRED_MARKER_PATTERN
)
if opposite_marker.search(evidence) is not None:
raise ValueError(
f"{self.kind.value} classification_quote contains an opposing marker"
)
if self.classification_quote is not None and not _source_quote_occurs(
self.classification_quote, self.source_quote
):
raise ValueError(
"classification_quote must be one contiguous posting excerpt "
"that also contains source_quote"
)
if (
self.classification_quote is not None
and not _classification_marker_is_local(
self.classification_quote, self.source_quote, marker
)
):
raise ValueError(
"classification_quote marker and source_quote must be in the "
"same posting section"
)
return self
def _requirement_anchors(requirement: JobRequirement) -> set[str]:
return _semantic_anchors(
" ".join(
[
requirement.text,
requirement.source_quote,
*requirement.keywords,
]
)
)
def _claim_mentions_requirement(
claim_text: str, requirement: JobRequirement
) -> bool:
"""Conservatively validate a claim-to-requirement scoring link."""
return _text_supports_requirement(claim_text, requirement, direct=True)
def _text_supports_requirement(
text: str, requirement: JobRequirement, *, direct: bool
) -> bool:
"""Reject a coincidental shared noun while preserving short tech skills."""
requirement_anchors = _requirement_anchors(requirement)
text_anchors = _semantic_anchors(text)
matched_anchors = requirement_anchors & text_anchors
if not matched_anchors:
return False
requirement_signals = _high_signal_tokens(
" ".join([requirement.text, *requirement.keywords])
)
if requirement_signals:
if not requirement_signals <= _high_signal_tokens(text):
return False
core_anchors = _semantic_anchors(
" ".join([requirement.text, *requirement.keywords])
)
signal_anchors = _semantic_anchors(" ".join(requirement_signals))
specific_anchors = {
anchor
for anchor in core_anchors - signal_anchors
if anchor not in _TECH_REQUIREMENT_CONTEXT_TERMS
and re.fullmatch(r"\d+(?:\.\d+)?", anchor) is None
}
if not specific_anchors <= text_anchors:
return False
# A fully matched explicit technology token is sufficient for a short
# technology requirement (for example Python, Kafka, C++, or CI/CD),
# even when the posting models it as an experience/responsibility.
return True
if not direct or len(requirement_anchors) == 1:
return True
if any(
len(anchor) >= 4 and re.fullmatch(r"[가-힣]+", anchor)
for anchor in matched_anchors
):
# A distinctive Korean domain term such as "데이터베이스" or
# "모니터링" can stand alone; short generic nouns such as "고객"
# cannot validate a composite DIRECT requirement.
return True
return len(matched_anchors) >= 2
def _evidence_supports_requirement(
evidence: EvidenceItem, requirement: JobRequirement, *, direct: bool
) -> bool:
evidence_text = " ".join(
[
evidence.content,
*evidence.keywords,
*(str(key) for key in evidence.metrics),
*(str(value) for value in evidence.metrics.values()),
]
)
return _text_supports_requirement(evidence_text, requirement, direct=direct)
class ConstraintKind(StrEnum):
BLIND_FIELD = "blind_field"
REDACTION = "redaction"
REQUIRED_SECTION = "required_section"
CHARACTER_LIMIT = "character_limit"
FILE_FORMAT = "file_format"
EMPLOYER_TEMPLATE = "employer_template"
OTHER = "other"
class PostingConstraint(DomainModel):
"""One application rule extracted verbatim from a job posting.
``fields`` intentionally uses posting vocabulary rather than a universal
privacy enum because Korean public institutions differ on items such as
school names, employer names, and identifying email domains.
"""
constraint_id: Identifier
kind: ConstraintKind
description: NonEmptyText
source_quote: NonEmptyText
fields: list[ShortText] = Field(default_factory=list, max_length=100)
section: str | None = Field(default=None, max_length=300)
max_characters: int | None = Field(default=None, ge=1, le=100_000)
formats: list[ShortText] = Field(default_factory=list, max_length=20)
blocking: bool = True
@model_validator(mode="after")
def validate_typed_payload(self) -> Self:
if _normalised_duplicates(self.fields):
raise ValueError("posting constraint fields must be unique")
if _normalised_duplicates(self.formats):
raise ValueError("posting constraint formats must be unique")
if self.kind in {ConstraintKind.BLIND_FIELD, ConstraintKind.REDACTION}:
if not self.fields:
raise ValueError("blind and redaction constraints require fields")
if self.kind is ConstraintKind.CHARACTER_LIMIT:
if self.max_characters is None or self.section is None:
raise ValueError(
"character limit constraints require section and max_characters"
)
if self.kind is ConstraintKind.FILE_FORMAT and not self.formats:
raise ValueError("file format constraints require formats")
return self
def _constraint_payload_is_grounded(constraint: PostingConstraint) -> bool:
"""Verify typed constraint values against the quoted posting text."""
quote = unicodedata.normalize("NFKC", constraint.source_quote).casefold()
quote_anchors = _semantic_anchors(quote)
if constraint.kind is ConstraintKind.CHARACTER_LIMIT:
assert constraint.max_characters is not None
assert constraint.section is not None
number_matches = list(re.finditer(r"(?<!\d)\d[\d,]*(?!\d)", quote))
selected_spans = [
match.span()
for match in number_matches
if int(match.group(0).replace(",", "")) == constraint.max_characters
]
alternative_spans = [
match.span()
for match in number_matches
if int(match.group(0).replace(",", "")) != constraint.max_characters
]
return _value_is_closest_to_scope(
_phrase_spans(quote, constraint.section),
selected_spans,
alternative_spans,
)
if constraint.kind is ConstraintKind.FILE_FORMAT:
selected_canonicals = {
_FORMAT_ALIASES.get(
unicodedata.normalize("NFKC", value).casefold().lstrip("."),
unicodedata.normalize("NFKC", value).casefold().lstrip("."),
)
for value in constraint.formats
}
occurrences: list[tuple[str, tuple[int, int]]] = []
aliases = dict(_FORMAT_ALIASES)
for value in constraint.formats:
normalised_value = (
unicodedata.normalize("NFKC", value).casefold().lstrip(".")
)
aliases.setdefault(normalised_value, normalised_value)
for alias, canonical in aliases.items():
occurrences.extend(
(canonical, span) for span in _phrase_spans(quote, alias)
)
if not all(
any(canonical == selected for canonical, _ in occurrences)
for selected in selected_canonicals
):
return False
description_anchors = _semantic_anchors(constraint.description)
format_anchors = {
anchor
for alias, canonical in aliases.items()
if canonical in selected_canonicals
for anchor in _semantic_anchors(alias)
}
scope_terms = (
[constraint.section]
if constraint.section
else sorted(
description_anchors
- format_anchors
- _CONSTRAINT_SCOPE_STOPWORDS
)
)
scope_spans = [
span
for term in scope_terms
for span in _phrase_spans(quote, term)
]
observed_canonicals = {canonical for canonical, _ in occurrences}
if not scope_spans:
# With no target subject, the quote is unambiguous only when every
# mentioned format is represented by the typed payload.
return observed_canonicals <= selected_canonicals
return all(
_value_is_closest_to_scope(
scope_spans,
[
span
for canonical, span in occurrences
if canonical == selected
],
[
span
for canonical, span in occurrences
if canonical not in selected_canonicals
],
)
for selected in selected_canonicals
)
if constraint.kind is ConstraintKind.REQUIRED_SECTION:
references = (
[constraint.section] if constraint.section else constraint.fields
)
return bool(references) and all(
bool(_semantic_anchors(reference) & quote_anchors)
for reference in references
)
return True
class JobAnalysis(DomainModel):
analysis_id: Identifier
posting_id: Identifier
target_role: ShortText
summary: NonEmptyText
requirements: list[JobRequirement] = Field(min_length=1, max_length=500)
constraints: list[PostingConstraint] = Field(default_factory=list, max_length=200)
keywords: list[ShortText] = Field(default_factory=list, max_length=200)
analysed_at: AwareDatetime = Field(default_factory=_utc_now)
locale: Literal["ko-KR"] = "ko-KR"
@field_validator("keywords")
@classmethod
def validate_analysis_keywords(cls, values: list[str]) -> list[str]:
if _normalised_duplicates(values):
raise ValueError("analysis keywords must be unique")
return values
@model_validator(mode="after")
def validate_requirement_ids(self) -> Self:
duplicates = _duplicates(
[requirement.requirement_id for requirement in self.requirements]
)
if duplicates:
raise ValueError(f"duplicate requirement_id values: {duplicates}")
duplicate_constraints = _duplicates(
[constraint.constraint_id for constraint in self.constraints]
)
if duplicate_constraints:
raise ValueError(
f"duplicate constraint_id values: {duplicate_constraints}"
)
return self
def assert_matches_posting(self, posting: JobPosting) -> Self:
if posting.posting_id != self.posting_id:
raise ValueError("job analysis references a different posting")
missing_quotes = [
requirement.requirement_id
for requirement in self.requirements
if not _source_quote_occurs(posting.raw_text, requirement.source_quote)
]
missing_constraint_quotes = [
constraint.constraint_id
for constraint in self.constraints
if not _source_quote_occurs(posting.raw_text, constraint.source_quote)
]
missing_classification_quotes = [
requirement.requirement_id
for requirement in self.requirements
if requirement.classification_quote is not None
and not _source_quote_occurs(
posting.raw_text, requirement.classification_quote
)
]
expected_constraint_kinds: dict[str, frozenset[ConstraintKind]] = {
"character_limit": frozenset({ConstraintKind.CHARACTER_LIMIT}),
"file_format": frozenset({ConstraintKind.FILE_FORMAT}),
"employer_template": frozenset({ConstraintKind.EMPLOYER_TEMPLATE}),
"required_section": frozenset({ConstraintKind.REQUIRED_SECTION}),
"privacy": frozenset(
{ConstraintKind.BLIND_FIELD, ConstraintKind.REDACTION}
),
}
uncovered_blocking_constraints: list[str] = []
for clause_index, (clause, expected_kinds) in enumerate(
_posting_blocking_constraint_clauses(posting.raw_text), start=1
):
for expected_kind in sorted(expected_kinds):
accepted_kinds = expected_constraint_kinds[expected_kind]
if not any(
constraint.blocking
and constraint.kind in accepted_kinds
and _source_quote_occurs(clause, constraint.source_quote)
for constraint in self.constraints
):
uncovered_blocking_constraints.append(
f"clause-{clause_index}:{expected_kind}"
)
ungrounded_requirements = [
requirement.requirement_id
for requirement in self.requirements
if (
not _has_sufficient_source_anchors(
requirement.text, requirement.source_quote
)
or not _high_signal_tokens(requirement.text)
<= _high_signal_tokens(requirement.source_quote)
)
]
ungrounded_constraints = [
constraint.constraint_id
for constraint in self.constraints
if not (
_semantic_anchors(constraint.description)
& _semantic_anchors(constraint.source_quote)
)
]
ungrounded_constraint_payloads = [
constraint.constraint_id
for constraint in self.constraints
if not _constraint_payload_is_grounded(constraint)
]
misclassified_requirements = []
for requirement in self.requirements:
classification = (
requirement.classification_quote or requirement.source_quote
)
combined = f"{classification}\n{requirement.source_quote}"
required_marker = _REQUIRED_MARKER_PATTERN.search(combined) is not None
preferred_marker = _PREFERRED_MARKER_PATTERN.search(combined) is not None
if (
requirement.kind is RequirementKind.REQUIRED
and preferred_marker
) or (
requirement.kind is RequirementKind.PREFERRED
and required_marker
):
misclassified_requirements.append(requirement.requirement_id)
if (
missing_quotes
or missing_constraint_quotes
or missing_classification_quotes
):
raise ValueError(
"job analysis contains source quotes absent from posting: "
f"requirements={missing_quotes}, constraints={missing_constraint_quotes}, "
f"classifications={missing_classification_quotes}"
)
if (
ungrounded_requirements
or ungrounded_constraints
or ungrounded_constraint_payloads
):
raise ValueError(
"job analysis text or typed constraint value lacks a meaningful "
"anchor in its source quote: "
f"requirements={ungrounded_requirements}, "
f"constraints={ungrounded_constraints}, "
f"constraint_payloads={ungrounded_constraint_payloads}"
)
if uncovered_blocking_constraints:
raise ValueError(
"job analysis omitted an explicit blocking submission constraint: "
f"{uncovered_blocking_constraints}"
)
if misclassified_requirements:
raise ValueError(
"job analysis changed an explicit required/preferred marker: "
f"requirements={misclassified_requirements}"
)
return self
class EvidenceMatchType(StrEnum):
DIRECT = "direct"
TRANSFERABLE = "transferable"
PARTIAL = "partial"
GAP = "gap"
class EvidenceMatch(DomainModel):
requirement_id: Identifier
evidence_ids: list[Identifier] = Field(default_factory=list, max_length=100)
match_type: EvidenceMatchType
relevance_score: float = Field(ge=0.0, le=1.0)
rationale: str | None = Field(default=None, max_length=2_000)
gap_reason: str | None = Field(default=None, max_length=2_000)
@model_validator(mode="after")
def validate_match(self) -> Self:
duplicates = _duplicates(self.evidence_ids)
if duplicates:
raise ValueError(f"duplicate evidence references: {duplicates}")
if self.match_type is EvidenceMatchType.GAP:
if self.evidence_ids:
raise ValueError("gap matches cannot reference evidence")
if self.relevance_score != 0:
raise ValueError("gap matches must have relevance_score 0")
if not self.gap_reason:
raise ValueError("gap matches require gap_reason")
else:
if not self.evidence_ids:
raise ValueError("non-gap matches require evidence")
if self.relevance_score <= 0:
raise ValueError("non-gap matches require a positive relevance score")
if not self.rationale:
raise ValueError("non-gap matches require a rationale")
return self
class EvidenceMap(DomainModel):
map_id: Identifier
posting_id: Identifier
analysis_id: Identifier
matches: list[EvidenceMatch] = Field(min_length=1, max_length=500)
generated_at: AwareDatetime = Field(default_factory=_utc_now)
@model_validator(mode="after")
def validate_unique_requirements(self) -> Self:
duplicates = _duplicates([match.requirement_id for match in self.matches])
if duplicates:
raise ValueError(f"requirement mapped more than once: {duplicates}")
return self
def assert_referential_integrity(
self, profile: CandidateProfile, analysis: JobAnalysis
) -> Self:
errors: list[str] = []
if self.analysis_id != analysis.analysis_id:
errors.append("map analysis_id does not match analysis")
if self.posting_id != analysis.posting_id:
errors.append("map posting_id does not match analysis")
requirement_by_id = {
requirement.requirement_id: requirement
for requirement in analysis.requirements
}
known_requirements = set(requirement_by_id)
mapped_requirements = {match.requirement_id for match in self.matches}
missing_requirements = sorted(known_requirements - mapped_requirements)
unknown_requirements = sorted(mapped_requirements - known_requirements)
if missing_requirements:
errors.append(f"requirements without a mapping: {missing_requirements}")
if unknown_requirements:
errors.append(f"unknown requirement references: {unknown_requirements}")
known_evidence = set(profile.evidence_by_id)
referenced_evidence = {
evidence_id for match in self.matches for evidence_id in match.evidence_ids
}
unknown_evidence = sorted(referenced_evidence - known_evidence)
if unknown_evidence:
errors.append(f"unknown evidence references: {unknown_evidence}")
for match in self.matches:
if match.match_type is EvidenceMatchType.GAP:
continue
requirement = requirement_by_id.get(match.requirement_id)
if requirement is None:
continue
for evidence_id in match.evidence_ids:
fact = profile.evidence_by_id.get(evidence_id)
if fact is None:
continue
if not _evidence_supports_requirement(
fact,
requirement,
direct=match.match_type is EvidenceMatchType.DIRECT,
):
errors.append(
f"evidence match {match.requirement_id!r}/{evidence_id!r} "
"lacks a semantic anchor"
)
if errors:
raise ValueError("; ".join(errors))
return self
class ClaimKind(StrEnum):
FACTUAL = "factual"
POSITIONING = "positioning"
class DraftClaim(DomainModel):
claim_id: Identifier
text: NonEmptyText
kind: ClaimKind = ClaimKind.FACTUAL
evidence_ids: list[Identifier] = Field(default_factory=list, max_length=100)
requirement_ids: list[Identifier] = Field(default_factory=list, max_length=100)
sensitive_categories: set[SensitiveDataCategory] = Field(default_factory=set)
order: int = Field(default=0, ge=0)
@model_validator(mode="after")
def validate_grounding(self) -> Self:
if not self.evidence_ids:
raise ValueError("every draft claim requires supporting evidence")
duplicate_evidence = _duplicates(self.evidence_ids)
if duplicate_evidence:
raise ValueError(f"duplicate claim evidence: {duplicate_evidence}")
duplicate_requirements = _duplicates(self.requirement_ids)
if duplicate_requirements:
raise ValueError(f"duplicate claim requirements: {duplicate_requirements}")
if self.sensitive_categories & PROHIBITED_SENSITIVE_CATEGORIES:
raise ValueError("draft claims cannot contain prohibited sensitive data")
if _KOREAN_RESIDENT_ID_PATTERN.search(self.text):
raise ValueError("Korean resident registration numbers are prohibited")
return self
class SectionType(StrEnum):
SUMMARY = "summary"
CORE_COMPETENCIES = "core_competencies"
EXPERIENCE = "experience"
PROJECTS = "projects"
EDUCATION = "education"
SKILLS = "skills"
CERTIFICATIONS = "certifications"
AWARDS = "awards"
LANGUAGES = "languages"
MILITARY_SERVICE = "military_service"
OTHER = "other"
class PlannedSection(DomainModel):
"""Bounded section instruction passed to the drafting prompt."""
section_id: Identifier
section_type: SectionType
heading: ShortText
evidence_ids: list[Identifier] = Field(default_factory=list, max_length=500)
requirement_ids: list[Identifier] = Field(default_factory=list, max_length=500)
bullet_budget: int = Field(ge=1, le=30)
order: int = Field(ge=0)
@model_validator(mode="after")
def validate_references(self) -> Self:
duplicate_evidence = _duplicates(self.evidence_ids)
if duplicate_evidence:
raise ValueError(f"duplicate planned evidence: {duplicate_evidence}")
duplicate_requirements = _duplicates(self.requirement_ids)
if duplicate_requirements:
raise ValueError(
f"duplicate planned requirements: {duplicate_requirements}"
)
return self
class ContentPlan(DomainModel):
"""Evidence-bounded content plan between matching and prose drafting."""
plan_id: Identifier
candidate_id: Identifier
posting_id: Identifier | None = None
mode: ResumeMode = ResumeMode.PRIVATE_MODERN
sections: list[PlannedSection] = Field(min_length=1, max_length=50)
created_at: AwareDatetime = Field(default_factory=_utc_now)
@model_validator(mode="after")
def validate_sections(self) -> Self:
duplicate_ids = _duplicates([section.section_id for section in self.sections])
if duplicate_ids:
raise ValueError(f"duplicate planned section_id values: {duplicate_ids}")
duplicate_orders = _duplicates([str(section.order) for section in self.sections])
if duplicate_orders:
raise ValueError(f"duplicate planned section orders: {duplicate_orders}")
if self.mode is ResumeMode.PUBLIC_BLIND and any(
section.section_type is SectionType.MILITARY_SERVICE
for section in self.sections
):
raise ValueError("public blind plans cannot include military details")
return self
def assert_referential_integrity(
self,
profile: CandidateProfile,
analysis: JobAnalysis | None = None,
) -> Self:
errors: list[str] = []
if self.candidate_id != profile.candidate_id:
errors.append("plan candidate_id does not match profile")
if analysis is not None and self.posting_id != analysis.posting_id:
errors.append("plan posting_id does not match analysis")
known_evidence = set(profile.evidence_by_id)
requirement_by_id = (
{item.requirement_id: item for item in analysis.requirements}
if analysis is not None
else {}
)
known_requirements = set(requirement_by_id)
for section in self.sections:
missing_evidence = sorted(set(section.evidence_ids) - known_evidence)
if missing_evidence:
errors.append(
f"planned section {section.section_id!r} references unknown "
f"evidence {missing_evidence}"
)
if analysis is not None:
missing_requirements = sorted(
set(section.requirement_ids) - known_requirements
)
if missing_requirements:
errors.append(
f"planned section {section.section_id!r} references unknown "
f"requirements {missing_requirements}"
)
if errors:
raise ValueError("; ".join(errors))
return self
def assert_matches_evidence_map(self, evidence_map: EvidenceMap) -> Self:
"""Ensure selected evidence/requirement pairs were actually mapped."""
errors: list[str] = []
if self.posting_id != evidence_map.posting_id:
errors.append("content plan posting_id does not match evidence map")
matches_by_requirement = {
match.requirement_id: match for match in evidence_map.matches
}
for section in self.sections:
section_requirements = set(section.requirement_ids)
for requirement_id in section_requirements:
match = matches_by_requirement.get(requirement_id)
if match is None:
errors.append(
f"planned section {section.section_id!r} uses unmapped "
f"requirement {requirement_id!r}"
)
elif match.match_type is EvidenceMatchType.GAP:
errors.append(
f"planned section {section.section_id!r} uses gap "
f"requirement {requirement_id!r}"
)
elif not set(section.evidence_ids) & set(match.evidence_ids):
errors.append(
f"planned section {section.section_id!r} has no evidence "
f"mapped to requirement {requirement_id!r}"
)
for evidence_id in section.evidence_ids:
supporting_requirements = {
requirement_id
for requirement_id in section_requirements
if requirement_id in matches_by_requirement
and evidence_id
in matches_by_requirement[requirement_id].evidence_ids
}
if not supporting_requirements:
errors.append(
f"planned section {section.section_id!r} uses evidence "
f"{evidence_id!r} outside mapped requirement pairs"
)
if errors:
raise ValueError("; ".join(errors))
return self
class DraftSection(DomainModel):
section_id: Identifier
section_type: SectionType
heading: ShortText
claims: list[DraftClaim] = Field(min_length=1, max_length=500)
order: int = Field(ge=0)
@model_validator(mode="after")
def validate_claims(self) -> Self:
duplicate_ids = _duplicates([claim.claim_id for claim in self.claims])
if duplicate_ids:
raise ValueError(f"duplicate claim_id values: {duplicate_ids}")
duplicate_orders = _duplicates([str(claim.order) for claim in self.claims])
if duplicate_orders:
raise ValueError(f"duplicate claim order values: {duplicate_orders}")
return self
class ResumeDraft(DomainModel):
draft_id: Identifier
candidate_id: Identifier
posting_id: Identifier | None = None
title: ShortText
mode: ResumeMode = ResumeMode.PRIVATE_MODERN
sections: list[DraftSection] = Field(min_length=1, max_length=50)
generated_at: AwareDatetime = Field(default_factory=_utc_now)
@model_validator(mode="after")
def validate_structure_and_mode(self) -> Self:
duplicate_sections = _duplicates(
[section.section_id for section in self.sections]
)
if duplicate_sections:
raise ValueError(f"duplicate section_id values: {duplicate_sections}")
duplicate_orders = _duplicates([str(section.order) for section in self.sections])
if duplicate_orders:
raise ValueError(f"duplicate section order values: {duplicate_orders}")
all_claim_ids = [
claim.claim_id for section in self.sections for claim in section.claims
]
duplicate_claims = _duplicates(all_claim_ids)
if duplicate_claims:
raise ValueError(f"claim_id values must be globally unique: {duplicate_claims}")
if self.mode is ResumeMode.PUBLIC_BLIND:
sensitive = {
category
for section in self.sections
for claim in section.claims
for category in claim.sensitive_categories
}
if sensitive:
raise ValueError("public blind resume drafts cannot contain sensitive data")
return self
def assert_referential_integrity(
self,
profile: CandidateProfile,
analysis: JobAnalysis | None = None,
) -> Self:
errors: list[str] = []
if self.candidate_id != profile.candidate_id:
errors.append("draft candidate_id does not match profile")
if analysis is not None and self.posting_id != analysis.posting_id:
errors.append("draft posting_id does not match analysis")
known_evidence = set(profile.evidence_by_id)
requirement_by_id = (
{item.requirement_id: item for item in analysis.requirements}
if analysis is not None
else {}
)
known_requirements = set(requirement_by_id)
for section in self.sections:
for claim in section.claims:
missing_evidence = sorted(set(claim.evidence_ids) - known_evidence)
if missing_evidence:
errors.append(
f"claim {claim.claim_id!r} references unknown evidence "
f"{missing_evidence}"
)
if analysis is not None:
missing_requirements = sorted(
set(claim.requirement_ids) - known_requirements
)
if missing_requirements:
errors.append(
f"claim {claim.claim_id!r} references unknown requirements "
f"{missing_requirements}"
)
for category in claim.sensitive_categories:
supporting_facts = [
profile.evidence_by_id[evidence_id]
for evidence_id in claim.evidence_ids
if evidence_id in profile.evidence_by_id
]
if not any(
fact.sensitive_category == category for fact in supporting_facts
):
errors.append(
f"claim {claim.claim_id!r} marks unsupported sensitive "
f"category {category.value!r}"
)
if errors:
raise ValueError("; ".join(errors))
return self
def assert_matches_plan(self, plan: ContentPlan) -> Self:
"""Verify that drafting did not escape the evidence-bounded plan."""
errors: list[str] = []
if self.candidate_id != plan.candidate_id:
errors.append("draft candidate_id does not match content plan")
if self.posting_id != plan.posting_id:
errors.append("draft posting_id does not match content plan")
if self.mode is not plan.mode:
errors.append("draft mode does not match content plan")
planned_by_id = {section.section_id: section for section in plan.sections}
drafted_by_id = {section.section_id: section for section in self.sections}
missing_sections = sorted(set(planned_by_id) - set(drafted_by_id))
extra_sections = sorted(set(drafted_by_id) - set(planned_by_id))
if missing_sections:
errors.append(f"planned sections missing from draft: {missing_sections}")
if extra_sections:
errors.append(f"unplanned draft sections: {extra_sections}")
for section_id in sorted(set(planned_by_id) & set(drafted_by_id)):
planned = planned_by_id[section_id]
drafted = drafted_by_id[section_id]
if drafted.section_type is not planned.section_type:
errors.append(f"section {section_id!r} changed planned type")
if drafted.order != planned.order:
errors.append(f"section {section_id!r} changed planned order")
if len(drafted.claims) > planned.bullet_budget:
errors.append(f"section {section_id!r} exceeds bullet budget")
allowed_evidence = set(planned.evidence_ids)
allowed_requirements = set(planned.requirement_ids)
for claim in drafted.claims:
if not set(claim.evidence_ids) <= allowed_evidence:
errors.append(
f"claim {claim.claim_id!r} uses unplanned evidence"
)
if not set(claim.requirement_ids) <= allowed_requirements:
errors.append(
f"claim {claim.claim_id!r} uses unplanned requirements"
)
if errors:
raise ValueError("; ".join(errors))
return self
def assert_matches_evidence_map(self, evidence_map: EvidenceMap) -> Self:
"""Require every claim requirement to share a mapped evidence item."""
errors: list[str] = []
if self.posting_id != evidence_map.posting_id:
errors.append("draft posting_id does not match evidence map")
matches = {match.requirement_id: match for match in evidence_map.matches}
for section in self.sections:
for claim in section.claims:
claim_evidence = set(claim.evidence_ids)
for requirement_id in claim.requirement_ids:
match = matches.get(requirement_id)
if match is None:
errors.append(
f"claim {claim.claim_id!r} uses unmapped requirement "
f"{requirement_id!r}"
)
elif match.match_type is EvidenceMatchType.GAP:
errors.append(
f"claim {claim.claim_id!r} uses gap requirement "
f"{requirement_id!r}"
)
elif not claim_evidence & set(match.evidence_ids):
errors.append(
f"claim {claim.claim_id!r} has no evidence mapped to "
f"requirement {requirement_id!r}"
)
if errors:
raise ValueError("; ".join(errors))
return self
def fingerprint(self) -> str:
"""Return a canonical SHA-256 binding for quality/audit artifacts."""
payload = json.dumps(
self.model_dump(mode="json", exclude={"generated_at"}),
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
return hashlib.sha256(payload).hexdigest()
class QualitySeverity(StrEnum):
INFO = "info"
WARNING = "warning"
ERROR = "error"
CRITICAL = "critical"
class QualityCategory(StrEnum):
EVIDENCE = "evidence"
JOB_ALIGNMENT = "job_alignment"
COMPLETENESS = "completeness"
CONSISTENCY = "consistency"
CHRONOLOGY = "chronology"
KOREAN_LANGUAGE = "korean_language"
READABILITY = "readability"
FORMATTING = "formatting"
PRIVACY = "privacy"
BIAS = "bias"
class QualityFinding(DomainModel):
finding_id: Identifier
code: Identifier
severity: QualitySeverity
category: QualityCategory
message: NonEmptyText
location: str | None = Field(default=None, max_length=500)
claim_id: Identifier | None = None
evidence_ids: list[Identifier] = Field(default_factory=list, max_length=100)
suggestion: str | None = Field(default=None, max_length=2_000)
@field_validator("evidence_ids")
@classmethod
def validate_evidence_ids(cls, values: list[str]) -> list[str]:
if _duplicates(values):
raise ValueError("quality finding evidence references must be unique")
return values
@computed_field
@property
def blocking(self) -> bool:
return self.severity in {QualitySeverity.ERROR, QualitySeverity.CRITICAL}
class QualityReport(DomainModel):
report_id: Identifier
draft_id: Identifier
draft_fingerprint: Annotated[
str, StringConstraints(pattern=r"^[a-f0-9]{64}$")
] | None = None
evaluation_fingerprint: Annotated[
str, StringConstraints(pattern=r"^[a-f0-9]{64}$")
] | None = None
overall_score: float = Field(default=0.0, ge=0, le=100)
evidence_coverage: float = Field(default=0.0, ge=0, le=1)
requirement_coverage: float = Field(default=0.0, ge=0, le=1)
category_scores: dict[QualityCategory, float] = Field(default_factory=dict)
findings: list[QualityFinding] = Field(default_factory=list, max_length=1_000)
minimum_score: float = Field(default=90, ge=0, le=100)
minimum_evidence_coverage: float = Field(default=1.0, ge=0, le=1)
minimum_requirement_coverage: float = Field(default=0.80, ge=0, le=1)
evaluated_at: AwareDatetime = Field(default_factory=_utc_now)
@field_validator("category_scores")
@classmethod
def validate_category_scores(
cls, values: dict[QualityCategory, float]
) -> dict[QualityCategory, float]:
invalid = [score for score in values.values() if not 0 <= score <= 100]
if invalid:
raise ValueError("all category scores must be between 0 and 100")
return values
@model_validator(mode="after")
def validate_findings(self) -> Self:
duplicates = _duplicates([finding.finding_id for finding in self.findings])
if duplicates:
raise ValueError(f"duplicate finding_id values: {duplicates}")
return self
@computed_field
@property
def passed(self) -> bool:
return (
self.overall_score >= self.minimum_score
and self.evidence_coverage >= self.minimum_evidence_coverage
and self.requirement_coverage >= self.minimum_requirement_coverage
and not any(finding.blocking for finding in self.findings)
)
@computed_field
@property
def blocking_count(self) -> int:
return sum(finding.blocking for finding in self.findings)
class GenerationConfig(DomainModel):
output_mode: OutputMode = OutputMode.MARKDOWN
resume_mode: ResumeMode = ResumeMode.PRIVATE_MODERN
locale: Literal["ko-KR"] = "ko-KR"
as_of_date: date = Field(default_factory=date.today)
max_pages: int = Field(default=2, ge=1, le=5)
strict_evidence: bool = True
include_photo: bool = False
allowed_sensitive_categories: set[SensitiveDataCategory] = Field(
default_factory=set
)
employer_required_sensitive_categories: set[SensitiveDataCategory] = Field(
default_factory=set
)
minimum_quality_score: float = Field(default=90, ge=0, le=100)
minimum_evidence_coverage: float = Field(default=1.0, ge=0, le=1)
minimum_requirement_coverage: float = Field(default=0.80, ge=0, le=1)
date_format: Literal["YYYY.MM", "YYYY.MM.DD"] = "YYYY.MM"
section_order: list[SectionType] = Field(
default_factory=lambda: [
SectionType.SUMMARY,
SectionType.CORE_COMPETENCIES,
SectionType.EXPERIENCE,
SectionType.PROJECTS,
SectionType.EDUCATION,
SectionType.SKILLS,
SectionType.CERTIFICATIONS,
]
)
@model_validator(mode="after")
def validate_privacy_configuration(self) -> Self:
configured_sensitive = (
self.allowed_sensitive_categories
| self.employer_required_sensitive_categories
)
if configured_sensitive & PROHIBITED_SENSITIVE_CATEGORIES:
raise ValueError(
"prohibited health, political opinion, property, national IDs, "
"and bank accounts can never be enabled"
)
if self.include_photo and SensitiveDataCategory.PHOTO not in (
self.allowed_sensitive_categories
):
raise ValueError("include_photo requires PHOTO in allowed sensitive data")
if self.resume_mode is ResumeMode.PUBLIC_BLIND:
if self.include_photo or configured_sensitive:
raise ValueError("public blind mode forbids photo and all sensitive data")
elif self.resume_mode is not ResumeMode.EMPLOYER_FORM:
if configured_sensitive:
raise ValueError(
"sensitive data can only be enabled for an employer_form"
)
else:
unrequested = (
self.allowed_sensitive_categories
- self.employer_required_sensitive_categories
)
if unrequested:
values = sorted(category.value for category in unrequested)
raise ValueError(
"sensitive data requires a recorded employer requirement: "
f"{values}"
)
if len(self.section_order) != len(set(self.section_order)):
raise ValueError("section_order values must be unique")
return self
def assert_profile_compatible(self, profile: CandidateProfile) -> Self:
"""Ensure configured sensitive fields have active candidate consent."""
instant = datetime.combine(
self.as_of_date, datetime.min.time(), tzinfo=timezone.utc
)
active_categories = {
consent.category
for consent in profile.consents
if consent.is_active_at(instant)
}
missing = self.allowed_sensitive_categories - active_categories
if missing:
values = sorted(category.value for category in missing)
raise ValueError(f"no active consent for sensitive categories: {values}")
return self
__all__ = [
"CandidateFact",
"CandidateProfile",
"ClaimKind",
"ContentPlan",
"ConstraintKind",
"ContactInfo",
"DateRange",
"DraftClaim",
"DraftSection",
"EmploymentType",
"EvidenceCategory",
"EvidenceItem",
"EvidenceMap",
"EvidenceMatch",
"EvidenceMatchType",
"EvidenceSource",
"GenerationConfig",
"JobAnalysis",
"JobPosting",
"JobRequirement",
"OutputMode",
"PlannedSection",
"PostingConstraint",
"QualityCategory",
"QualityFinding",
"QualityReport",
"QualitySeverity",
"RequirementCategory",
"RequirementKind",
"ResumeDate",
"ResumeDraft",
"ResumeMode",
"ResumeRecords",
"SectionType",
"SensitiveDataCategory",
"SensitiveDataConsent",
"VerificationStatus",
]