diff --git a/myeongsung/app/schemas/resume_dto.py b/myeongsung/app/schemas/resume_dto.py index 9ec4b17..aaf148f 100644 --- a/myeongsung/app/schemas/resume_dto.py +++ b/myeongsung/app/schemas/resume_dto.py @@ -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]] = { @@ -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] 상세 서술형 @@ -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): diff --git a/myeongsung/app/services/experience_preset_service.py b/myeongsung/app/services/experience_preset_service.py new file mode 100644 index 0000000..b72e1d1 --- /dev/null +++ b/myeongsung/app/services/experience_preset_service.py @@ -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, + ) diff --git a/myeongsung/tests/test_experience_step2_v2.py b/myeongsung/tests/test_experience_step2_v2.py new file mode 100644 index 0000000..44de036 --- /dev/null +++ b/myeongsung/tests/test_experience_step2_v2.py @@ -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()