From 628f267b02036317440efc74333bb8fe56ef2006 Mon Sep 17 00:00:00 2001 From: tomchccom Date: Mon, 22 Jun 2026 20:43:10 +0900 Subject: [PATCH] =?UTF-8?q?feat=20:=20=EB=9F=B0=ED=83=80=EC=9E=84=20?= =?UTF-8?q?=EA=B2=BD=ED=97=98=20Preset=20=EC=8A=A4=ED=82=A4=EB=A7=88=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20-=20Spring=20Preset=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=8F=99=EC=A0=81=20basic=5Finfo=20=EB=AA=A8=EB=8D=B8=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20-=20=EB=AF=B8=EB=93=B1=EB=A1=9D=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=EC=99=80=20=EA=B7=B8=EB=A3=B9=20=EB=B6=88?= =?UTF-8?q?=EC=9D=BC=EC=B9=98=20=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- myeongsung/app/schemas/resume_dto.py | 25 ++++++- .../app/services/experience_preset_service.py | 50 ++++++++++++++ myeongsung/tests/test_experience_step2_v2.py | 68 +++++++++++++++++++ 3 files changed, 141 insertions(+), 2 deletions(-) create mode 100644 myeongsung/app/services/experience_preset_service.py create mode 100644 myeongsung/tests/test_experience_step2_v2.py 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()