from typing import Literal, Optional from pydantic import BaseModel, ConfigDict, Field from app.utils.prompts.schemas import MarketingPromptOutput class AttributeInfo(BaseModel): """음악 속성 정보""" genre: str = Field(..., description="음악 장르") vocal: str = Field(..., description="보컬 스타일") tempo: str = Field(..., description="템포") mood: str = Field(..., description="분위기") class GenerateRequestImg(BaseModel): """이미지 URL 스키마""" url: str = Field(..., description="이미지 URL") name: Optional[str] = Field(None, description="이미지명 (없으면 URL에서 추출)") class GenerateRequestInfo(BaseModel): """생성 요청 정보 스키마 (이미지 제외)""" customer_name: str = Field(..., description="고객명/가게명") region: str = Field(..., description="지역명") detail_region_info: Optional[str] = Field(None, description="상세 지역 정보") attribute: AttributeInfo = Field(..., description="음악 속성 정보") language: str = Field( default="Korean", description="가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)", ) class GenerateRequest(GenerateRequestInfo): """기본 생성 요청 스키마 (이미지 없음, JSON body) 이미지 없이 프로젝트 정보만 전달합니다. """ model_config = ConfigDict( json_schema_extra={ "example": { "customer_name": "스테이 머뭄", "region": "군산", "detail_region_info": "군산 신흥동 말랭이 마을", "attribute": { "genre": "K-Pop", "vocal": "Raspy", "tempo": "110 BPM", "mood": "happy", }, "language": "Korean", } } ) class GenerateUrlsRequest(GenerateRequestInfo): """URL 기반 생성 요청 스키마 (JSON body) GenerateRequestInfo를 상속받아 이미지 목록을 추가합니다. """ model_config = ConfigDict( json_schema_extra={ "example": { "customer_name": "스테이 머뭄", "region": "군산", "detail_region_info": "군산 신흥동 말랭이 마을", "attribute": { "genre": "K-Pop", "vocal": "Raspy", "tempo": "110 BPM", "mood": "happy", }, "language": "Korean", "images": [ {"url": "https://example.com/images/image_001.jpg"}, {"url": "https://example.com/images/image_002.jpg", "name": "외관"}, ], } } ) images: list[GenerateRequestImg] = Field( ..., description="이미지 URL 목록", min_length=1 ) class GenerateUploadResponse(BaseModel): """파일 업로드 기반 생성 응답 스키마""" task_id: str = Field(..., description="작업 고유 식별자 (UUID7)") status: Literal["processing", "completed", "failed"] = Field( ..., description="작업 상태" ) message: str = Field(..., description="응답 메시지") uploaded_count: int = Field(..., description="업로드된 이미지 개수") class GenerateResponse(BaseModel): """생성 응답 스키마""" task_id: str = Field(..., description="작업 고유 식별자 (UUID7)") status: Literal["processing", "completed", "failed"] = Field( ..., description="작업 상태" ) message: str = Field(..., description="응답 메시지") class CrawlingRequest(BaseModel): """크롤링 요청 스키마""" model_config = ConfigDict( json_schema_extra={ "example": { "url": "https://map.naver.com/p/search/%EC%8A%A4%ED%85%8C%EC%9D%B4%EB%A8%B8%EB%AD%84/place/1133638931?c=14.70,0,0,0,dh&placePath=/photo?businessCategory=pension&fromPanelNum=2&locale=ko&searchText=%EC%8A%A4%ED%85%8C%EC%9D%B4%EB%A8%B8%EB%AD%84&svcName=map_pcv5×tamp=202512191123&fromPanelNum=2&locale=ko&searchText=%EC%8A%A4%ED%85%8C%EC%9D%B4%EB%A8%B8%EB%AD%84&svcName=map_pcv5×tamp=202512191007&from=map&entry=bmp&filterType=%EC%97%85%EC%B2%B4&businessCategory=pension" } } ) url: str = Field(..., description="네이버 지도 장소 URL") class AutoCompleteRequest(BaseModel): """자동완성 요청 스키마""" model_config = ConfigDict( json_schema_extra={ "example": { 'title': '스테이,머뭄', 'address': '전북특별자치도 군산시 신흥동 63-18', 'roadAddress': '전북특별자치도 군산시 절골길 18', } } ) title: str = Field(..., description="네이버 검색 place API Title") address: str = Field(..., description="네이버 검색 place API 지번주소") roadAddress: Optional[str] = Field(None, description="네이버 검색 place API 도로명주소") class AccommodationSearchItem(BaseModel): """숙박 검색 결과 아이템""" title: str = Field(..., description="숙소명 (HTML 태그 포함 가능)") address: str = Field(..., description="지번 주소") roadAddress: str = Field(default="", description="도로명 주소") class AccommodationSearchResponse(BaseModel): """숙박 자동완성 검색 응답""" model_config = ConfigDict( json_schema_extra={ "example": { "query": "스테이 머뭄", "count": 2, "items": [ { "title": "스테이,머뭄", "address": "전북특별자치도 군산시 신흥동 63-18", "roadAddress": "전북특별자치도 군산시 절골길 18", }, { "title": "머뭄스테이", "address": "전북특별자치도 군산시 비응도동 123", "roadAddress": "전북특별자치도 군산시 비응로 456", }, ], } } ) query: str = Field(..., description="검색어") count: int = Field(..., description="검색 결과 수") items: list[AccommodationSearchItem] = Field( default_factory=list, description="검색 결과 목록" ) class ProcessedInfo(BaseModel): """가공된 장소 정보 스키마""" customer_name: str = Field(..., description="고객명/가게명 (base_info.name)") region: str = Field(..., description="지역명 (roadAddress에서 추출한 시 이름)") detail_region_info: str = Field(..., description="상세 지역 정보 (roadAddress)") # class MarketingAnalysisDetail(BaseModel): # detail_title : str = Field(..., description="디테일 카테고리 이름") # detail_description : str = Field(..., description="해당 항목 설명") # class MarketingAnalysisReport(BaseModel): # """마케팅 분석 리포트 스키마""" # summary : str = Field(..., description="비즈니스 한 줄 요약") # details : list[MarketingAnalysisDetail] = Field(default_factory=list, description="개별 디테일") # class MarketingAnalysis(BaseModel): # """마케팅 분석 결과 스키마""" # # report: MarketingAnalysisReport = Field(..., description="마케팅 분석 리포트") # tags: list[str] = Field(default_factory=list, description="추천 태그 목록") # selling_points: list[str] = Field(default_factory=list, description="추천 부대시설 목록") class CrawlingResponse(BaseModel): """크롤링 응답 스키마""" model_config = ConfigDict( json_schema_extra={ "example": { "status": "completed", "image_list": ["https://example.com/image1.jpg", "https://example.com/image2.jpg"], "image_count": 2, "processed_info": { "customer_name": "스테이 머뭄", "region": "군산", "detail_region_info": "전북특별자치도 군산시 절골길 18" }, "marketing_analysis": { "brand_identity": { "location_feature_analysis": "전북 군산시 절골길 일대는 도시의 편의성과 근교의 한적함을 동시에 누릴 수 있어 ‘조용한 재충전’ 수요에 적합합니다. 군산의 레트로 감성과 주변 관광 동선 결합이 쉬워 1~2박 체류형 여행지로 매력적입니다.", "concept_scalability": "‘머뭄’이라는 네이밍을 ‘잠시 멈춰 머무는 시간’으로 확장해, 느린 체크인·명상/독서 큐레이션·로컬 티/다과 등 체류 경험형 서비스로 고도화가 가능합니다. 로컬 콘텐츠(군산 빵/커피, 근대문화 투어)와 결합해 패키지화하면 재방문 명분을 만들 수 있습니다." }, "market_positioning": { "category_definition": "군산 감성 ‘슬로우 스테이’ 프라이빗 숙소", "core_value": "아무것도 하지 않아도 회복되는 ‘멈춤의 시간’" }, "target_persona": [ { "persona": "번아웃 회복형 직장인 커플: 주말에 조용히 쉬며 리셋을 원하는 2인 여행자", "age": { "min_age": 27, "max_age": 39 }, "favor_target": [ "조용한 동네 분위기", "미니멀/내추럴 인테리어", "편안한 침구와 숙면 환경", "셀프 체크인 선호", "카페·맛집 연계 동선" ], "decision_trigger": "‘조용히 쉬는 데 최적화’된 프라이빗함과 숙면 컨디션(침구/동선/소음 차단) 확신" }, { "persona": "감성 기록형 친구 여행: 사진과 무드를 위해 공간을 선택하는 2~3인 여성 그룹", "age": { "min_age": 23, "max_age": 34 }, "favor_target": [ "자연광 좋은 공간", "감성 소품/컬러 톤", "포토존(거울/창가/테이블)", "와인·디저트 페어링", "야간 무드 조명" ], "decision_trigger": "사진이 ‘그대로 작품’이 되는 포토 스팟과 야간 무드 연출 요소" }, { "persona": "로컬 탐험형 소도시 여행자: 군산의 레트로/로컬 콘텐츠를 깊게 즐기는 커플·솔로", "age": { "min_age": 28, "max_age": 45 }, "favor_target": [ "근대문화/레트로 감성", "로컬 맛집·빵집 투어", "동선 효율(차로 이동 용이)", "체크아웃 후 관광 연계", "조용한 밤" ], "decision_trigger": "‘군산 로컬 동선’과 결합하기 좋은 위치 + 숙소 자체의 휴식 완성도" } ], "selling_points": [ { "english_category": "LOCATION", "korean_category": "입지 환경", "description": "군산 감성 동선", "score": 88 }, { "english_category": "HEALING", "korean_category": "힐링 요소", "description": "멈춤이 되는 쉼", "score": 92 }, { "english_category": "PRIVACY", "korean_category": "프라이버시", "description": "방해 없는 머뭄", "score": 86 }, { "english_category": "NIGHT MOOD", "korean_category": "야간 감성", "description": "밤이 예쁜 조명", "score": 84 }, { "english_category": "PHOTO SPOT", "korean_category": "포토 스팟", "description": "자연광 포토존", "score": 83 }, { "english_category": "SHORT GETAWAY", "korean_category": "숏브레이크", "description": "주말 리셋 스테이", "score": 89 }, { "english_category": "HOSPITALITY", "korean_category": "서비스", "description": "세심한 웰컴감", "score": 80 } ], "target_keywords": [ "군산숙소", "군산감성숙소", "전북숙소추천", "군산여행", "커플스테이", "주말여행", "감성스테이", "조용한숙소", "힐링스테이", "스테이머뭄" ] } } } ) status: str = Field( default="completed", description="처리 상태 (completed: 성공, failed: ChatGPT 분석 실패)" ) image_list: Optional[list[str]] = Field(None, description="이미지 URL 목록") image_count: int = Field(..., description="이미지 개수") processed_info: Optional[ProcessedInfo] = Field( None, description="가공된 장소 정보 (customer_name, region, detail_region_info)" ) marketing_analysis: Optional[MarketingPromptOutput] = Field( None, description="마케팅 분석 결과 . 실패 시 null" ) class ErrorResponse(BaseModel): """에러 응답 스키마""" success: bool = Field(default=False, description="요청 성공 여부") error_code: str = Field(..., description="에러 코드") message: str = Field(..., description="에러 메시지") detail: Optional[str] = Field(None, description="상세 에러 정보") # ============================================================================= # Image Upload Schemas # ============================================================================= class ImageUrlItem(BaseModel): """이미지 URL 아이템 스키마""" url: str = Field(..., description="외부 이미지 URL") name: Optional[str] = Field(None, description="이미지명 (없으면 URL에서 추출)") class ImageUploadRequest(BaseModel): """이미지 업로드 요청 스키마 (JSON body 부분) URL 이미지 목록을 전달합니다. 바이너리 파일은 multipart/form-data로 별도 전달됩니다. """ model_config = ConfigDict( json_schema_extra={ "example": { "images": [ {"url": "https://example.com/images/image_001.jpg"}, {"url": "https://example.com/images/image_002.jpg", "name": "외관"}, ] } } ) images: Optional[list[ImageUrlItem]] = Field( None, description="외부 이미지 URL 목록" ) class ImageUploadResultItem(BaseModel): """업로드된 이미지 결과 아이템""" id: int = Field(..., description="이미지 ID") img_name: str = Field(..., description="이미지명") img_url: str = Field(..., description="이미지 URL") img_order: int = Field(..., description="이미지 순서") source: Literal["url", "file", "blob"] = Field( ..., description="이미지 소스 (url: 외부 URL, file: 로컬 서버, blob: Azure Blob)" ) class ImageUploadResponse(BaseModel): """이미지 업로드 응답 스키마""" model_config = ConfigDict( json_schema_extra={ "example": { "task_id": "0694b716-dbff-7219-8000-d08cb5fce431", "total_count": 3, "url_count": 2, "file_count": 1, "saved_count": 3, "images": [ { "id": 1, "img_name": "외관", "img_url": "https://example.com/images/image_001.jpg", "img_order": 0, "source": "url", }, { "id": 2, "img_name": "내부", "img_url": "https://example.com/images/image_002.jpg", "img_order": 1, "source": "url", }, { "id": 3, "img_name": "uploaded_image.jpg", "img_url": "/media/image/2024-01-15/0694b716-dbff-7219-8000-d08cb5fce431/uploaded_image_002.jpg", "img_order": 2, "source": "file", }, ], "image_urls": [ "https://example.com/images/image_001.jpg", "https://example.com/images/image_002.jpg", "/media/image/2024-01-15/0694b716-dbff-7219-8000-d08cb5fce431/uploaded_image_002.jpg", ], } } ) task_id: str = Field(..., description="작업 고유 식별자 (새로 생성된 UUID7)") total_count: int = Field(..., description="총 업로드된 이미지 개수") url_count: int = Field(..., description="URL로 등록된 이미지 개수") file_count: int = Field(..., description="파일로 업로드된 이미지 개수") saved_count: int = Field(..., description="Image 테이블에 저장된 row 수") images: list[ImageUploadResultItem] = Field(..., description="업로드된 이미지 목록") image_urls: list[str] = Field(..., description="Image 테이블에 저장된 현재 task_id의 이미지 URL 목록")