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

442 lines
14 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.
"""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",
]