Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions myeongsung/app/schemas/resume_dto.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from pydantic import BaseModel, ConfigDict, Field, model_validator
from typing import List, Optional, Union, Any, Dict
from datetime import datetime
from typing import List, Optional, Union, Any, Dict

from pydantic import BaseModel, ConfigDict, Field, model_validator


PRESET_FIELDS: Dict[str, List[str]] = {
Expand Down Expand Up @@ -153,6 +154,20 @@ class ExperienceSummary(BaseModel):
class Step1ExtractionResponse(BaseModel):
experiences: List[ExperienceSummary] = Field(..., description="1차 추출된 경험 목록")

class PresetFieldDefinition(BaseModel):
key: str = Field(..., description="Spring PresetRegistry의 필드 키")
label: str = Field(..., description="필드 한글 라벨")


class ExperiencePresetSchema(BaseModel):
experience_group: str = Field(..., description="경험 대분류 한글명")
experience_type: str = Field(..., description="Spring ExperienceType enum 코드")
experience_type_name: str = Field(..., description="경험 소분류 한글명")
fields: List[PresetFieldDefinition] = Field(
default_factory=list,
description="해당 경험 유형에서 허용하는 basic_info 필드",
)

# ── 2차 추출 (소분류별 맞춤 스키마) ──────────────────────────────────────

# [1] 상세 서술형
Expand Down Expand Up @@ -310,6 +325,12 @@ def normalize_basic_info_after_validation(self):
class Step2ExtractionResponse(BaseModel):
experiences: List[Step2ExtractedExperience] = Field(..., description="2차 추출된 경험 상세 목록")

class Step2V2ExtractionResponse(BaseModel):
experiences: List[Dict[str, Any]] = Field(
...,
description="Spring 런타임 프리셋으로 검증된 2차 추출 결과",
)


# ── 경험 병합 후보 검사 스키마 ──────────────────────────────────────
class MergeExperiencePayload(BaseModel):
Expand Down
50 changes: 50 additions & 0 deletions myeongsung/app/services/experience_preset_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import re
from typing import Optional

from pydantic import ConfigDict, Field, create_model

from app.schemas.resume_dto import (
ExperiencePresetSchema,
ExperienceSummary,
Step2ExtractedExperience,
)


def build_dynamic_step2_model(
selected_experience: ExperienceSummary,
preset_schema: ExperiencePresetSchema,
):
if preset_schema.experience_type_name != selected_experience.experience_type:
raise ValueError("선택 경험과 프리셋의 경험 유형이 일치하지 않습니다.")
if preset_schema.experience_group != selected_experience.experience_group:
raise ValueError("선택 경험과 프리셋의 경험 그룹이 일치하지 않습니다.")

basic_info_fields = {
field.key: (
Optional[str],
Field(default=None, description=field.label),
)
for field in preset_schema.fields
}
safe_type_name = re.sub(r"[^0-9A-Za-z_]", "_", preset_schema.experience_type)
basic_info_model = create_model(
f"{safe_type_name}RuntimeBasicInfo",
__config__=ConfigDict(extra="forbid"),
**basic_info_fields,
)

experience_fields = {}
for name, field_info in Step2ExtractedExperience.model_fields.items():
if name == "basic_info":
experience_fields[name] = (
basic_info_model,
Field(..., description="Spring PresetRegistry 기반 유형별 기본 필드"),
)
else:
experience_fields[name] = (field_info.annotation, field_info)

return create_model(
f"{safe_type_name}RuntimeStep2Experience",
__config__=ConfigDict(extra="forbid"),
**experience_fields,
)
68 changes: 68 additions & 0 deletions myeongsung/tests/test_experience_step2_v2.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import unittest

from pydantic import ValidationError

from app.schemas.resume_dto import ExperiencePresetSchema, ExperienceSummary
from app.services.experience_preset_service import build_dynamic_step2_model


class ExperienceStep2V2Test(unittest.TestCase):

def test_dynamic_model_allows_only_runtime_preset_fields(self):
selected = ExperienceSummary(
experience_name="캡스톤 프로젝트",
experience_group="상세 서술형",
experience_type="프로젝트",
)
preset = ExperiencePresetSchema(
experience_group="상세 서술형",
experience_type="PROJECT",
experience_type_name="프로젝트",
fields=[
{"key": "project_name", "label": "프로젝트명"},
{"key": "period", "label": "진행 기간"},
],
)
model = build_dynamic_step2_model(selected, preset)

parsed = model.model_validate({
"experience_name": "캡스톤 프로젝트",
"experience_group": "상세 서술형",
"experience_type": "프로젝트",
"basic_info": {
"project_name": "캡스톤 프로젝트",
"period": "2026.01 ~ 2026.06",
},
})

self.assertEqual("캡스톤 프로젝트", parsed.basic_info.project_name)
with self.assertRaises(ValidationError):
model.model_validate({
"experience_name": "캡스톤 프로젝트",
"experience_group": "상세 서술형",
"experience_type": "프로젝트",
"basic_info": {
"project_name": "캡스톤 프로젝트",
"unknown_field": "허용되지 않음",
},
})

def test_dynamic_model_rejects_mismatched_group(self):
selected = ExperienceSummary(
experience_name="토익",
experience_group="스펙·증빙형",
experience_type="어학",
)
preset = ExperiencePresetSchema(
experience_group="상세 서술형",
experience_type="LANGUAGE",
experience_type_name="어학",
fields=[],
)

with self.assertRaises(ValueError):
build_dynamic_step2_model(selected, preset)


if __name__ == "__main__":
unittest.main()