442 lines
14 KiB
Python
442 lines
14 KiB
Python
"""Structured Korean resume records with evidence provenance.
|
||
|
||
The generation harness stores atomic evidence as prose because that is the most
|
||
flexible ingestion format. This module complements it with typed records for
|
||
the facts that must remain machine-readable in a Korean resume: employment,
|
||
unpaid experience, education, and certifications.
|
||
|
||
Every record is linked to one or more evidence IDs. The link lets the profile
|
||
validate the structured value against the evidence inventory without making a
|
||
second, untraceable source of truth.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import calendar
|
||
from datetime import date
|
||
from enum import StrEnum
|
||
import re
|
||
from typing import Annotated, Literal, Mapping, Self
|
||
import unicodedata
|
||
|
||
from pydantic import (
|
||
BaseModel,
|
||
ConfigDict,
|
||
Field,
|
||
StringConstraints,
|
||
field_validator,
|
||
model_validator,
|
||
)
|
||
|
||
|
||
RecordIdentifier = Annotated[
|
||
str,
|
||
StringConstraints(
|
||
strip_whitespace=True,
|
||
min_length=1,
|
||
max_length=128,
|
||
pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]*$",
|
||
),
|
||
]
|
||
RecordText = Annotated[
|
||
str, StringConstraints(strip_whitespace=True, min_length=1, max_length=300)
|
||
]
|
||
|
||
|
||
class RecordModel(BaseModel):
|
||
"""Strict base for profile records exchanged outside the harness."""
|
||
|
||
model_config = ConfigDict(
|
||
extra="forbid",
|
||
str_strip_whitespace=True,
|
||
validate_assignment=True,
|
||
)
|
||
|
||
|
||
class RecordDate(RecordModel):
|
||
"""Calendar date preserving the precision supplied by the candidate."""
|
||
|
||
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
|
||
|
||
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 RecordPeriod(RecordModel):
|
||
"""Closed or ongoing interval used by career, experience, and education."""
|
||
|
||
start: RecordDate
|
||
end: RecordDate | None = None
|
||
ongoing: bool = False
|
||
|
||
@model_validator(mode="after")
|
||
def validate_chronology(self) -> Self:
|
||
if self.ongoing and self.end is not None:
|
||
raise ValueError("ongoing record period cannot have an end date")
|
||
if not self.ongoing and self.end is None:
|
||
raise ValueError("completed record period requires an end date")
|
||
if self.end is not None and self.end.latest() < self.start.earliest():
|
||
raise ValueError("record end date must not be earlier than start date")
|
||
return self
|
||
|
||
def format_ko(self) -> str:
|
||
end = "현재" if self.ongoing else (
|
||
self.end.format_ko() if self.end is not None else ""
|
||
)
|
||
return f"{self.start.format_ko()}–{end}".rstrip("–")
|
||
|
||
def reverse_chronology_key(self) -> tuple[date, date]:
|
||
effective_end = date.max if self.ongoing else (
|
||
self.end.latest() if self.end is not None else self.start.latest()
|
||
)
|
||
return effective_end, self.start.latest()
|
||
|
||
|
||
class EmploymentType(StrEnum):
|
||
"""Employment classifications shared by postings and career histories."""
|
||
|
||
FULL_TIME = "full_time"
|
||
PART_TIME = "part_time"
|
||
FIXED_TERM = "fixed_term"
|
||
CONTRACT = "contract"
|
||
INTERN = "intern"
|
||
FREELANCE = "freelance"
|
||
DISPATCHED = "dispatched"
|
||
OTHER = "other"
|
||
|
||
@property
|
||
def label_ko(self) -> str:
|
||
return {
|
||
self.FULL_TIME: "정규직",
|
||
self.PART_TIME: "시간제",
|
||
self.FIXED_TERM: "기간제",
|
||
self.CONTRACT: "계약직",
|
||
self.INTERN: "인턴",
|
||
self.FREELANCE: "프리랜서",
|
||
self.DISPATCHED: "파견직",
|
||
self.OTHER: "기타",
|
||
}[self]
|
||
|
||
|
||
class ExperienceType(StrEnum):
|
||
PROJECT = "project"
|
||
INTERNSHIP = "internship"
|
||
VOLUNTEER = "volunteer"
|
||
CLUB = "club"
|
||
TRAINING = "training"
|
||
RESEARCH = "research"
|
||
COMMUNITY = "community"
|
||
OTHER = "other"
|
||
|
||
@property
|
||
def label_ko(self) -> str:
|
||
return {
|
||
self.PROJECT: "프로젝트",
|
||
self.INTERNSHIP: "무급 인턴",
|
||
self.VOLUNTEER: "봉사",
|
||
self.CLUB: "동아리",
|
||
self.TRAINING: "교육·훈련",
|
||
self.RESEARCH: "연구",
|
||
self.COMMUNITY: "커뮤니티",
|
||
self.OTHER: "기타",
|
||
}[self]
|
||
|
||
|
||
class EducationStatus(StrEnum):
|
||
GRADUATED = "graduated"
|
||
EXPECTED = "expected"
|
||
IN_PROGRESS = "in_progress"
|
||
COMPLETED = "completed"
|
||
WITHDRAWN = "withdrawn"
|
||
|
||
@property
|
||
def label_ko(self) -> str:
|
||
return {
|
||
self.GRADUATED: "졸업",
|
||
self.EXPECTED: "졸업예정",
|
||
self.IN_PROGRESS: "재학",
|
||
self.COMPLETED: "수료",
|
||
self.WITHDRAWN: "중퇴",
|
||
}[self]
|
||
|
||
|
||
class _EvidenceLinkedRecord(RecordModel):
|
||
record_id: RecordIdentifier
|
||
evidence_ids: list[RecordIdentifier] = Field(min_length=1, max_length=100)
|
||
|
||
@field_validator("evidence_ids")
|
||
@classmethod
|
||
def require_unique_evidence(cls, values: list[str]) -> list[str]:
|
||
if len(values) != len(set(values)):
|
||
raise ValueError("record evidence_ids must be unique")
|
||
return values
|
||
|
||
|
||
class CareerRecord(_EvidenceLinkedRecord):
|
||
"""Paid employment; compensation itself is deliberately not collected."""
|
||
|
||
organization: RecordText
|
||
role: RecordText
|
||
period: RecordPeriod
|
||
employment_type: EmploymentType
|
||
department: str | None = Field(default=None, max_length=300)
|
||
paid: Literal[True] = True
|
||
|
||
|
||
class ExperienceRecord(_EvidenceLinkedRecord):
|
||
"""Unpaid, job-relevant participation kept separate from paid career."""
|
||
|
||
role: RecordText
|
||
period: RecordPeriod
|
||
experience_type: ExperienceType
|
||
organization: str | None = Field(default=None, max_length=300)
|
||
paid: Literal[False] = False
|
||
|
||
|
||
class EducationRecord(_EvidenceLinkedRecord):
|
||
institution: RecordText
|
||
degree: RecordText
|
||
period: RecordPeriod
|
||
status: EducationStatus
|
||
field_of_study: str | None = Field(default=None, max_length=300)
|
||
|
||
@model_validator(mode="after")
|
||
def validate_status_timeline(self) -> Self:
|
||
if self.status is EducationStatus.IN_PROGRESS and not self.period.ongoing:
|
||
raise ValueError("in-progress education requires an ongoing period")
|
||
if self.status is not EducationStatus.IN_PROGRESS and self.period.ongoing:
|
||
raise ValueError("only in-progress education may have an ongoing period")
|
||
if self.status is EducationStatus.EXPECTED and self.period.end is None:
|
||
raise ValueError("expected graduation requires an expected end date")
|
||
return self
|
||
|
||
|
||
class CertificationRecord(_EvidenceLinkedRecord):
|
||
name: RecordText
|
||
issuer: RecordText
|
||
issued_on: RecordDate
|
||
expires_on: RecordDate | None = None
|
||
issuer_is_educational_institution: bool = False
|
||
|
||
@model_validator(mode="after")
|
||
def validate_expiration(self) -> Self:
|
||
if (
|
||
self.expires_on is not None
|
||
and self.expires_on.latest() < self.issued_on.earliest()
|
||
):
|
||
raise ValueError("certification expiry must not precede issue date")
|
||
return self
|
||
|
||
|
||
StructuredRecord = CareerRecord | ExperienceRecord | EducationRecord | CertificationRecord
|
||
|
||
|
||
class ResumeRecords(RecordModel):
|
||
"""Structured record collection attached to a candidate profile.
|
||
|
||
Input order is not meaningful. The ``*_chronological`` methods provide a
|
||
stable newest-first view for deterministic renderers.
|
||
"""
|
||
|
||
careers: list[CareerRecord] = Field(default_factory=list, max_length=200)
|
||
experiences: list[ExperienceRecord] = Field(default_factory=list, max_length=300)
|
||
educations: list[EducationRecord] = Field(default_factory=list, max_length=100)
|
||
certifications: list[CertificationRecord] = Field(
|
||
default_factory=list, max_length=300
|
||
)
|
||
|
||
@model_validator(mode="after")
|
||
def require_unique_record_and_evidence_ownership(self) -> Self:
|
||
records = list(self.all_records())
|
||
record_ids = [record.record_id for record in records]
|
||
if len(record_ids) != len(set(record_ids)):
|
||
raise ValueError("structured record_id values must be globally unique")
|
||
|
||
owner_by_evidence: dict[str, str] = {}
|
||
for record in records:
|
||
for evidence_id in record.evidence_ids:
|
||
owner = owner_by_evidence.setdefault(evidence_id, record.record_id)
|
||
if owner != record.record_id:
|
||
raise ValueError(
|
||
f"evidence {evidence_id!r} is owned by multiple structured records"
|
||
)
|
||
return self
|
||
|
||
def all_records(self) -> tuple[StructuredRecord, ...]:
|
||
return (
|
||
*self.careers,
|
||
*self.experiences,
|
||
*self.educations,
|
||
*self.certifications,
|
||
)
|
||
|
||
def assert_evidence_integrity(
|
||
self, evidence_categories: Mapping[str, str]
|
||
) -> Self:
|
||
"""Validate provenance and category semantics against profile evidence."""
|
||
|
||
allowed_by_type: tuple[tuple[type[StructuredRecord], set[str]], ...] = (
|
||
(CareerRecord, {"career"}),
|
||
(
|
||
ExperienceRecord,
|
||
{"project", "volunteer", "publication", "award", "other"},
|
||
),
|
||
(EducationRecord, {"education"}),
|
||
(CertificationRecord, {"certification"}),
|
||
)
|
||
errors: list[str] = []
|
||
for record in self.all_records():
|
||
allowed = next(
|
||
categories
|
||
for record_type, categories in allowed_by_type
|
||
if isinstance(record, record_type)
|
||
)
|
||
missing = sorted(
|
||
evidence_id
|
||
for evidence_id in record.evidence_ids
|
||
if evidence_id not in evidence_categories
|
||
)
|
||
if missing:
|
||
errors.append(
|
||
f"record {record.record_id!r} references unknown evidence {missing}"
|
||
)
|
||
mismatched = sorted(
|
||
evidence_id
|
||
for evidence_id in record.evidence_ids
|
||
if evidence_id in evidence_categories
|
||
and evidence_categories[evidence_id] not in allowed
|
||
)
|
||
if mismatched:
|
||
errors.append(
|
||
f"record {record.record_id!r} has incompatible evidence categories "
|
||
f"for {mismatched}"
|
||
)
|
||
if errors:
|
||
raise ValueError("; ".join(errors))
|
||
return self
|
||
|
||
def assert_value_grounding(self, evidence_texts: Mapping[str, str]) -> Self:
|
||
"""Require material record values to occur in their linked evidence."""
|
||
|
||
def normalise(value: str) -> str:
|
||
return re.sub(
|
||
r"\s+", "", unicodedata.normalize("NFKC", value).casefold()
|
||
)
|
||
|
||
errors: list[str] = []
|
||
for record in self.all_records():
|
||
combined = normalise(
|
||
" ".join(
|
||
evidence_texts.get(evidence_id, "")
|
||
for evidence_id in record.evidence_ids
|
||
)
|
||
)
|
||
values: list[str] = []
|
||
if isinstance(record, CareerRecord):
|
||
values.extend([record.organization, record.role])
|
||
elif isinstance(record, ExperienceRecord):
|
||
values.append(record.role)
|
||
if record.organization:
|
||
values.append(record.organization)
|
||
elif isinstance(record, EducationRecord):
|
||
values.extend([record.institution, record.degree])
|
||
if record.field_of_study:
|
||
values.append(record.field_of_study)
|
||
else:
|
||
values.extend([record.name, record.issuer])
|
||
|
||
if isinstance(record, CertificationRecord):
|
||
values.append(record.issued_on.format_ko())
|
||
if record.expires_on is not None:
|
||
values.append(record.expires_on.format_ko())
|
||
else:
|
||
values.append(record.period.start.format_ko())
|
||
if record.period.end is not None:
|
||
values.append(record.period.end.format_ko())
|
||
|
||
missing = [value for value in values if normalise(value) not in combined]
|
||
if missing:
|
||
errors.append(
|
||
f"record {record.record_id!r} has values absent from linked "
|
||
f"evidence: {missing}"
|
||
)
|
||
if errors:
|
||
raise ValueError("; ".join(errors))
|
||
return self
|
||
|
||
def careers_chronological(self) -> tuple[CareerRecord, ...]:
|
||
return tuple(
|
||
sorted(
|
||
self.careers,
|
||
key=lambda item: item.period.reverse_chronology_key(),
|
||
reverse=True,
|
||
)
|
||
)
|
||
|
||
def experiences_chronological(self) -> tuple[ExperienceRecord, ...]:
|
||
return tuple(
|
||
sorted(
|
||
self.experiences,
|
||
key=lambda item: item.period.reverse_chronology_key(),
|
||
reverse=True,
|
||
)
|
||
)
|
||
|
||
def educations_chronological(self) -> tuple[EducationRecord, ...]:
|
||
return tuple(
|
||
sorted(
|
||
self.educations,
|
||
key=lambda item: item.period.reverse_chronology_key(),
|
||
reverse=True,
|
||
)
|
||
)
|
||
|
||
def certifications_chronological(self) -> tuple[CertificationRecord, ...]:
|
||
return tuple(
|
||
sorted(
|
||
self.certifications,
|
||
key=lambda item: (item.issued_on.latest(), item.record_id),
|
||
reverse=True,
|
||
)
|
||
)
|
||
|
||
|
||
__all__ = [
|
||
"CareerRecord",
|
||
"CertificationRecord",
|
||
"EducationRecord",
|
||
"EducationStatus",
|
||
"EmploymentType",
|
||
"ExperienceRecord",
|
||
"ExperienceType",
|
||
"RecordDate",
|
||
"RecordPeriod",
|
||
"ResumeRecords",
|
||
"StructuredRecord",
|
||
]
|