init: resume 작성 하네스 설계
This commit is contained in:
@@ -0,0 +1,441 @@
|
||||
"""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",
|
||||
]
|
||||
Reference in New Issue
Block a user