From 5f1b9da77eee1de971336158d094ba9e64d91d7e Mon Sep 17 00:00:00 2001 From: bluebamus Date: Sat, 20 Dec 2025 23:16:22 +0900 Subject: [PATCH] =?UTF-8?q?home,=20lyric,=20song,=20video=EC=97=90=20?= =?UTF-8?q?=EB=8C=80=ED=95=9C=20app=20=EC=B6=94=EA=B0=80,=20=EA=B0=81=20ap?= =?UTF-8?q?p=EB=B3=84=20=EB=AA=A8=EB=8D=B8=20=EC=B6=94=EA=B0=80,=20admin?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80,=20debug=20=EB=AA=A8=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80,=20lifespan=EC=97=90=20debug=EC=8B=9C=20=EB=8F=99?= =?UTF-8?q?=EC=9E=91=ED=95=98=EB=8A=94=20=EA=B2=83=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/admin_manager.py | 2 +- app/core/common.py | 12 +- app/database/session.py | 2 +- app/home/api/home_admin.py | 102 +++ app/home/models.py | 197 ++++ app/{lyrics => lyric}/__init__.py | 0 app/{lyrics => lyric}/api/__init__.py | 0 app/lyric/api/lyrics_admin.py | 56 ++ app/{lyrics => lyric}/api/routers/__init__.py | 0 .../api/routers/v1/__init__.py | 0 app/lyric/api/routers/v1/router.py | 146 +++ app/{lyrics => lyric}/api/schemas/.gitkeep | 0 app/{lyrics => lyric}/dependencies.py | 0 app/lyric/models.py | 131 +++ app/{lyrics => lyric}/schemas/__init__.py | 0 .../schemas/lyrics_schema.py | 0 app/{lyrics => lyric}/services/__init__.py | 0 app/{lyrics => lyric}/services/base.py | 0 app/lyric/services/lyrics.py | 852 ++++++++++++++++++ app/{lyrics => lyric}/tests/__init__.py | 0 app/{lyrics => lyric}/tests/conftest.py | 0 .../tests/lyrics/__init__.py | 0 .../tests/lyrics/test_module.py | 0 app/{lyrics => lyric}/worker/__init__.py | 0 app/lyrics/api/lyrics_admin.py | 135 --- app/lyrics/models.py | 273 ------ .../lyrics_temp.py => song/__init__.py} | 0 app/song/api/__init__.py | 0 app/song/api/routers/__init__.py | 0 app/song/api/routers/v1/__init__.py | 0 app/{lyrics => song}/api/routers/v1/router.py | 0 app/song/api/schemas/.gitkeep | 0 app/song/api/song_admin.py | 62 ++ app/song/dependencies.py | 8 + app/song/models.py | 144 +++ app/song/schemas/__init__.py | 0 app/song/schemas/song_schema.py | 91 ++ app/song/services/__init__.py | 0 app/song/services/base.py | 24 + .../lyrics.py => song/services/song.py} | 0 app/song/tests/__init__.py | 0 app/song/tests/conftest.py | 0 app/song/tests/lyrics/__init__.py | 0 app/song/tests/lyrics/test_module.py | 0 app/song/worker/__init__.py | 0 app/video/__init__.py | 0 app/video/api/__init__.py | 0 app/video/api/routers/__init__.py | 0 app/video/api/routers/v1/__init__.py | 0 app/video/api/routers/v1/router.py | 146 +++ app/video/api/schemas/.gitkeep | 0 app/video/api/video_admin.py | 62 ++ app/video/dependencies.py | 8 + app/video/models.py | 136 +++ app/video/schemas/__init__.py | 0 app/video/schemas/video_schema.py | 91 ++ app/video/services/__init__.py | 0 app/video/services/base.py | 24 + app/video/services/video.py | 852 ++++++++++++++++++ app/video/tests/__init__.py | 0 app/video/tests/conftest.py | 0 app/video/tests/lyrics/__init__.py | 0 app/video/tests/lyrics/test_module.py | 0 app/video/worker/__init__.py | 0 config.py | 1 + .../mysql_create_tables-dev.sql | 82 ++ docs/database-schema/mysql_create_tables.sql | 83 ++ .../models-refer.py => docs/sample/models.py | 0 main.py | 2 +- o2o_castad_backend.egg-info/PKG-INFO | 17 + o2o_castad_backend.egg-info/SOURCES.txt | 99 ++ .../dependency_links.txt | 1 + o2o_castad_backend.egg-info/requires.txt | 11 + o2o_castad_backend.egg-info/top_level.txt | 1 + 74 files changed, 3437 insertions(+), 416 deletions(-) rename app/{lyrics => lyric}/__init__.py (100%) rename app/{lyrics => lyric}/api/__init__.py (100%) create mode 100644 app/lyric/api/lyrics_admin.py rename app/{lyrics => lyric}/api/routers/__init__.py (100%) rename app/{lyrics => lyric}/api/routers/v1/__init__.py (100%) create mode 100644 app/lyric/api/routers/v1/router.py rename app/{lyrics => lyric}/api/schemas/.gitkeep (100%) rename app/{lyrics => lyric}/dependencies.py (100%) create mode 100644 app/lyric/models.py rename app/{lyrics => lyric}/schemas/__init__.py (100%) rename app/{lyrics => lyric}/schemas/lyrics_schema.py (100%) rename app/{lyrics => lyric}/services/__init__.py (100%) rename app/{lyrics => lyric}/services/base.py (100%) create mode 100644 app/lyric/services/lyrics.py rename app/{lyrics => lyric}/tests/__init__.py (100%) rename app/{lyrics => lyric}/tests/conftest.py (100%) rename app/{lyrics => lyric}/tests/lyrics/__init__.py (100%) rename app/{lyrics => lyric}/tests/lyrics/test_module.py (100%) rename app/{lyrics => lyric}/worker/__init__.py (100%) delete mode 100644 app/lyrics/api/lyrics_admin.py delete mode 100644 app/lyrics/models.py rename app/{lyrics/services/lyrics_temp.py => song/__init__.py} (100%) create mode 100644 app/song/api/__init__.py create mode 100644 app/song/api/routers/__init__.py create mode 100644 app/song/api/routers/v1/__init__.py rename app/{lyrics => song}/api/routers/v1/router.py (100%) create mode 100644 app/song/api/schemas/.gitkeep create mode 100644 app/song/api/song_admin.py create mode 100644 app/song/dependencies.py create mode 100644 app/song/models.py create mode 100644 app/song/schemas/__init__.py create mode 100644 app/song/schemas/song_schema.py create mode 100644 app/song/services/__init__.py create mode 100644 app/song/services/base.py rename app/{lyrics/services/lyrics.py => song/services/song.py} (100%) create mode 100644 app/song/tests/__init__.py create mode 100644 app/song/tests/conftest.py create mode 100644 app/song/tests/lyrics/__init__.py create mode 100644 app/song/tests/lyrics/test_module.py create mode 100644 app/song/worker/__init__.py create mode 100644 app/video/__init__.py create mode 100644 app/video/api/__init__.py create mode 100644 app/video/api/routers/__init__.py create mode 100644 app/video/api/routers/v1/__init__.py create mode 100644 app/video/api/routers/v1/router.py create mode 100644 app/video/api/schemas/.gitkeep create mode 100644 app/video/api/video_admin.py create mode 100644 app/video/dependencies.py create mode 100644 app/video/models.py create mode 100644 app/video/schemas/__init__.py create mode 100644 app/video/schemas/video_schema.py create mode 100644 app/video/services/__init__.py create mode 100644 app/video/services/base.py create mode 100644 app/video/services/video.py create mode 100644 app/video/tests/__init__.py create mode 100644 app/video/tests/conftest.py create mode 100644 app/video/tests/lyrics/__init__.py create mode 100644 app/video/tests/lyrics/test_module.py create mode 100644 app/video/worker/__init__.py create mode 100644 docs/database-schema/mysql_create_tables-dev.sql create mode 100644 docs/database-schema/mysql_create_tables.sql rename app/lyrics/models-refer.py => docs/sample/models.py (100%) create mode 100644 o2o_castad_backend.egg-info/PKG-INFO create mode 100644 o2o_castad_backend.egg-info/SOURCES.txt create mode 100644 o2o_castad_backend.egg-info/dependency_links.txt create mode 100644 o2o_castad_backend.egg-info/requires.txt create mode 100644 o2o_castad_backend.egg-info/top_level.txt diff --git a/app/admin_manager.py b/app/admin_manager.py index 876be71..d36947c 100644 --- a/app/admin_manager.py +++ b/app/admin_manager.py @@ -2,7 +2,7 @@ from fastapi import FastAPI from sqladmin import Admin from app.database.session import engine -from app.lyrics.api.lyrics_admin import ( +from app.lyric.api.lyrics_admin import ( LyricsAttributeAdmin, LyricsPromptTemplateAdmin, LyricsSongResultsAllAdmin, diff --git a/app/core/common.py b/app/core/common.py index f3c3564..f908214 100644 --- a/app/core/common.py +++ b/app/core/common.py @@ -12,12 +12,14 @@ async def lifespan(app: FastAPI): print("Starting up...") try: - pass - # # 데이터베이스 테이블 생성 - # from app.database.session import create_db_tables + from config import prj_settings - # await create_db_tables() - # print("Database tables ready") + # DEBUG 모드일 때만 데이터베이스 테이블 자동 생성 + if prj_settings.DEBUG: + from app.database.session import create_db_tables + + await create_db_tables() + print("Database tables created (DEBUG mode)") except asyncio.TimeoutError: print("Database initialization timed out") # 타임아웃 시 앱 시작 중단하려면 raise, 계속하려면 pass diff --git a/app/database/session.py b/app/database/session.py index 24972b5..3774ab8 100644 --- a/app/database/session.py +++ b/app/database/session.py @@ -38,7 +38,7 @@ AsyncSessionLocal = async_sessionmaker( async def create_db_tables(): async with engine.begin() as connection: - from app.lyrics.models import ( # noqa: F401 + from app.lyric.models import ( # noqa: F401 Attribute, PromptTemplate, SongResultsAll, diff --git a/app/home/api/home_admin.py b/app/home/api/home_admin.py index e69de29..c096847 100644 --- a/app/home/api/home_admin.py +++ b/app/home/api/home_admin.py @@ -0,0 +1,102 @@ +from sqladmin import ModelView + +from app.home.models import Image, Project + + +class ProjectAdmin(ModelView, model=Project): + name = "프로젝트" + name_plural = "프로젝트 목록" + icon = "fa-solid fa-folder" + category = "프로젝트 관리" + page_size = 20 + + column_list = [ + "id", + "store_name", + "region", + "task_id", + "created_at", + ] + + column_details_list = [ + "id", + "store_name", + "region", + "task_id", + "detail_region_info", + "created_at", + ] + + # 폼(생성/수정)에서 제외 + form_excluded_columns = ["created_at", "lyrics", "songs", "videos"] + + column_searchable_list = [ + Project.store_name, + Project.region, + Project.task_id, + ] + + column_default_sort = (Project.created_at, True) # True: DESC (최신순) + + column_sortable_list = [ + Project.id, + Project.store_name, + Project.region, + Project.created_at, + ] + + column_labels = { + "id": "ID", + "store_name": "가게명", + "region": "지역", + "task_id": "작업 ID", + "detail_region_info": "상세 지역 정보", + "created_at": "생성일시", + } + + +class ImageAdmin(ModelView, model=Image): + name = "이미지" + name_plural = "이미지 목록" + icon = "fa-solid fa-image" + category = "프로젝트 관리" + page_size = 20 + + column_list = [ + "id", + "task_id", + "img_name", + "created_at", + ] + + column_details_list = [ + "id", + "task_id", + "img_name", + "img_url", + "created_at", + ] + + # 폼(생성/수정)에서 제외 + form_excluded_columns = ["created_at"] + + column_searchable_list = [ + Image.task_id, + Image.img_name, + ] + + column_default_sort = (Image.created_at, True) # True: DESC (최신순) + + column_sortable_list = [ + Image.id, + Image.img_name, + Image.created_at, + ] + + column_labels = { + "id": "ID", + "task_id": "작업 ID", + "img_name": "이미지명", + "img_url": "이미지 URL", + "created_at": "생성일시", + } diff --git a/app/home/models.py b/app/home/models.py index e69de29..3986c53 100644 --- a/app/home/models.py +++ b/app/home/models.py @@ -0,0 +1,197 @@ +""" +Home 모듈 SQLAlchemy 모델 정의 + +이 모듈은 영상 제작 파이프라인의 핵심 데이터 모델을 정의합니다. +- Project: 프로젝트(사용자 입력 이력) 관리 +- Image: 업로드된 이미지 URL 관리 +""" + +from datetime import datetime +from typing import TYPE_CHECKING, List, Optional + +from sqlalchemy import ( + DateTime, + Index, + Integer, + String, + Text, + func, +) +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database.session import Base + +if TYPE_CHECKING: + from app.lyric.models import Lyric + from app.song.models import Song + from app.video.models import Video + + +class Project(Base): + """ + 프로젝트 테이블 (사용자 입력 이력) + + 영상 제작 요청의 시작점으로, 고객 정보와 지역 정보를 저장합니다. + 하위 테이블(Lyric, Song, Video)의 부모 테이블 역할을 합니다. + + Attributes: + id: 고유 식별자 (자동 증가) + store_name: 고객명 (필수) + region: 지역명 (필수, 예: 서울, 부산, 대구 등) + task_id: 작업 고유 식별자 (UUID 형식, 36자) + detail_region_info: 상세 지역 정보 (선택, JSON 또는 텍스트 형식) + created_at: 생성 일시 (자동 설정) + + Relationships: + lyrics: 생성된 가사 목록 + songs: 생성된 노래 목록 + videos: 최종 영상 결과 목록 + """ + + __tablename__ = "project" + __table_args__ = ( + Index("idx_project_task_id", "task_id"), + Index("idx_project_store_name", "store_name"), + Index("idx_project_region", "region"), + {"mysql_engine": "InnoDB", "mysql_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"}, + ) + + id: Mapped[int] = mapped_column( + Integer, + primary_key=True, + nullable=False, + autoincrement=True, + comment="고유 식별자", + ) + + store_name: Mapped[str] = mapped_column( + String(255), + nullable=False, + index=True, + comment="가게명", + ) + + region: Mapped[str] = mapped_column( + String(100), + nullable=False, + index=True, + comment="지역명 (예: 군산)", + ) + + task_id: Mapped[str] = mapped_column( + String(36), + nullable=False, + unique=True, + comment="프로젝트 작업 고유 식별자 (UUID)", + ) + + detail_region_info: Mapped[Optional[str]] = mapped_column( + Text, + nullable=True, + comment="상세 지역 정보", + ) + + created_at: Mapped[datetime] = mapped_column( + DateTime, + nullable=False, + server_default=func.now(), + comment="생성 일시", + ) + + # Relationships + lyrics: Mapped[List["Lyric"]] = relationship( + "Lyric", + back_populates="project", + cascade="all, delete-orphan", + lazy="selectin", + ) + + songs: Mapped[List["Song"]] = relationship( + "Song", + back_populates="project", + cascade="all, delete-orphan", + lazy="selectin", + ) + + videos: Mapped[List["Video"]] = relationship( + "Video", + back_populates="project", + cascade="all, delete-orphan", + lazy="selectin", + ) + + def __repr__(self) -> str: + def truncate(value: str | None, max_len: int = 10) -> str: + if value is None: + return "None" + return (value[:max_len] + "...") if len(value) > max_len else value + + return ( + f"" + ) + + +class Image(Base): + """ + 업로드 이미지 테이블 + + 사용자가 업로드한 이미지의 URL을 저장합니다. + 독립적으로 관리되며 Project와 직접적인 관계가 없습니다. + + Attributes: + id: 고유 식별자 (자동 증가) + task_id: 이미지 업로드 작업 고유 식별자 (UUID) + img_name: 이미지명 + img_url: 이미지 URL (S3, CDN 등의 경로) + created_at: 생성 일시 (자동 설정) + """ + + __tablename__ = "image" + __table_args__ = ( + {"mysql_engine": "InnoDB", "mysql_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"}, + ) + + id: Mapped[int] = mapped_column( + Integer, + primary_key=True, + nullable=False, + autoincrement=True, + comment="고유 식별자", + ) + + task_id: Mapped[str] = mapped_column( + String(36), + nullable=False, + unique=True, + comment="이미지 업로드 작업 고유 식별자 (UUID)", + ) + + img_name: Mapped[str] = mapped_column( + String(255), + nullable=False, + unique=True, + comment="이미지명", + ) + + img_url: Mapped[str] = mapped_column( + String(2048), + nullable=False, + comment="이미지 URL (blob, CDN 경로)", + ) + + created_at: Mapped[datetime] = mapped_column( + DateTime, + nullable=False, + server_default=func.now(), + comment="생성 일시", + ) + + def __repr__(self) -> str: + task_id_str = (self.task_id[:10] + "...") if len(self.task_id) > 10 else self.task_id + img_name_str = (self.img_name[:10] + "...") if len(self.img_name) > 10 else self.img_name + + return f"" diff --git a/app/lyrics/__init__.py b/app/lyric/__init__.py similarity index 100% rename from app/lyrics/__init__.py rename to app/lyric/__init__.py diff --git a/app/lyrics/api/__init__.py b/app/lyric/api/__init__.py similarity index 100% rename from app/lyrics/api/__init__.py rename to app/lyric/api/__init__.py diff --git a/app/lyric/api/lyrics_admin.py b/app/lyric/api/lyrics_admin.py new file mode 100644 index 0000000..11faa30 --- /dev/null +++ b/app/lyric/api/lyrics_admin.py @@ -0,0 +1,56 @@ +from sqladmin import ModelView + +from app.lyric.models import Lyric + + +class LyricAdmin(ModelView, model=Lyric): + name = "가사" + name_plural = "가사 목록" + icon = "fa-solid fa-music" + category = "가사 관리" + page_size = 20 + + column_list = [ + "id", + "project_id", + "task_id", + "status", + "created_at", + ] + + column_details_list = [ + "id", + "project_id", + "task_id", + "status", + "lyric_prompt", + "lyric_result", + "created_at", + ] + + # 폼(생성/수정)에서 제외 + form_excluded_columns = ["created_at", "songs", "videos"] + + column_searchable_list = [ + Lyric.task_id, + Lyric.status, + ] + + column_default_sort = (Lyric.created_at, True) # True: DESC (최신순) + + column_sortable_list = [ + Lyric.id, + Lyric.project_id, + Lyric.status, + Lyric.created_at, + ] + + column_labels = { + "id": "ID", + "project_id": "프로젝트 ID", + "task_id": "작업 ID", + "status": "상태", + "lyric_prompt": "프롬프트", + "lyric_result": "생성 결과", + "created_at": "생성일시", + } diff --git a/app/lyrics/api/routers/__init__.py b/app/lyric/api/routers/__init__.py similarity index 100% rename from app/lyrics/api/routers/__init__.py rename to app/lyric/api/routers/__init__.py diff --git a/app/lyrics/api/routers/v1/__init__.py b/app/lyric/api/routers/v1/__init__.py similarity index 100% rename from app/lyrics/api/routers/v1/__init__.py rename to app/lyric/api/routers/v1/__init__.py diff --git a/app/lyric/api/routers/v1/router.py b/app/lyric/api/routers/v1/router.py new file mode 100644 index 0000000..f87cd1d --- /dev/null +++ b/app/lyric/api/routers/v1/router.py @@ -0,0 +1,146 @@ +from typing import Any + +from fastapi import APIRouter, Depends, Request # , UploadFile, File, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.session import get_session +from app.lyric.services import lyrics + +router = APIRouter(prefix="/lyrics", tags=["lyrics"]) + + +@router.get("/store") +async def home( + request: Request, + conn: AsyncSession = Depends(get_session), +): + # store_info_list: List[StoreData] = await lyrics_svc.get_store_info(conn) + result: Any = await lyrics.get_store_info(conn) + + # return templates.TemplateResponse( + # request=request, + # name="store.html", + # context={"store_info_list": result}, + # ) + pass + + +@router.post("/attributes") +async def attribute( + request: Request, + conn: AsyncSession = Depends(get_session), +): + print("attributes") + print(await request.form()) + + result: Any = await lyrics.get_attribute(conn) + print(result) + + # return templates.TemplateResponse( + # request=request, + # name="attribute.html", + # context={ + # "attribute_group_dict": result, + # "before_dict": await request.form(), + # }, + # ) + pass + + +@router.post("/fewshot") +async def sample_song( + request: Request, + conn: AsyncSession = Depends(get_session), +): + print("fewshot") + print(await request.form()) + + result: Any = await lyrics.get_sample_song(conn) + print(result) + + # return templates.TemplateResponse( + # request=request, + # name="fewshot.html", + # context={"fewshot_list": result, "before_dict": await request.form()}, + # ) + pass + + +@router.post("/prompt") +async def prompt_template( + request: Request, + conn: AsyncSession = Depends(get_session), +): + print("prompt_template") + print(await request.form()) + + result: Any = await lyrics.get_prompt_template(conn) + print(result) + + print("prompt_template after") + print(await request.form()) + + # return templates.TemplateResponse( + # request=request, + # name="prompt.html", + # context={"prompt_list": result, "before_dict": await request.form()}, + # ) + + pass + + +@router.post("/result") +async def song_result( + request: Request, + conn: AsyncSession = Depends(get_session), +): + print("song_result") + print(await request.form()) + + result: Any = await lyrics.make_song_result(request, conn) + print("result : ", result) + + # return templates.TemplateResponse( + # request=request, + # name="result.html", + # context={"result_dict": result}, + # ) + pass + + +@router.get("/result") +async def get_song_result( + request: Request, + conn: AsyncSession = Depends(get_session), +): + print("get_song_result") + print(await request.form()) + + result: Any = await lyrics.get_song_result(conn) + print("result : ", result) + + # return templates.TemplateResponse( + # request=request, + # name="result.html", + # context={"result_dict": result}, + # ) + pass + + +@router.post("/automation") +async def automation( + request: Request, + conn: AsyncSession = Depends(get_session), +): + print("automation") + print(await request.form()) + + result: Any = await lyrics.make_automation(request, conn) + print("result : ", result) + + # return templates.TemplateResponse( + # request=request, + # name="result.html", + # context={"result_dict": result}, + # ) + pass diff --git a/app/lyrics/api/schemas/.gitkeep b/app/lyric/api/schemas/.gitkeep similarity index 100% rename from app/lyrics/api/schemas/.gitkeep rename to app/lyric/api/schemas/.gitkeep diff --git a/app/lyrics/dependencies.py b/app/lyric/dependencies.py similarity index 100% rename from app/lyrics/dependencies.py rename to app/lyric/dependencies.py diff --git a/app/lyric/models.py b/app/lyric/models.py new file mode 100644 index 0000000..17e958c --- /dev/null +++ b/app/lyric/models.py @@ -0,0 +1,131 @@ +from datetime import datetime +from typing import TYPE_CHECKING, List + +from sqlalchemy import ( + DateTime, + ForeignKey, + Integer, + String, + Text, + func, +) +from sqlalchemy.dialects.mysql import LONGTEXT +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database.session import Base + +if TYPE_CHECKING: + from app.home.models import Project + from app.song.models import Song + from app.video.models import Video + + +class Lyric(Base): + """ + 가사 테이블 + + AI를 통해 생성된 가사 정보를 저장합니다. + 프롬프트와 생성 결과, 처리 상태를 관리합니다. + + Attributes: + id: 고유 식별자 (자동 증가) + project_id: 연결된 Project의 id (외래키) + task_id: 가사 생성 작업의 고유 식별자 (UUID 형식) + status: 처리 상태 (pending, processing, completed, failed 등) + lyric_prompt: 가사 생성에 사용된 프롬프트 + lyric_result: 생성된 가사 결과 (LONGTEXT로 긴 가사 지원) + created_at: 생성 일시 (자동 설정) + + Relationships: + project: 연결된 Project + songs: 이 가사를 사용한 노래 목록 + videos: 이 가사를 사용한 영상 목록 + """ + + __tablename__ = "lyric" + __table_args__ = ( + {"mysql_engine": "InnoDB", "mysql_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"}, + ) + + id: Mapped[int] = mapped_column( + Integer, + primary_key=True, + nullable=False, + autoincrement=True, + comment="고유 식별자", + ) + + project_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("project.id", ondelete="CASCADE"), + nullable=False, + index=True, + comment="연결된 Project의 id", + ) + + task_id: Mapped[str] = mapped_column( + String(36), + nullable=False, + unique=True, + comment="가사 생성 작업 고유 식별자 (UUID)", + ) + + status: Mapped[str] = mapped_column( + String(50), + nullable=False, + comment="처리 상태 (processing, completed, failed)", + ) + + lyric_prompt: Mapped[str] = mapped_column( + Text, + nullable=False, + comment="가사 생성에 사용된 프롬프트", + ) + + lyric_result: Mapped[str] = mapped_column( + LONGTEXT, + nullable=True, + comment="생성된 가사 결과", + ) + + created_at: Mapped[datetime] = mapped_column( + DateTime, + nullable=True, + server_default=func.now(), + comment="생성 일시", + ) + + # Relationships + project: Mapped["Project"] = relationship( + "Project", + back_populates="lyrics", + ) + + songs: Mapped[List["Song"]] = relationship( + "Song", + back_populates="lyric", + cascade="all, delete-orphan", + lazy="selectin", + ) + + videos: Mapped[List["Video"]] = relationship( + "Video", + back_populates="lyric", + cascade="all, delete-orphan", + lazy="selectin", + ) + + def __repr__(self) -> str: + def truncate(value: str | None, max_len: int = 10) -> str: + if value is None: + return "None" + return (value[:max_len] + "...") if len(value) > max_len else value + + return ( + f"" + ) + diff --git a/app/lyrics/schemas/__init__.py b/app/lyric/schemas/__init__.py similarity index 100% rename from app/lyrics/schemas/__init__.py rename to app/lyric/schemas/__init__.py diff --git a/app/lyrics/schemas/lyrics_schema.py b/app/lyric/schemas/lyrics_schema.py similarity index 100% rename from app/lyrics/schemas/lyrics_schema.py rename to app/lyric/schemas/lyrics_schema.py diff --git a/app/lyrics/services/__init__.py b/app/lyric/services/__init__.py similarity index 100% rename from app/lyrics/services/__init__.py rename to app/lyric/services/__init__.py diff --git a/app/lyrics/services/base.py b/app/lyric/services/base.py similarity index 100% rename from app/lyrics/services/base.py rename to app/lyric/services/base.py diff --git a/app/lyric/services/lyrics.py b/app/lyric/services/lyrics.py new file mode 100644 index 0000000..2778ed4 --- /dev/null +++ b/app/lyric/services/lyrics.py @@ -0,0 +1,852 @@ +import random +from typing import List + +from fastapi import Request, status +from fastapi.exceptions import HTTPException +from sqlalchemy import Connection, text +from sqlalchemy.exc import SQLAlchemyError + +from app.lyric.schemas.lyrics_schema import ( + AttributeData, + PromptTemplateData, + SongFormData, + SongSampleData, + StoreData, +) +from app.utils.chatgpt_prompt import chatgpt_api + + +async def get_store_info(conn: Connection) -> List[StoreData]: + try: + query = """SELECT * FROM store_default_info;""" + result = await conn.execute(text(query)) + + all_store_info = [ + StoreData( + id=row[0], + store_info=row[1], + store_name=row[2], + store_category=row[3], + store_region=row[4], + store_address=row[5], + store_phone_number=row[6], + created_at=row[7], + ) + for row in result + ] + + result.close() + return all_store_info + except SQLAlchemyError as e: + print(e) + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.", + ) + except Exception as e: + print(e) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="알수없는 이유로 서비스 오류가 발생하였습니다", + ) + + +async def get_attribute(conn: Connection) -> List[AttributeData]: + try: + query = """SELECT * FROM attribute;""" + result = await conn.execute(text(query)) + + all_attribute = [ + AttributeData( + id=row[0], + attr_category=row[1], + attr_value=row[2], + created_at=row[3], + ) + for row in result + ] + + result.close() + return all_attribute + except SQLAlchemyError as e: + print(e) + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.", + ) + except Exception as e: + print(e) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="알수없는 이유로 서비스 오류가 발생하였습니다", + ) + + +async def get_attribute(conn: Connection) -> List[AttributeData]: + try: + query = """SELECT * FROM attribute;""" + result = await conn.execute(text(query)) + + all_attribute = [ + AttributeData( + id=row[0], + attr_category=row[1], + attr_value=row[2], + created_at=row[3], + ) + for row in result + ] + + result.close() + return all_attribute + except SQLAlchemyError as e: + print(e) + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.", + ) + except Exception as e: + print(e) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="알수없는 이유로 서비스 오류가 발생하였습니다", + ) + + +async def get_sample_song(conn: Connection) -> List[SongSampleData]: + try: + query = """SELECT * FROM song_sample;""" + result = await conn.execute(text(query)) + + all_sample_song = [ + SongSampleData( + id=row[0], + ai=row[1], + ai_model=row[2], + genre=row[3], + sample_song=row[4], + ) + for row in result + ] + + result.close() + return all_sample_song + except SQLAlchemyError as e: + print(e) + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.", + ) + except Exception as e: + print(e) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="알수없는 이유로 서비스 오류가 발생하였습니다", + ) + + +async def get_prompt_template(conn: Connection) -> List[PromptTemplateData]: + try: + query = """SELECT * FROM prompt_template;""" + result = await conn.execute(text(query)) + + all_prompt_template = [ + PromptTemplateData( + id=row[0], + description=row[1], + prompt=row[2], + ) + for row in result + ] + + result.close() + return all_prompt_template + except SQLAlchemyError as e: + print(e) + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.", + ) + except Exception as e: + print(e) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="알수없는 이유로 서비스 오류가 발생하였습니다", + ) + + +async def get_song_result(conn: Connection) -> List[PromptTemplateData]: + try: + query = """SELECT * FROM prompt_template;""" + result = await conn.execute(text(query)) + + all_prompt_template = [ + PromptTemplateData( + id=row[0], + description=row[1], + prompt=row[2], + ) + for row in result + ] + + result.close() + return all_prompt_template + except SQLAlchemyError as e: + print(e) + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.", + ) + except Exception as e: + print(e) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="알수없는 이유로 서비스 오류가 발생하였습니다", + ) + + +async def make_song_result(request: Request, conn: Connection): + try: + # 1. Form 데이터 파싱 + form_data = await SongFormData.from_form(request) + + print(f"\n{'=' * 60}") + print(f"Store ID: {form_data.store_id}") + print(f"Lyrics IDs: {form_data.lyrics_ids}") + print(f"Prompt IDs: {form_data.prompts}") + print(f"{'=' * 60}\n") + + # 2. Store 정보 조회 + store_query = """ + SELECT * FROM store_default_info WHERE id=:id; + """ + store_result = await conn.execute(text(store_query), {"id": form_data.store_id}) + + all_store_info = [ + StoreData( + id=row[0], + store_info=row[1], + store_name=row[2], + store_category=row[3], + store_region=row[4], + store_address=row[5], + store_phone_number=row[6], + created_at=row[7], + ) + for row in store_result + ] + + if not all_store_info: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Store not found: {form_data.store_id}", + ) + + store_info = all_store_info[0] + print(f"Store: {store_info.store_name}") + + # 3. 속성 조회 -- 단계별 선택 프로세서시 구현 필요 없음 + + # 4. Sample Song 조회 및 결합 + combined_sample_song = None + + if form_data.lyrics_ids: + print(f"\n[샘플 가사 조회] - {len(form_data.lyrics_ids)}개") + + lyrics_query = """ + SELECT sample_song FROM song_sample + WHERE id IN :ids + ORDER BY created_at; + """ + lyrics_result = await conn.execute( + text(lyrics_query), {"ids": tuple(form_data.lyrics_ids)} + ) + + sample_songs = [ + row.sample_song for row in lyrics_result.fetchall() if row.sample_song + ] + + if sample_songs: + combined_sample_song = "\n\n".join( + [f"[샘플 {i + 1}]\n{song}" for i, song in enumerate(sample_songs)] + ) + print(f"{len(sample_songs)}개의 샘플 가사 조회 완료") + else: + print("샘플 가사가 비어있습니다") + else: + print("선택된 lyrics가 없습니다") + + # 5. 템플릿 가져오기 + if not form_data.prompts: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="프롬프트 ID가 필요합니다", + ) + + print("템플릿 가져오기") + + prompts_query = """ + SELECT * FROM prompt_template WHERE id=:id; + """ + + # ✅ 수정: store_query → prompts_query + prompts_result = await conn.execute( + text(prompts_query), {"id": form_data.prompts} + ) + + prompts_info = [ + PromptTemplateData( + id=row[0], + description=row[1], + prompt=row[2], + ) + for row in prompts_result + ] + + if not prompts_info: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Prompt not found: {form_data.prompts}", + ) + + prompt = prompts_info[0] + print(f"Prompt Template: {prompt.prompt}") + + # ✅ 6. 프롬프트 조합 + updated_prompt = prompt.prompt.replace("###", form_data.attributes_str).format( + name=store_info.store_name or "", + address=store_info.store_address or "", + category=store_info.store_category or "", + description=store_info.store_info or "", + ) + + updated_prompt += f""" + + 다음은 참고해야 하는 샘플 가사 정보입니다. + + 샘플 가사를 참고하여 작곡을 해주세요. + + {combined_sample_song} + """ + + print(f"\n[업데이트된 프롬프트]\n{updated_prompt}\n") + + # 7. 모델에게 요청 + generated_lyrics = await chatgpt_api.generate_lyrics(prompt=updated_prompt) + + # 글자 수 계산 + total_chars_with_space = len(generated_lyrics) + total_chars_without_space = len( + generated_lyrics.replace(" ", "") + .replace("\n", "") + .replace("\r", "") + .replace("\t", "") + ) + + # final_lyrics 생성 + final_lyrics = f"""속성 {form_data.attributes_str} + 전체 글자 수 (공백 포함): {total_chars_with_space}자 + 전체 글자 수 (공백 제외): {total_chars_without_space}자\r\n\r\n{generated_lyrics}""" + + print("=" * 40) + print("[translate:form_data.attributes_str:] ", form_data.attributes_str) + print("[translate:total_chars_with_space:] ", total_chars_with_space) + print("[translate:total_chars_without_space:] ", total_chars_without_space) + print("[translate:final_lyrics:]") + print(final_lyrics) + print("=" * 40) + + # 8. DB 저장 + insert_query = """ + INSERT INTO song_results_all ( + store_info, store_name, store_category, store_address, store_phone_number, + description, prompt, attr_category, attr_value, + ai, ai_model, genre, + sample_song, result_song, created_at + ) VALUES ( + :store_info, :store_name, :store_category, :store_address, :store_phone_number, + :description, :prompt, :attr_category, :attr_value, + :ai, :ai_model, :genre, + :sample_song, :result_song, NOW() + ); + """ + + # ✅ attr_category, attr_value 추가 + insert_params = { + "store_info": store_info.store_info or "", + "store_name": store_info.store_name, + "store_category": store_info.store_category or "", + "store_address": store_info.store_address or "", + "store_phone_number": store_info.store_phone_number or "", + "description": store_info.store_info or "", + "prompt": form_data.prompts, + "attr_category": ", ".join(form_data.attributes.keys()) + if form_data.attributes + else "", + "attr_value": ", ".join(form_data.attributes.values()) + if form_data.attributes + else "", + "ai": "ChatGPT", + "ai_model": form_data.llm_model, + "genre": "후크송", + "sample_song": combined_sample_song or "없음", + "result_song": final_lyrics, + } + + await conn.execute(text(insert_query), insert_params) + await conn.commit() + + print("결과 저장 완료") + + print("\n전체 결과 조회 중...") + + # 9. 생성 결과 가져오기 (created_at 역순) + select_query = """ + SELECT * FROM song_results_all + ORDER BY created_at DESC; + """ + + all_results = await conn.execute(text(select_query)) + + results_list = [ + { + "id": row.id, + "store_info": row.store_info, + "store_name": row.store_name, + "store_category": row.store_category, + "store_address": row.store_address, + "store_phone_number": row.store_phone_number, + "description": row.description, + "prompt": row.prompt, + "attr_category": row.attr_category, + "attr_value": row.attr_value, + "ai": row.ai, + "ai_model": row.ai_model, + "genre": row.genre, + "sample_song": row.sample_song, + "result_song": row.result_song, + "created_at": row.created_at.isoformat() if row.created_at else None, + } + for row in all_results.fetchall() + ] + + print(f"전체 {len(results_list)}개의 결과 조회 완료\n") + + return results_list + + except HTTPException: + raise + except SQLAlchemyError as e: + print(f"Database Error: {e}") + import traceback + + traceback.print_exc() + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="데이터베이스 연결에 문제가 발생했습니다.", + ) + except Exception as e: + print(f"Unexpected Error: {e}") + import traceback + + traceback.print_exc() + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="서비스 처리 중 오류가 발생했습니다.", + ) + + +async def get_song_result(conn: Connection): # 반환 타입 수정 + try: + select_query = """ + SELECT * FROM song_results_all + ORDER BY created_at DESC; + """ + + all_results = await conn.execute(text(select_query)) + + results_list = [ + { + "id": row.id, + "store_info": row.store_info, + "store_name": row.store_name, + "store_category": row.store_category, + "store_address": row.store_address, + "store_phone_number": row.store_phone_number, + "description": row.description, + "prompt": row.prompt, + "attr_category": row.attr_category, + "attr_value": row.attr_value, + "ai": row.ai, + "ai_model": row.ai_model, + "season": row.season, + "num_of_people": row.num_of_people, + "people_category": row.people_category, + "genre": row.genre, + "sample_song": row.sample_song, + "result_song": row.result_song, + "created_at": row.created_at.isoformat() if row.created_at else None, + } + for row in all_results.fetchall() + ] + + print(f"전체 {len(results_list)}개의 결과 조회 완료\n") + + return results_list + except HTTPException: # HTTPException은 그대로 raise + raise + except SQLAlchemyError as e: + print(f"Database Error: {e}") + import traceback + + traceback.print_exc() + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="데이터베이스 연결에 문제가 발생했습니다.", + ) + except Exception as e: + print(f"Unexpected Error: {e}") + import traceback + + traceback.print_exc() + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="서비스 처리 중 오류가 발생했습니다.", + ) + + +async def make_automation(request: Request, conn: Connection): + try: + # 1. Form 데이터 파싱 + form_data = await SongFormData.from_form(request) + + print(f"\n{'=' * 60}") + print(f"Store ID: {form_data.store_id}") + print(f"{'=' * 60}\n") + + # 2. Store 정보 조회 + store_query = """ + SELECT * FROM store_default_info WHERE id=:id; + """ + store_result = await conn.execute(text(store_query), {"id": form_data.store_id}) + + all_store_info = [ + StoreData( + id=row[0], + store_info=row[1], + store_name=row[2], + store_category=row[3], + store_region=row[4], + store_address=row[5], + store_phone_number=row[6], + created_at=row[7], + ) + for row in store_result + ] + + if not all_store_info: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Store not found: {form_data.store_id}", + ) + + store_info = all_store_info[0] + print(f"Store: {store_info.store_name}") + + # 3. 속성 조회 -- 단계별 선택 프로세서시 구현 필요 없음 + attribute_query = """ + SELECT * FROM attribute; + """ + + attribute_results = await conn.execute(text(attribute_query)) + + # 결과 가져오기 + attribute_rows = attribute_results.fetchall() + + formatted_attributes = "" + selected_categories = [] + selected_values = [] + + if attribute_rows: + attribute_list = [ + AttributeData( + id=row[0], + attr_category=row[1], + attr_value=row[2], + created_at=row[3], + ) + for row in attribute_rows + ] + + # ✅ 각 category에서 하나의 value만 랜덤 선택 + formatted_pairs = [] + for attr in attribute_list: + # 쉼표로 분리 및 공백 제거 + values = [v.strip() for v in attr.attr_value.split(",") if v.strip()] + + if values: + # 랜덤하게 하나만 선택 + selected_value = random.choice(values) + formatted_pairs.append(f"{attr.attr_category} : {selected_value}") + + # ✅ 선택된 category와 value 저장 + selected_categories.append(attr.attr_category) + selected_values.append(selected_value) + + # 최종 문자열 생성 + formatted_attributes = "\n".join(formatted_pairs) + + print(f"\n[포맷팅된 문자열 속성 정보]\n{formatted_attributes}\n") + else: + print("속성 데이터가 없습니다") + formatted_attributes = "" + + # 4. 템플릿 가져오기 + print("템플릿 가져오기 (ID=1)") + + prompts_query = """ + SELECT * FROM prompt_template WHERE id=1; + """ + + prompts_result = await conn.execute(text(prompts_query)) + + row = prompts_result.fetchone() + + if not row: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Prompt ID 1 not found", + ) + + prompt = PromptTemplateData( + id=row[0], + description=row[1], + prompt=row[2], + ) + + print(f"Prompt Template: {prompt.prompt}") + + # 5. 템플릿 조합 + + updated_prompt = prompt.prompt.replace("###", formatted_attributes).format( + name=store_info.store_name or "", + address=store_info.store_address or "", + category=store_info.store_category or "", + description=store_info.store_info or "", + ) + + print("\n" + "=" * 80) + print("업데이트된 프롬프트") + print("=" * 80) + print(updated_prompt) + print("=" * 80 + "\n") + + # 4. Sample Song 조회 및 결합 + combined_sample_song = None + + if form_data.lyrics_ids: + print(f"\n[샘플 가사 조회] - {len(form_data.lyrics_ids)}개") + + lyrics_query = """ + SELECT sample_song FROM song_sample + WHERE id IN :ids + ORDER BY created_at; + """ + lyrics_result = await conn.execute( + text(lyrics_query), {"ids": tuple(form_data.lyrics_ids)} + ) + + sample_songs = [ + row.sample_song for row in lyrics_result.fetchall() if row.sample_song + ] + + if sample_songs: + combined_sample_song = "\n\n".join( + [f"[샘플 {i + 1}]\n{song}" for i, song in enumerate(sample_songs)] + ) + print(f"{len(sample_songs)}개의 샘플 가사 조회 완료") + else: + print("샘플 가사가 비어있습니다") + else: + print("선택된 lyrics가 없습니다") + + # 1. song_sample 테이블의 모든 ID 조회 + print("\n[샘플 가사 랜덤 선택]") + + all_ids_query = """ + SELECT id FROM song_sample; + """ + ids_result = await conn.execute(text(all_ids_query)) + all_ids = [row.id for row in ids_result.fetchall()] + + print(f"전체 샘플 가사 개수: {len(all_ids)}개") + + # 2. 랜덤하게 3개 선택 (또는 전체 개수가 3개 미만이면 전체) + combined_sample_song = None + + if all_ids: + # 3개 또는 전체 개수 중 작은 값 선택 + sample_count = min(3, len(all_ids)) + selected_ids = random.sample(all_ids, sample_count) + + print(f"랜덤 선택된 ID: {selected_ids}") + + # 3. 선택된 ID로 샘플 가사 조회 + lyrics_query = """ + SELECT sample_song FROM song_sample + WHERE id IN :ids + ORDER BY created_at; + """ + lyrics_result = await conn.execute( + text(lyrics_query), {"ids": tuple(selected_ids)} + ) + + sample_songs = [ + row.sample_song for row in lyrics_result.fetchall() if row.sample_song + ] + + # 4. combined_sample_song 생성 + if sample_songs: + combined_sample_song = "\n\n".join( + [f"[샘플 {i + 1}]\n{song}" for i, song in enumerate(sample_songs)] + ) + print(f"{len(sample_songs)}개의 샘플 가사 조회 완료") + else: + print("샘플 가사가 비어있습니다") + else: + print("song_sample 테이블에 데이터가 없습니다") + + # 5. 프롬프트에 샘플 가사 추가 + if combined_sample_song: + updated_prompt += f""" + + 다음은 참고해야 하는 샘플 가사 정보입니다. + + 샘플 가사를 참고하여 작곡을 해주세요. + + {combined_sample_song} + """ + print("샘플 가사 정보가 프롬프트에 추가되었습니다") + else: + print("샘플 가사가 없어 기본 프롬프트만 사용합니다") + + print(f"\n[최종 프롬프트 길이: {len(updated_prompt)} 자]\n") + + # 7. 모델에게 요청 + generated_lyrics = await chatgpt_api.generate_lyrics(prompt=updated_prompt) + + # 글자 수 계산 + total_chars_with_space = len(generated_lyrics) + total_chars_without_space = len( + generated_lyrics.replace(" ", "") + .replace("\n", "") + .replace("\r", "") + .replace("\t", "") + ) + + # final_lyrics 생성 + final_lyrics = f"""속성 {formatted_attributes} + 전체 글자 수 (공백 포함): {total_chars_with_space}자 + 전체 글자 수 (공백 제외): {total_chars_without_space}자\r\n\r\n{generated_lyrics}""" + + # 8. DB 저장 + insert_query = """ + INSERT INTO song_results_all ( + store_info, store_name, store_category, store_address, store_phone_number, + description, prompt, attr_category, attr_value, + ai, ai_model, genre, + sample_song, result_song, created_at + ) VALUES ( + :store_info, :store_name, :store_category, :store_address, :store_phone_number, + :description, :prompt, :attr_category, :attr_value, + :ai, :ai_model, :genre, + :sample_song, :result_song, NOW() + ); + """ + print("\n[insert_params 선택된 속성 확인]") + print(f"Categories: {selected_categories}") + print(f"Values: {selected_values}") + print() + + # attr_category, attr_value + insert_params = { + "store_info": store_info.store_info or "", + "store_name": store_info.store_name, + "store_category": store_info.store_category or "", + "store_address": store_info.store_address or "", + "store_phone_number": store_info.store_phone_number or "", + "description": store_info.store_info or "", + "prompt": prompt.id, + # 랜덤 선택된 category와 value 사용 + "attr_category": ", ".join(selected_categories) + if selected_categories + else "", + "attr_value": ", ".join(selected_values) if selected_values else "", + "ai": "ChatGPT", + "ai_model": "gpt-4o", + "genre": "후크송", + "sample_song": combined_sample_song or "없음", + "result_song": final_lyrics, + } + + await conn.execute(text(insert_query), insert_params) + await conn.commit() + + print("결과 저장 완료") + + print("\n전체 결과 조회 중...") + + # 9. 생성 결과 가져오기 (created_at 역순) + select_query = """ + SELECT * FROM song_results_all + ORDER BY created_at DESC; + """ + + all_results = await conn.execute(text(select_query)) + + results_list = [ + { + "id": row.id, + "store_info": row.store_info, + "store_name": row.store_name, + "store_category": row.store_category, + "store_address": row.store_address, + "store_phone_number": row.store_phone_number, + "description": row.description, + "prompt": row.prompt, + "attr_category": row.attr_category, + "attr_value": row.attr_value, + "ai": row.ai, + "ai_model": row.ai_model, + "genre": row.genre, + "sample_song": row.sample_song, + "result_song": row.result_song, + "created_at": row.created_at.isoformat() if row.created_at else None, + } + for row in all_results.fetchall() + ] + + print(f"전체 {len(results_list)}개의 결과 조회 완료\n") + + return results_list + + except HTTPException: + raise + except SQLAlchemyError as e: + print(f"Database Error: {e}") + import traceback + + traceback.print_exc() + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="데이터베이스 연결에 문제가 발생했습니다.", + ) + except Exception as e: + print(f"Unexpected Error: {e}") + import traceback + + traceback.print_exc() + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="서비스 처리 중 오류가 발생했습니다.", + ) diff --git a/app/lyrics/tests/__init__.py b/app/lyric/tests/__init__.py similarity index 100% rename from app/lyrics/tests/__init__.py rename to app/lyric/tests/__init__.py diff --git a/app/lyrics/tests/conftest.py b/app/lyric/tests/conftest.py similarity index 100% rename from app/lyrics/tests/conftest.py rename to app/lyric/tests/conftest.py diff --git a/app/lyrics/tests/lyrics/__init__.py b/app/lyric/tests/lyrics/__init__.py similarity index 100% rename from app/lyrics/tests/lyrics/__init__.py rename to app/lyric/tests/lyrics/__init__.py diff --git a/app/lyrics/tests/lyrics/test_module.py b/app/lyric/tests/lyrics/test_module.py similarity index 100% rename from app/lyrics/tests/lyrics/test_module.py rename to app/lyric/tests/lyrics/test_module.py diff --git a/app/lyrics/worker/__init__.py b/app/lyric/worker/__init__.py similarity index 100% rename from app/lyrics/worker/__init__.py rename to app/lyric/worker/__init__.py diff --git a/app/lyrics/api/lyrics_admin.py b/app/lyrics/api/lyrics_admin.py deleted file mode 100644 index e25cdc9..0000000 --- a/app/lyrics/api/lyrics_admin.py +++ /dev/null @@ -1,135 +0,0 @@ -from sqladmin import ModelView - -from app.lyrics.models import PromptTemplate # noqa: F401 -from app.lyrics.models import Attribute, SongResultsAll, SongSample, StoreDefaultInfo - - -class LyricsStoreDefaultInfoAdmin(ModelView, model=StoreDefaultInfo): - name = "상가 기본 정보" - name_plural = "상가 정보 목록" - icon = "fa-solid fa-store" - category = "상가 정보 관리" - page_size = 20 - - column_list = ["id", "store_name"] - - # 폼(생성/수정)에서 제외 - form_excluded_columns = ["created_at"] - - column_searchable_list = [ - StoreDefaultInfo.store_name, - StoreDefaultInfo.store_phone_number, - ] - - column_default_sort = (StoreDefaultInfo.store_name, False) # False: ASC, True: DESC - - column_sortable_list = [ - StoreDefaultInfo.created_at, - StoreDefaultInfo.store_phone_number, - ] - - # 폼 컬럼 (기존 유지, user_posts가 관계라면 모델에 정의 필요) - # form_columns = [ - # Post.user_id, - # Post.title, - # Post.content, - # Post.is_published, - # Post.user_posts, # Many-to-Many 또는 One-to-Many 관계 가정 - # ] - - -class LyricsAttributeAdmin(ModelView, model=Attribute): - name = "속성" - name_plural = "속성 목록" - icon = "fa-solid fa-tags" - category = "속성 관리" - page_size = 20 - - column_list = ["id", "attr_category"] - - # 폼(생성/수정)에서 제외 - form_excluded_columns = ["created_at"] - - column_searchable_list = [ - Attribute.attr_category, - Attribute.attr_value, - ] - - column_default_sort = (Attribute.created_at, False) # False: ASC, True: DESC - - column_sortable_list = [ - Attribute.created_at, - ] - - -class LyricsSongSampleAdmin(ModelView, model=SongSample): - name = "가사 샘플" - name_plural = "가사 샘플 목록" - icon = "fa-solid fa-flask" - category = "가사 샘플 관리" - page_size = 20 - - column_list = [ - "id", - "ai_model", - "season", - "num_of_people", - "people_category", - "genre", - ] - - # 폼(생성/수정)에서 제외 - form_excluded_columns = ["created_at"] - - column_default_sort = (SongSample.created_at, False) # False: ASC, True: DESC - - -class LyricsPromptTemplateAdmin(ModelView, model=PromptTemplate): - name = "프롬프트 템플릿" - name_plural = "프롬프트 템플릿 목록" - icon = "fa-solid fa-file-alt" - category = "프롬프트 템플릿 관리" - page_size = 20 - - column_list = [ - "id", - "description", - ] - - # 폼(생성/수정)에서 제외 - form_excluded_columns = ["created_at"] - - column_default_sort = (PromptTemplate.created_at, False) # False: ASC, True: DESC - - -class LyricsSongResultsAllAdmin(ModelView, model=SongResultsAll): - name = "가사 결과" - name_plural = "가사 결과 목록" - icon = "fa-solid fa-music" - category = "가사 결과 관리" - page_size = 20 - - column_list = [ - "id", - "store_name", - ] - - # 폼(생성/수정)에서 제외 - form_excluded_columns = ["created_at"] - - column_searchable_list = [ - SongResultsAll.ai, - SongResultsAll.ai_model, - ] - - column_default_sort = (SongResultsAll.created_at, False) # False: ASC, True: DESC - - column_sortable_list = [ - SongResultsAll.store_name, - SongResultsAll.store_category, - SongResultsAll.ai, - SongResultsAll.ai_model, - SongResultsAll.num_of_people, - SongResultsAll.genre, - SongResultsAll.created_at, - ] diff --git a/app/lyrics/models.py b/app/lyrics/models.py deleted file mode 100644 index f0aaa62..0000000 --- a/app/lyrics/models.py +++ /dev/null @@ -1,273 +0,0 @@ -from sqlalchemy import DateTime, Integer, String, Text, func -from sqlalchemy.orm import Mapped, mapped_column - -from app.database.session import Base - -# from sqlalchemy import ( -# Column, -# Integer, -# String, -# Boolean, -# DateTime, -# ForeignKey, -# Numeric, -# Table, -# Index, -# UniqueConstraint, -# CheckConstraint, -# text, -# func, -# PrimaryKeyConstraint, -# Enum, -# ) - - -class StoreDefaultInfo(Base): - __tablename__ = "store_default_info" - - id: Mapped[int] = mapped_column( - Integer, primary_key=True, nullable=False, autoincrement=True - ) - - store_info: Mapped[str] = mapped_column( - Text, - unique=False, - nullable=True, - ) - - store_name: Mapped[str] = mapped_column( - String(255), - unique=False, - nullable=False, - ) - - store_category: Mapped[str] = mapped_column( - String(255), - unique=False, - nullable=True, - ) - - store_region: Mapped[str] = mapped_column( - String(255), - unique=False, - nullable=True, - ) - - store_address: Mapped[str] = mapped_column( - String(255), - unique=False, - nullable=True, - ) - - store_phone_number: Mapped[str] = mapped_column( - String(255), - unique=True, - nullable=True, - ) - - created_at: Mapped[DateTime] = mapped_column(DateTime, server_default=func.now()) - - def __repr__(self) -> str: - return f"id={self.id}, store_name={self.store_name}" - - -class PromptTemplate(Base): - __tablename__ = "prompt_template" - - id: Mapped[int] = mapped_column( - Integer, primary_key=True, nullable=False, autoincrement=True - ) - - description: Mapped[str] = mapped_column( - Text, - unique=False, - nullable=True, - ) - - prompt: Mapped[str] = mapped_column( - Text, - unique=False, - nullable=False, - ) - - created_at: Mapped[DateTime] = mapped_column(DateTime, server_default=func.now()) - - def __repr__(self) -> str: - return f"id={self.id}, description={self.description}" - - -class Attribute(Base): - __tablename__ = "attribute" - - id: Mapped[int] = mapped_column( - Integer, primary_key=True, nullable=False, autoincrement=True - ) - - attr_category: Mapped[str] = mapped_column( - String(255), - unique=True, - nullable=False, - ) - - attr_value: Mapped[str] = mapped_column( - String(255), - unique=True, - nullable=False, - ) - - created_at: Mapped[DateTime] = mapped_column(DateTime, server_default=func.now()) - - def __repr__(self) -> str: - return f"id={self.id}, attr_category={self.attr_category}" - - -class SongSample(Base): - __tablename__ = "song_sample" - - id: Mapped[int] = mapped_column( - Integer, primary_key=True, nullable=False, autoincrement=True - ) - - ai: Mapped[str] = mapped_column( - String(255), - unique=False, - nullable=False, - ) - - ai_model: Mapped[str] = mapped_column( - String(255), - unique=False, - nullable=False, - ) - - genre: Mapped[str] = mapped_column( - String(255), - unique=False, - nullable=True, - ) - - sample_song: Mapped[str] = mapped_column( - Text, - unique=False, - nullable=False, - ) - - created_at: Mapped[DateTime] = mapped_column(DateTime, server_default=func.now()) - - def __repr__(self) -> str: - return f"id={self.id}, sample_song={self.sample_song}" - - -class SongResultsAll(Base): - __tablename__ = "song_results_all" - - id: Mapped[int] = mapped_column( - Integer, primary_key=True, nullable=False, autoincrement=True - ) - - store_info: Mapped[str] = mapped_column( - Text, - unique=False, - nullable=True, - ) - - store_name: Mapped[str] = mapped_column( - String(255), - unique=False, - nullable=False, - ) - - store_category: Mapped[str] = mapped_column( - String(255), - unique=False, - nullable=True, - ) - - store_address: Mapped[str] = mapped_column( - String(255), - unique=False, - nullable=True, - ) - - store_phone_number: Mapped[str] = mapped_column( - String(255), - unique=False, - nullable=True, - ) - - description: Mapped[str] = mapped_column( - Text, - unique=False, - nullable=True, - ) - - prompt: Mapped[str] = mapped_column( - String(255), - unique=False, - nullable=False, - ) - - attr_category: Mapped[str] = mapped_column( - String(255), - unique=False, - nullable=False, - ) - - attr_value: Mapped[str] = mapped_column( - String(255), - unique=False, - nullable=False, - ) - - ai: Mapped[str] = mapped_column( - String(255), - unique=False, - nullable=False, - ) - - ai_model: Mapped[str] = mapped_column( - String(255), - unique=False, - nullable=False, - ) - - season: Mapped[str] = mapped_column( - String(255), - unique=False, - nullable=True, - ) - - num_of_people: Mapped[int] = mapped_column( - Integer, - unique=False, - nullable=True, - ) - - people_category: Mapped[str] = mapped_column( - String(255), - unique=False, - nullable=True, - ) - - genre: Mapped[str] = mapped_column( - String(255), - unique=False, - nullable=True, - ) - - sample_song: Mapped[str] = mapped_column( - Text, - unique=False, - nullable=False, - ) - - result_song: Mapped[str] = mapped_column( - Text, - unique=False, - nullable=False, - ) - - created_at: Mapped[DateTime] = mapped_column(DateTime, server_default=func.now()) - - def __repr__(self) -> str: - return f"id={self.id}, result_song={self.result_song}" diff --git a/app/lyrics/services/lyrics_temp.py b/app/song/__init__.py similarity index 100% rename from app/lyrics/services/lyrics_temp.py rename to app/song/__init__.py diff --git a/app/song/api/__init__.py b/app/song/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/song/api/routers/__init__.py b/app/song/api/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/song/api/routers/v1/__init__.py b/app/song/api/routers/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/lyrics/api/routers/v1/router.py b/app/song/api/routers/v1/router.py similarity index 100% rename from app/lyrics/api/routers/v1/router.py rename to app/song/api/routers/v1/router.py diff --git a/app/song/api/schemas/.gitkeep b/app/song/api/schemas/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/app/song/api/song_admin.py b/app/song/api/song_admin.py new file mode 100644 index 0000000..bbc52f3 --- /dev/null +++ b/app/song/api/song_admin.py @@ -0,0 +1,62 @@ +from sqladmin import ModelView + +from app.song.models import Song + + +class SongAdmin(ModelView, model=Song): + name = "노래" + name_plural = "노래 목록" + icon = "fa-solid fa-headphones" + category = "노래 관리" + page_size = 20 + + column_list = [ + "id", + "project_id", + "lyric_id", + "task_id", + "status", + "created_at", + ] + + column_details_list = [ + "id", + "project_id", + "lyric_id", + "task_id", + "status", + "song_prompt", + "song_result_url_1", + "song_result_url_2", + "created_at", + ] + + # 폼(생성/수정)에서 제외 + form_excluded_columns = ["created_at", "videos"] + + column_searchable_list = [ + Song.task_id, + Song.status, + ] + + column_default_sort = (Song.created_at, True) # True: DESC (최신순) + + column_sortable_list = [ + Song.id, + Song.project_id, + Song.lyric_id, + Song.status, + Song.created_at, + ] + + column_labels = { + "id": "ID", + "project_id": "프로젝트 ID", + "lyric_id": "가사 ID", + "task_id": "작업 ID", + "status": "상태", + "song_prompt": "프롬프트", + "song_result_url_1": "결과 URL 1", + "song_result_url_2": "결과 URL 2", + "created_at": "생성일시", + } diff --git a/app/song/dependencies.py b/app/song/dependencies.py new file mode 100644 index 0000000..bf6f8ea --- /dev/null +++ b/app/song/dependencies.py @@ -0,0 +1,8 @@ +from typing import Annotated + +from fastapi import Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.session import get_session + +SessionDep = Annotated[AsyncSession, Depends(get_session)] diff --git a/app/song/models.py b/app/song/models.py new file mode 100644 index 0000000..e18ccb2 --- /dev/null +++ b/app/song/models.py @@ -0,0 +1,144 @@ +from datetime import datetime +from typing import TYPE_CHECKING, List, Optional + +from sqlalchemy import ( + DateTime, + ForeignKey, + Integer, + String, + Text, + func, +) +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database.session import Base + +if TYPE_CHECKING: + from app.home.models import Project + from app.lyric.models import Lyric + from app.video.models import Video + + +class Song(Base): + """ + 노래 테이블 + + AI를 통해 생성된 노래 정보를 저장합니다. + 가사를 기반으로 생성되며, 두 개의 결과 URL을 저장할 수 있습니다. + + Attributes: + id: 고유 식별자 (자동 증가) + project_id: 연결된 Project의 id (외래키) + lyric_id: 연결된 Lyric의 id (외래키) + task_id: 노래 생성 작업의 고유 식별자 (UUID 형식) + status: 처리 상태 (pending, processing, completed, failed 등) + song_prompt: 노래 생성에 사용된 프롬프트 + song_result_url_1: 첫 번째 생성 결과 URL (선택) + song_result_url_2: 두 번째 생성 결과 URL (선택) + created_at: 생성 일시 (자동 설정) + + Relationships: + project: 연결된 Project + lyric: 연결된 Lyric + videos: 이 노래를 사용한 영상 결과 목록 + """ + + __tablename__ = "song" + __table_args__ = ( + {"mysql_engine": "InnoDB", "mysql_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"}, + ) + + id: Mapped[int] = mapped_column( + Integer, + primary_key=True, + nullable=False, + autoincrement=True, + comment="고유 식별자", + ) + + project_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("project.id", ondelete="CASCADE"), + nullable=False, + index=True, + comment="연결된 Project의 id", + ) + + lyric_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("lyric.id", ondelete="CASCADE"), + nullable=False, + index=True, + comment="연결된 Lyric의 id", + ) + + task_id: Mapped[str] = mapped_column( + String(36), + nullable=False, + unique=True, + comment="노래 생성 작업 고유 식별자 (UUID)", + ) + + status: Mapped[str] = mapped_column( + String(50), + nullable=False, + comment="처리 상태 (processing, completed, failed)", + ) + + song_prompt: Mapped[str] = mapped_column( + Text, + nullable=False, + comment="노래 생성에 사용된 프롬프트", + ) + + song_result_url_1: Mapped[Optional[str]] = mapped_column( + String(2048), + nullable=True, + comment="첫 번째 노래 결과 URL", + ) + + song_result_url_2: Mapped[Optional[str]] = mapped_column( + String(2048), + nullable=True, + comment="두 번째 노래 결과 URL", + ) + + created_at: Mapped[datetime] = mapped_column( + DateTime, + nullable=False, + server_default=func.now(), + comment="생성 일시", + ) + + # Relationships + project: Mapped["Project"] = relationship( + "Project", + back_populates="songs", + ) + + lyric: Mapped["Lyric"] = relationship( + "Lyric", + back_populates="songs", + ) + + videos: Mapped[List["Video"]] = relationship( + "Video", + back_populates="song", + cascade="all, delete-orphan", + lazy="selectin", + ) + + def __repr__(self) -> str: + def truncate(value: str | None, max_len: int = 10) -> str: + if value is None: + return "None" + return (value[:max_len] + "...") if len(value) > max_len else value + + return ( + f"" + ) + diff --git a/app/song/schemas/__init__.py b/app/song/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/song/schemas/song_schema.py b/app/song/schemas/song_schema.py new file mode 100644 index 0000000..ec3a5e9 --- /dev/null +++ b/app/song/schemas/song_schema.py @@ -0,0 +1,91 @@ +from dataclasses import dataclass, field +from datetime import datetime +from typing import Dict, List + +from fastapi import Request + + +@dataclass +class StoreData: + id: int + created_at: datetime + store_name: str + store_category: str | None = None + store_region: str | None = None + store_address: str | None = None + store_phone_number: str | None = None + store_info: str | None = None + + +@dataclass +class AttributeData: + id: int + attr_category: str + attr_value: str + created_at: datetime + + +@dataclass +class SongSampleData: + id: int + ai: str + ai_model: str + sample_song: str + season: str | None = None + num_of_people: int | None = None + people_category: str | None = None + genre: str | None = None + + +@dataclass +class PromptTemplateData: + id: int + prompt: str + description: str | None = None + + +@dataclass +class SongFormData: + store_name: str + store_id: str + prompts: str + attributes: Dict[str, str] = field(default_factory=dict) + attributes_str: str = "" + lyrics_ids: List[int] = field(default_factory=list) + llm_model: str = "gpt-4o" + + @classmethod + async def from_form(cls, request: Request): + """Request의 form 데이터로부터 dataclass 인스턴스 생성""" + form_data = await request.form() + + # 고정 필드명들 + fixed_keys = {"store_info_name", "store_id", "llm_model", "prompts"} + + # lyrics-{id} 형태의 모든 키를 찾아서 ID 추출 + lyrics_ids = [] + attributes = {} + + for key, value in form_data.items(): + if key.startswith("lyrics-"): + lyrics_id = key.split("-")[1] + lyrics_ids.append(int(lyrics_id)) + elif key not in fixed_keys: + attributes[key] = value + + # attributes를 문자열로 변환 + attributes_str = ( + "\r\n\r\n".join([f"{key} : {value}" for key, value in attributes.items()]) + if attributes + else "" + ) + + return cls( + store_name=form_data.get("store_info_name", ""), + store_id=form_data.get("store_id", ""), + attributes=attributes, + attributes_str=attributes_str, + lyrics_ids=lyrics_ids, + llm_model=form_data.get("llm_model", "gpt-4o"), + prompts=form_data.get("prompts", ""), + ) diff --git a/app/song/services/__init__.py b/app/song/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/song/services/base.py b/app/song/services/base.py new file mode 100644 index 0000000..2a0b0a9 --- /dev/null +++ b/app/song/services/base.py @@ -0,0 +1,24 @@ +from uuid import UUID +from sqlalchemy.ext.asyncio import AsyncSession +from sqlmodel import SQLModel + + +class BaseService: + def __init__(self, model, session: AsyncSession): + self.model = model + self.session = session + + async def _get(self, id: UUID): + return await self.session.get(self.model, id) + + async def _add(self, entity): + self.session.add(entity) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def _update(self, entity): + return await self._add(entity) + + async def _delete(self, entity): + await self.session.delete(entity) \ No newline at end of file diff --git a/app/lyrics/services/lyrics.py b/app/song/services/song.py similarity index 100% rename from app/lyrics/services/lyrics.py rename to app/song/services/song.py diff --git a/app/song/tests/__init__.py b/app/song/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/song/tests/conftest.py b/app/song/tests/conftest.py new file mode 100644 index 0000000..e69de29 diff --git a/app/song/tests/lyrics/__init__.py b/app/song/tests/lyrics/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/song/tests/lyrics/test_module.py b/app/song/tests/lyrics/test_module.py new file mode 100644 index 0000000..e69de29 diff --git a/app/song/worker/__init__.py b/app/song/worker/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/video/__init__.py b/app/video/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/video/api/__init__.py b/app/video/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/video/api/routers/__init__.py b/app/video/api/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/video/api/routers/v1/__init__.py b/app/video/api/routers/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/video/api/routers/v1/router.py b/app/video/api/routers/v1/router.py new file mode 100644 index 0000000..71c81eb --- /dev/null +++ b/app/video/api/routers/v1/router.py @@ -0,0 +1,146 @@ +from typing import Any + +from fastapi import APIRouter, Depends, Request # , UploadFile, File, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.session import get_session +from app.lyrics.services import lyrics + +router = APIRouter(prefix="/lyrics", tags=["lyrics"]) + + +@router.get("/store") +async def home( + request: Request, + conn: AsyncSession = Depends(get_session), +): + # store_info_list: List[StoreData] = await lyrics_svc.get_store_info(conn) + result: Any = await lyrics.get_store_info(conn) + + # return templates.TemplateResponse( + # request=request, + # name="store.html", + # context={"store_info_list": result}, + # ) + pass + + +@router.post("/attributes") +async def attribute( + request: Request, + conn: AsyncSession = Depends(get_session), +): + print("attributes") + print(await request.form()) + + result: Any = await lyrics.get_attribute(conn) + print(result) + + # return templates.TemplateResponse( + # request=request, + # name="attribute.html", + # context={ + # "attribute_group_dict": result, + # "before_dict": await request.form(), + # }, + # ) + pass + + +@router.post("/fewshot") +async def sample_song( + request: Request, + conn: AsyncSession = Depends(get_session), +): + print("fewshot") + print(await request.form()) + + result: Any = await lyrics.get_sample_song(conn) + print(result) + + # return templates.TemplateResponse( + # request=request, + # name="fewshot.html", + # context={"fewshot_list": result, "before_dict": await request.form()}, + # ) + pass + + +@router.post("/prompt") +async def prompt_template( + request: Request, + conn: AsyncSession = Depends(get_session), +): + print("prompt_template") + print(await request.form()) + + result: Any = await lyrics.get_prompt_template(conn) + print(result) + + print("prompt_template after") + print(await request.form()) + + # return templates.TemplateResponse( + # request=request, + # name="prompt.html", + # context={"prompt_list": result, "before_dict": await request.form()}, + # ) + + pass + + +@router.post("/result") +async def song_result( + request: Request, + conn: AsyncSession = Depends(get_session), +): + print("song_result") + print(await request.form()) + + result: Any = await lyrics.make_song_result(request, conn) + print("result : ", result) + + # return templates.TemplateResponse( + # request=request, + # name="result.html", + # context={"result_dict": result}, + # ) + pass + + +@router.get("/result") +async def get_song_result( + request: Request, + conn: AsyncSession = Depends(get_session), +): + print("get_song_result") + print(await request.form()) + + result: Any = await lyrics.get_song_result(conn) + print("result : ", result) + + # return templates.TemplateResponse( + # request=request, + # name="result.html", + # context={"result_dict": result}, + # ) + pass + + +@router.post("/automation") +async def automation( + request: Request, + conn: AsyncSession = Depends(get_session), +): + print("automation") + print(await request.form()) + + result: Any = await lyrics.make_automation(request, conn) + print("result : ", result) + + # return templates.TemplateResponse( + # request=request, + # name="result.html", + # context={"result_dict": result}, + # ) + pass diff --git a/app/video/api/schemas/.gitkeep b/app/video/api/schemas/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/app/video/api/video_admin.py b/app/video/api/video_admin.py new file mode 100644 index 0000000..9577f8e --- /dev/null +++ b/app/video/api/video_admin.py @@ -0,0 +1,62 @@ +from sqladmin import ModelView + +from app.video.models import Video + + +class VideoAdmin(ModelView, model=Video): + name = "영상" + name_plural = "영상 목록" + icon = "fa-solid fa-video" + category = "영상 관리" + page_size = 20 + + column_list = [ + "id", + "project_id", + "lyric_id", + "song_id", + "task_id", + "status", + "created_at", + ] + + column_details_list = [ + "id", + "project_id", + "lyric_id", + "song_id", + "task_id", + "status", + "result_movie_url", + "created_at", + ] + + # 폼(생성/수정)에서 제외 + form_excluded_columns = ["created_at"] + + column_searchable_list = [ + Video.task_id, + Video.status, + ] + + column_default_sort = (Video.created_at, True) # True: DESC (최신순) + + column_sortable_list = [ + Video.id, + Video.project_id, + Video.lyric_id, + Video.song_id, + Video.status, + Video.created_at, + ] + + column_labels = { + "id": "ID", + "project_id": "프로젝트 ID", + "lyric_id": "가사 ID", + "song_id": "노래 ID", + "task_id": "작업 ID", + "status": "상태", + "result_movie_url": "영상 URL", + "created_at": "생성일시", + } diff --git a/app/video/dependencies.py b/app/video/dependencies.py new file mode 100644 index 0000000..bf6f8ea --- /dev/null +++ b/app/video/dependencies.py @@ -0,0 +1,8 @@ +from typing import Annotated + +from fastapi import Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.session import get_session + +SessionDep = Annotated[AsyncSession, Depends(get_session)] diff --git a/app/video/models.py b/app/video/models.py new file mode 100644 index 0000000..d5619c9 --- /dev/null +++ b/app/video/models.py @@ -0,0 +1,136 @@ +from datetime import datetime +from typing import TYPE_CHECKING, Optional + +from sqlalchemy import ( + DateTime, + ForeignKey, + Integer, + String, + func, +) +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database.session import Base + +if TYPE_CHECKING: + from app.home.models import Project + from app.lyric.models import Lyric + from app.song.models import Song + + +class Video(Base): + """ + 영상 결과 테이블 + + 최종 생성된 영상의 결과 URL을 저장합니다. + Creatomate 서비스를 통해 이미지와 노래를 결합한 영상 결과입니다. + + Attributes: + id: 고유 식별자 (자동 증가) + project_id: 연결된 Project의 id (외래키) + lyric_id: 연결된 Lyric의 id (외래키) + song_id: 연결된 Song의 id (외래키) + task_id: 영상 생성 작업의 고유 식별자 (UUID 형식) + status: 처리 상태 (pending, processing, completed, failed 등) + result_movie_url: 생성된 영상 URL (S3, CDN 경로) + created_at: 생성 일시 (자동 설정) + + Relationships: + project: 연결된 Project + lyric: 연결된 Lyric + song: 연결된 Song + """ + + __tablename__ = "video" + __table_args__ = ( + {"mysql_engine": "InnoDB", "mysql_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"}, + ) + + id: Mapped[int] = mapped_column( + Integer, + primary_key=True, + nullable=False, + autoincrement=True, + comment="고유 식별자", + ) + + project_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("project.id", ondelete="CASCADE"), + nullable=False, + index=True, + comment="연결된 Project의 id", + ) + + lyric_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("lyric.id", ondelete="CASCADE"), + nullable=False, + index=True, + comment="연결된 Lyric의 id", + ) + + song_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("song.id", ondelete="CASCADE"), + nullable=False, + index=True, + comment="연결된 Song의 id", + ) + + task_id: Mapped[str] = mapped_column( + String(36), + nullable=False, + unique=True, + index=True, + comment="영상 생성 작업 고유 식별자 (UUID)", + ) + + status: Mapped[str] = mapped_column( + String(50), + nullable=False, + comment="처리 상태 (processing, completed, failed)", + ) + + result_movie_url: Mapped[Optional[str]] = mapped_column( + String(2048), + nullable=True, + comment="생성된 영상 URL", + ) + + created_at: Mapped[datetime] = mapped_column( + DateTime, + nullable=False, + server_default=func.now(), + comment="생성 일시", + ) + + # Relationships + project: Mapped["Project"] = relationship( + "Project", + back_populates="videos", + ) + + lyric: Mapped["Lyric"] = relationship( + "Lyric", + back_populates="videos", + ) + + song: Mapped["Song"] = relationship( + "Song", + back_populates="videos", + ) + + def __repr__(self) -> str: + def truncate(value: str | None, max_len: int = 10) -> str: + if value is None: + return "None" + return (value[:max_len] + "...") if len(value) > max_len else value + + return ( + f"" + ) diff --git a/app/video/schemas/__init__.py b/app/video/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/video/schemas/video_schema.py b/app/video/schemas/video_schema.py new file mode 100644 index 0000000..ec3a5e9 --- /dev/null +++ b/app/video/schemas/video_schema.py @@ -0,0 +1,91 @@ +from dataclasses import dataclass, field +from datetime import datetime +from typing import Dict, List + +from fastapi import Request + + +@dataclass +class StoreData: + id: int + created_at: datetime + store_name: str + store_category: str | None = None + store_region: str | None = None + store_address: str | None = None + store_phone_number: str | None = None + store_info: str | None = None + + +@dataclass +class AttributeData: + id: int + attr_category: str + attr_value: str + created_at: datetime + + +@dataclass +class SongSampleData: + id: int + ai: str + ai_model: str + sample_song: str + season: str | None = None + num_of_people: int | None = None + people_category: str | None = None + genre: str | None = None + + +@dataclass +class PromptTemplateData: + id: int + prompt: str + description: str | None = None + + +@dataclass +class SongFormData: + store_name: str + store_id: str + prompts: str + attributes: Dict[str, str] = field(default_factory=dict) + attributes_str: str = "" + lyrics_ids: List[int] = field(default_factory=list) + llm_model: str = "gpt-4o" + + @classmethod + async def from_form(cls, request: Request): + """Request의 form 데이터로부터 dataclass 인스턴스 생성""" + form_data = await request.form() + + # 고정 필드명들 + fixed_keys = {"store_info_name", "store_id", "llm_model", "prompts"} + + # lyrics-{id} 형태의 모든 키를 찾아서 ID 추출 + lyrics_ids = [] + attributes = {} + + for key, value in form_data.items(): + if key.startswith("lyrics-"): + lyrics_id = key.split("-")[1] + lyrics_ids.append(int(lyrics_id)) + elif key not in fixed_keys: + attributes[key] = value + + # attributes를 문자열로 변환 + attributes_str = ( + "\r\n\r\n".join([f"{key} : {value}" for key, value in attributes.items()]) + if attributes + else "" + ) + + return cls( + store_name=form_data.get("store_info_name", ""), + store_id=form_data.get("store_id", ""), + attributes=attributes, + attributes_str=attributes_str, + lyrics_ids=lyrics_ids, + llm_model=form_data.get("llm_model", "gpt-4o"), + prompts=form_data.get("prompts", ""), + ) diff --git a/app/video/services/__init__.py b/app/video/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/video/services/base.py b/app/video/services/base.py new file mode 100644 index 0000000..2a0b0a9 --- /dev/null +++ b/app/video/services/base.py @@ -0,0 +1,24 @@ +from uuid import UUID +from sqlalchemy.ext.asyncio import AsyncSession +from sqlmodel import SQLModel + + +class BaseService: + def __init__(self, model, session: AsyncSession): + self.model = model + self.session = session + + async def _get(self, id: UUID): + return await self.session.get(self.model, id) + + async def _add(self, entity): + self.session.add(entity) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def _update(self, entity): + return await self._add(entity) + + async def _delete(self, entity): + await self.session.delete(entity) \ No newline at end of file diff --git a/app/video/services/video.py b/app/video/services/video.py new file mode 100644 index 0000000..550b4fc --- /dev/null +++ b/app/video/services/video.py @@ -0,0 +1,852 @@ +import random +from typing import List + +from fastapi import Request, status +from fastapi.exceptions import HTTPException +from sqlalchemy import Connection, text +from sqlalchemy.exc import SQLAlchemyError + +from app.lyrics.schemas.lyrics_schema import ( + AttributeData, + PromptTemplateData, + SongFormData, + SongSampleData, + StoreData, +) +from app.utils.chatgpt_prompt import chatgpt_api + + +async def get_store_info(conn: Connection) -> List[StoreData]: + try: + query = """SELECT * FROM store_default_info;""" + result = await conn.execute(text(query)) + + all_store_info = [ + StoreData( + id=row[0], + store_info=row[1], + store_name=row[2], + store_category=row[3], + store_region=row[4], + store_address=row[5], + store_phone_number=row[6], + created_at=row[7], + ) + for row in result + ] + + result.close() + return all_store_info + except SQLAlchemyError as e: + print(e) + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.", + ) + except Exception as e: + print(e) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="알수없는 이유로 서비스 오류가 발생하였습니다", + ) + + +async def get_attribute(conn: Connection) -> List[AttributeData]: + try: + query = """SELECT * FROM attribute;""" + result = await conn.execute(text(query)) + + all_attribute = [ + AttributeData( + id=row[0], + attr_category=row[1], + attr_value=row[2], + created_at=row[3], + ) + for row in result + ] + + result.close() + return all_attribute + except SQLAlchemyError as e: + print(e) + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.", + ) + except Exception as e: + print(e) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="알수없는 이유로 서비스 오류가 발생하였습니다", + ) + + +async def get_attribute(conn: Connection) -> List[AttributeData]: + try: + query = """SELECT * FROM attribute;""" + result = await conn.execute(text(query)) + + all_attribute = [ + AttributeData( + id=row[0], + attr_category=row[1], + attr_value=row[2], + created_at=row[3], + ) + for row in result + ] + + result.close() + return all_attribute + except SQLAlchemyError as e: + print(e) + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.", + ) + except Exception as e: + print(e) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="알수없는 이유로 서비스 오류가 발생하였습니다", + ) + + +async def get_sample_song(conn: Connection) -> List[SongSampleData]: + try: + query = """SELECT * FROM song_sample;""" + result = await conn.execute(text(query)) + + all_sample_song = [ + SongSampleData( + id=row[0], + ai=row[1], + ai_model=row[2], + genre=row[3], + sample_song=row[4], + ) + for row in result + ] + + result.close() + return all_sample_song + except SQLAlchemyError as e: + print(e) + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.", + ) + except Exception as e: + print(e) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="알수없는 이유로 서비스 오류가 발생하였습니다", + ) + + +async def get_prompt_template(conn: Connection) -> List[PromptTemplateData]: + try: + query = """SELECT * FROM prompt_template;""" + result = await conn.execute(text(query)) + + all_prompt_template = [ + PromptTemplateData( + id=row[0], + description=row[1], + prompt=row[2], + ) + for row in result + ] + + result.close() + return all_prompt_template + except SQLAlchemyError as e: + print(e) + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.", + ) + except Exception as e: + print(e) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="알수없는 이유로 서비스 오류가 발생하였습니다", + ) + + +async def get_song_result(conn: Connection) -> List[PromptTemplateData]: + try: + query = """SELECT * FROM prompt_template;""" + result = await conn.execute(text(query)) + + all_prompt_template = [ + PromptTemplateData( + id=row[0], + description=row[1], + prompt=row[2], + ) + for row in result + ] + + result.close() + return all_prompt_template + except SQLAlchemyError as e: + print(e) + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.", + ) + except Exception as e: + print(e) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="알수없는 이유로 서비스 오류가 발생하였습니다", + ) + + +async def make_song_result(request: Request, conn: Connection): + try: + # 1. Form 데이터 파싱 + form_data = await SongFormData.from_form(request) + + print(f"\n{'=' * 60}") + print(f"Store ID: {form_data.store_id}") + print(f"Lyrics IDs: {form_data.lyrics_ids}") + print(f"Prompt IDs: {form_data.prompts}") + print(f"{'=' * 60}\n") + + # 2. Store 정보 조회 + store_query = """ + SELECT * FROM store_default_info WHERE id=:id; + """ + store_result = await conn.execute(text(store_query), {"id": form_data.store_id}) + + all_store_info = [ + StoreData( + id=row[0], + store_info=row[1], + store_name=row[2], + store_category=row[3], + store_region=row[4], + store_address=row[5], + store_phone_number=row[6], + created_at=row[7], + ) + for row in store_result + ] + + if not all_store_info: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Store not found: {form_data.store_id}", + ) + + store_info = all_store_info[0] + print(f"Store: {store_info.store_name}") + + # 3. 속성 조회 -- 단계별 선택 프로세서시 구현 필요 없음 + + # 4. Sample Song 조회 및 결합 + combined_sample_song = None + + if form_data.lyrics_ids: + print(f"\n[샘플 가사 조회] - {len(form_data.lyrics_ids)}개") + + lyrics_query = """ + SELECT sample_song FROM song_sample + WHERE id IN :ids + ORDER BY created_at; + """ + lyrics_result = await conn.execute( + text(lyrics_query), {"ids": tuple(form_data.lyrics_ids)} + ) + + sample_songs = [ + row.sample_song for row in lyrics_result.fetchall() if row.sample_song + ] + + if sample_songs: + combined_sample_song = "\n\n".join( + [f"[샘플 {i + 1}]\n{song}" for i, song in enumerate(sample_songs)] + ) + print(f"{len(sample_songs)}개의 샘플 가사 조회 완료") + else: + print("샘플 가사가 비어있습니다") + else: + print("선택된 lyrics가 없습니다") + + # 5. 템플릿 가져오기 + if not form_data.prompts: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="프롬프트 ID가 필요합니다", + ) + + print("템플릿 가져오기") + + prompts_query = """ + SELECT * FROM prompt_template WHERE id=:id; + """ + + # ✅ 수정: store_query → prompts_query + prompts_result = await conn.execute( + text(prompts_query), {"id": form_data.prompts} + ) + + prompts_info = [ + PromptTemplateData( + id=row[0], + description=row[1], + prompt=row[2], + ) + for row in prompts_result + ] + + if not prompts_info: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Prompt not found: {form_data.prompts}", + ) + + prompt = prompts_info[0] + print(f"Prompt Template: {prompt.prompt}") + + # ✅ 6. 프롬프트 조합 + updated_prompt = prompt.prompt.replace("###", form_data.attributes_str).format( + name=store_info.store_name or "", + address=store_info.store_address or "", + category=store_info.store_category or "", + description=store_info.store_info or "", + ) + + updated_prompt += f""" + + 다음은 참고해야 하는 샘플 가사 정보입니다. + + 샘플 가사를 참고하여 작곡을 해주세요. + + {combined_sample_song} + """ + + print(f"\n[업데이트된 프롬프트]\n{updated_prompt}\n") + + # 7. 모델에게 요청 + generated_lyrics = await chatgpt_api.generate_lyrics(prompt=updated_prompt) + + # 글자 수 계산 + total_chars_with_space = len(generated_lyrics) + total_chars_without_space = len( + generated_lyrics.replace(" ", "") + .replace("\n", "") + .replace("\r", "") + .replace("\t", "") + ) + + # final_lyrics 생성 + final_lyrics = f"""속성 {form_data.attributes_str} + 전체 글자 수 (공백 포함): {total_chars_with_space}자 + 전체 글자 수 (공백 제외): {total_chars_without_space}자\r\n\r\n{generated_lyrics}""" + + print("=" * 40) + print("[translate:form_data.attributes_str:] ", form_data.attributes_str) + print("[translate:total_chars_with_space:] ", total_chars_with_space) + print("[translate:total_chars_without_space:] ", total_chars_without_space) + print("[translate:final_lyrics:]") + print(final_lyrics) + print("=" * 40) + + # 8. DB 저장 + insert_query = """ + INSERT INTO song_results_all ( + store_info, store_name, store_category, store_address, store_phone_number, + description, prompt, attr_category, attr_value, + ai, ai_model, genre, + sample_song, result_song, created_at + ) VALUES ( + :store_info, :store_name, :store_category, :store_address, :store_phone_number, + :description, :prompt, :attr_category, :attr_value, + :ai, :ai_model, :genre, + :sample_song, :result_song, NOW() + ); + """ + + # ✅ attr_category, attr_value 추가 + insert_params = { + "store_info": store_info.store_info or "", + "store_name": store_info.store_name, + "store_category": store_info.store_category or "", + "store_address": store_info.store_address or "", + "store_phone_number": store_info.store_phone_number or "", + "description": store_info.store_info or "", + "prompt": form_data.prompts, + "attr_category": ", ".join(form_data.attributes.keys()) + if form_data.attributes + else "", + "attr_value": ", ".join(form_data.attributes.values()) + if form_data.attributes + else "", + "ai": "ChatGPT", + "ai_model": form_data.llm_model, + "genre": "후크송", + "sample_song": combined_sample_song or "없음", + "result_song": final_lyrics, + } + + await conn.execute(text(insert_query), insert_params) + await conn.commit() + + print("결과 저장 완료") + + print("\n전체 결과 조회 중...") + + # 9. 생성 결과 가져오기 (created_at 역순) + select_query = """ + SELECT * FROM song_results_all + ORDER BY created_at DESC; + """ + + all_results = await conn.execute(text(select_query)) + + results_list = [ + { + "id": row.id, + "store_info": row.store_info, + "store_name": row.store_name, + "store_category": row.store_category, + "store_address": row.store_address, + "store_phone_number": row.store_phone_number, + "description": row.description, + "prompt": row.prompt, + "attr_category": row.attr_category, + "attr_value": row.attr_value, + "ai": row.ai, + "ai_model": row.ai_model, + "genre": row.genre, + "sample_song": row.sample_song, + "result_song": row.result_song, + "created_at": row.created_at.isoformat() if row.created_at else None, + } + for row in all_results.fetchall() + ] + + print(f"전체 {len(results_list)}개의 결과 조회 완료\n") + + return results_list + + except HTTPException: + raise + except SQLAlchemyError as e: + print(f"Database Error: {e}") + import traceback + + traceback.print_exc() + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="데이터베이스 연결에 문제가 발생했습니다.", + ) + except Exception as e: + print(f"Unexpected Error: {e}") + import traceback + + traceback.print_exc() + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="서비스 처리 중 오류가 발생했습니다.", + ) + + +async def get_song_result(conn: Connection): # 반환 타입 수정 + try: + select_query = """ + SELECT * FROM song_results_all + ORDER BY created_at DESC; + """ + + all_results = await conn.execute(text(select_query)) + + results_list = [ + { + "id": row.id, + "store_info": row.store_info, + "store_name": row.store_name, + "store_category": row.store_category, + "store_address": row.store_address, + "store_phone_number": row.store_phone_number, + "description": row.description, + "prompt": row.prompt, + "attr_category": row.attr_category, + "attr_value": row.attr_value, + "ai": row.ai, + "ai_model": row.ai_model, + "season": row.season, + "num_of_people": row.num_of_people, + "people_category": row.people_category, + "genre": row.genre, + "sample_song": row.sample_song, + "result_song": row.result_song, + "created_at": row.created_at.isoformat() if row.created_at else None, + } + for row in all_results.fetchall() + ] + + print(f"전체 {len(results_list)}개의 결과 조회 완료\n") + + return results_list + except HTTPException: # HTTPException은 그대로 raise + raise + except SQLAlchemyError as e: + print(f"Database Error: {e}") + import traceback + + traceback.print_exc() + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="데이터베이스 연결에 문제가 발생했습니다.", + ) + except Exception as e: + print(f"Unexpected Error: {e}") + import traceback + + traceback.print_exc() + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="서비스 처리 중 오류가 발생했습니다.", + ) + + +async def make_automation(request: Request, conn: Connection): + try: + # 1. Form 데이터 파싱 + form_data = await SongFormData.from_form(request) + + print(f"\n{'=' * 60}") + print(f"Store ID: {form_data.store_id}") + print(f"{'=' * 60}\n") + + # 2. Store 정보 조회 + store_query = """ + SELECT * FROM store_default_info WHERE id=:id; + """ + store_result = await conn.execute(text(store_query), {"id": form_data.store_id}) + + all_store_info = [ + StoreData( + id=row[0], + store_info=row[1], + store_name=row[2], + store_category=row[3], + store_region=row[4], + store_address=row[5], + store_phone_number=row[6], + created_at=row[7], + ) + for row in store_result + ] + + if not all_store_info: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Store not found: {form_data.store_id}", + ) + + store_info = all_store_info[0] + print(f"Store: {store_info.store_name}") + + # 3. 속성 조회 -- 단계별 선택 프로세서시 구현 필요 없음 + attribute_query = """ + SELECT * FROM attribute; + """ + + attribute_results = await conn.execute(text(attribute_query)) + + # 결과 가져오기 + attribute_rows = attribute_results.fetchall() + + formatted_attributes = "" + selected_categories = [] + selected_values = [] + + if attribute_rows: + attribute_list = [ + AttributeData( + id=row[0], + attr_category=row[1], + attr_value=row[2], + created_at=row[3], + ) + for row in attribute_rows + ] + + # ✅ 각 category에서 하나의 value만 랜덤 선택 + formatted_pairs = [] + for attr in attribute_list: + # 쉼표로 분리 및 공백 제거 + values = [v.strip() for v in attr.attr_value.split(",") if v.strip()] + + if values: + # 랜덤하게 하나만 선택 + selected_value = random.choice(values) + formatted_pairs.append(f"{attr.attr_category} : {selected_value}") + + # ✅ 선택된 category와 value 저장 + selected_categories.append(attr.attr_category) + selected_values.append(selected_value) + + # 최종 문자열 생성 + formatted_attributes = "\n".join(formatted_pairs) + + print(f"\n[포맷팅된 문자열 속성 정보]\n{formatted_attributes}\n") + else: + print("속성 데이터가 없습니다") + formatted_attributes = "" + + # 4. 템플릿 가져오기 + print("템플릿 가져오기 (ID=1)") + + prompts_query = """ + SELECT * FROM prompt_template WHERE id=1; + """ + + prompts_result = await conn.execute(text(prompts_query)) + + row = prompts_result.fetchone() + + if not row: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Prompt ID 1 not found", + ) + + prompt = PromptTemplateData( + id=row[0], + description=row[1], + prompt=row[2], + ) + + print(f"Prompt Template: {prompt.prompt}") + + # 5. 템플릿 조합 + + updated_prompt = prompt.prompt.replace("###", formatted_attributes).format( + name=store_info.store_name or "", + address=store_info.store_address or "", + category=store_info.store_category or "", + description=store_info.store_info or "", + ) + + print("\n" + "=" * 80) + print("업데이트된 프롬프트") + print("=" * 80) + print(updated_prompt) + print("=" * 80 + "\n") + + # 4. Sample Song 조회 및 결합 + combined_sample_song = None + + if form_data.lyrics_ids: + print(f"\n[샘플 가사 조회] - {len(form_data.lyrics_ids)}개") + + lyrics_query = """ + SELECT sample_song FROM song_sample + WHERE id IN :ids + ORDER BY created_at; + """ + lyrics_result = await conn.execute( + text(lyrics_query), {"ids": tuple(form_data.lyrics_ids)} + ) + + sample_songs = [ + row.sample_song for row in lyrics_result.fetchall() if row.sample_song + ] + + if sample_songs: + combined_sample_song = "\n\n".join( + [f"[샘플 {i + 1}]\n{song}" for i, song in enumerate(sample_songs)] + ) + print(f"{len(sample_songs)}개의 샘플 가사 조회 완료") + else: + print("샘플 가사가 비어있습니다") + else: + print("선택된 lyrics가 없습니다") + + # 1. song_sample 테이블의 모든 ID 조회 + print("\n[샘플 가사 랜덤 선택]") + + all_ids_query = """ + SELECT id FROM song_sample; + """ + ids_result = await conn.execute(text(all_ids_query)) + all_ids = [row.id for row in ids_result.fetchall()] + + print(f"전체 샘플 가사 개수: {len(all_ids)}개") + + # 2. 랜덤하게 3개 선택 (또는 전체 개수가 3개 미만이면 전체) + combined_sample_song = None + + if all_ids: + # 3개 또는 전체 개수 중 작은 값 선택 + sample_count = min(3, len(all_ids)) + selected_ids = random.sample(all_ids, sample_count) + + print(f"랜덤 선택된 ID: {selected_ids}") + + # 3. 선택된 ID로 샘플 가사 조회 + lyrics_query = """ + SELECT sample_song FROM song_sample + WHERE id IN :ids + ORDER BY created_at; + """ + lyrics_result = await conn.execute( + text(lyrics_query), {"ids": tuple(selected_ids)} + ) + + sample_songs = [ + row.sample_song for row in lyrics_result.fetchall() if row.sample_song + ] + + # 4. combined_sample_song 생성 + if sample_songs: + combined_sample_song = "\n\n".join( + [f"[샘플 {i + 1}]\n{song}" for i, song in enumerate(sample_songs)] + ) + print(f"{len(sample_songs)}개의 샘플 가사 조회 완료") + else: + print("샘플 가사가 비어있습니다") + else: + print("song_sample 테이블에 데이터가 없습니다") + + # 5. 프롬프트에 샘플 가사 추가 + if combined_sample_song: + updated_prompt += f""" + + 다음은 참고해야 하는 샘플 가사 정보입니다. + + 샘플 가사를 참고하여 작곡을 해주세요. + + {combined_sample_song} + """ + print("샘플 가사 정보가 프롬프트에 추가되었습니다") + else: + print("샘플 가사가 없어 기본 프롬프트만 사용합니다") + + print(f"\n[최종 프롬프트 길이: {len(updated_prompt)} 자]\n") + + # 7. 모델에게 요청 + generated_lyrics = await chatgpt_api.generate_lyrics(prompt=updated_prompt) + + # 글자 수 계산 + total_chars_with_space = len(generated_lyrics) + total_chars_without_space = len( + generated_lyrics.replace(" ", "") + .replace("\n", "") + .replace("\r", "") + .replace("\t", "") + ) + + # final_lyrics 생성 + final_lyrics = f"""속성 {formatted_attributes} + 전체 글자 수 (공백 포함): {total_chars_with_space}자 + 전체 글자 수 (공백 제외): {total_chars_without_space}자\r\n\r\n{generated_lyrics}""" + + # 8. DB 저장 + insert_query = """ + INSERT INTO song_results_all ( + store_info, store_name, store_category, store_address, store_phone_number, + description, prompt, attr_category, attr_value, + ai, ai_model, genre, + sample_song, result_song, created_at + ) VALUES ( + :store_info, :store_name, :store_category, :store_address, :store_phone_number, + :description, :prompt, :attr_category, :attr_value, + :ai, :ai_model, :genre, + :sample_song, :result_song, NOW() + ); + """ + print("\n[insert_params 선택된 속성 확인]") + print(f"Categories: {selected_categories}") + print(f"Values: {selected_values}") + print() + + # attr_category, attr_value + insert_params = { + "store_info": store_info.store_info or "", + "store_name": store_info.store_name, + "store_category": store_info.store_category or "", + "store_address": store_info.store_address or "", + "store_phone_number": store_info.store_phone_number or "", + "description": store_info.store_info or "", + "prompt": prompt.id, + # 랜덤 선택된 category와 value 사용 + "attr_category": ", ".join(selected_categories) + if selected_categories + else "", + "attr_value": ", ".join(selected_values) if selected_values else "", + "ai": "ChatGPT", + "ai_model": "gpt-4o", + "genre": "후크송", + "sample_song": combined_sample_song or "없음", + "result_song": final_lyrics, + } + + await conn.execute(text(insert_query), insert_params) + await conn.commit() + + print("결과 저장 완료") + + print("\n전체 결과 조회 중...") + + # 9. 생성 결과 가져오기 (created_at 역순) + select_query = """ + SELECT * FROM song_results_all + ORDER BY created_at DESC; + """ + + all_results = await conn.execute(text(select_query)) + + results_list = [ + { + "id": row.id, + "store_info": row.store_info, + "store_name": row.store_name, + "store_category": row.store_category, + "store_address": row.store_address, + "store_phone_number": row.store_phone_number, + "description": row.description, + "prompt": row.prompt, + "attr_category": row.attr_category, + "attr_value": row.attr_value, + "ai": row.ai, + "ai_model": row.ai_model, + "genre": row.genre, + "sample_song": row.sample_song, + "result_song": row.result_song, + "created_at": row.created_at.isoformat() if row.created_at else None, + } + for row in all_results.fetchall() + ] + + print(f"전체 {len(results_list)}개의 결과 조회 완료\n") + + return results_list + + except HTTPException: + raise + except SQLAlchemyError as e: + print(f"Database Error: {e}") + import traceback + + traceback.print_exc() + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="데이터베이스 연결에 문제가 발생했습니다.", + ) + except Exception as e: + print(f"Unexpected Error: {e}") + import traceback + + traceback.print_exc() + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="서비스 처리 중 오류가 발생했습니다.", + ) diff --git a/app/video/tests/__init__.py b/app/video/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/video/tests/conftest.py b/app/video/tests/conftest.py new file mode 100644 index 0000000..e69de29 diff --git a/app/video/tests/lyrics/__init__.py b/app/video/tests/lyrics/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/video/tests/lyrics/test_module.py b/app/video/tests/lyrics/test_module.py new file mode 100644 index 0000000..e69de29 diff --git a/app/video/worker/__init__.py b/app/video/worker/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/config.py b/config.py index 2636046..b40c4dc 100644 --- a/config.py +++ b/config.py @@ -18,6 +18,7 @@ class ProjectSettings(BaseSettings): VERSION: str = Field(default="0.1.0") DESCRIPTION: str = Field(default="FastAPI 기반 CastAD 프로젝트") ADMIN_BASE_URL: str = Field(default="/admin") + DEBUG: bool = Field(default=True) model_config = _base_config diff --git a/docs/database-schema/mysql_create_tables-dev.sql b/docs/database-schema/mysql_create_tables-dev.sql new file mode 100644 index 0000000..460ca2d --- /dev/null +++ b/docs/database-schema/mysql_create_tables-dev.sql @@ -0,0 +1,82 @@ +-- input_history 테이블 +CREATE TABLE input_history ( + id INT AUTO_INCREMENT PRIMARY KEY, + customer_name VARCHAR(255) NOT NULL, + region VARCHAR(100) NOT NULL, + task_id CHAR(36) NOT NULL, + detail_region_info TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- upload_img_url 테이블 +CREATE TABLE upload_img_url ( + id INT AUTO_INCREMENT PRIMARY KEY, + task_id CHAR(36) NOT NULL, + img_uid INT NOT NULL, + img_url VARCHAR(2048) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (task_id) REFERENCES input_history(task_id) ON DELETE CASCADE +); + +-- lyrics 테이블 +CREATE TABLE lyrics ( + id INT AUTO_INCREMENT PRIMARY KEY, + input_history_id INT NOT NULL, + task_id CHAR(36) NOT NULL, + status VARCHAR(50) NOT NULL, + lyrics_prompt TEXT NOT NULL, + lyrics_result LONGTEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (input_history_id) REFERENCES input_history(id) ON DELETE CASCADE +); + +-- song 테이블 +CREATE TABLE song ( + id INT AUTO_INCREMENT PRIMARY KEY, + input_history_id INT NOT NULL, + lyrics_id INT NOT NULL, + task_id CHAR(36) NOT NULL, + status VARCHAR(50) NOT NULL, + song_prompt TEXT NOT NULL, + song_result_url_1 VARCHAR(2048), + song_result_url_2 VARCHAR(2048), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (input_history_id) REFERENCES input_history(id) ON DELETE CASCADE, + FOREIGN KEY (lyrics_id) REFERENCES lyrics(id) ON DELETE CASCADE +); + +-- creatomate_result_url 테이블 +CREATE TABLE creatomate_result_url ( + id INT AUTO_INCREMENT PRIMARY KEY, + input_history_id INT NOT NULL, + song_id INT NOT NULL, + task_id CHAR(36) NOT NULL, + status VARCHAR(50) NOT NULL, + result_movie_url VARCHAR(2048), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (input_history_id) REFERENCES input_history(id) ON DELETE CASCADE, + FOREIGN KEY (song_id) REFERENCES song(id) ON DELETE CASCADE +); + +-- ===== 인덱스 추가 (쿼리 성능 최적화) ===== + +-- input_history +CREATE INDEX idx_input_history_task_id ON input_history(task_id); + +-- upload_img_url (task_id 인덱스 + 복합 인덱스) +CREATE INDEX idx_upload_img_url_task_id ON upload_img_url(task_id); +CREATE INDEX idx_upload_img_url_task_id_img_uid ON upload_img_url(task_id, img_uid); + +-- lyrics (input_history_id + task_id 인덱스) +CREATE INDEX idx_lyrics_input_history_id ON lyrics(input_history_id); +CREATE INDEX idx_lyrics_task_id ON lyrics(task_id); + +-- song (input_history_id + lyrics_id + task_id 인덱스) +CREATE INDEX idx_song_input_history_id ON song(input_history_id); +CREATE INDEX idx_song_lyrics_id ON song(lyrics_id); +CREATE INDEX idx_song_task_id ON song(task_id); + +-- creatomate_result_url (input_history_id + song_id + task_id 인덱스) +CREATE INDEX idx_creatomate_input_history_id ON creatomate_result_url(input_history_id); +CREATE INDEX idx_creatomate_song_id ON creatomate_result_url(song_id); +CREATE INDEX idx_creatomate_task_id ON creatomate_result_url(task_id); \ No newline at end of file diff --git a/docs/database-schema/mysql_create_tables.sql b/docs/database-schema/mysql_create_tables.sql new file mode 100644 index 0000000..564f3bf --- /dev/null +++ b/docs/database-schema/mysql_create_tables.sql @@ -0,0 +1,83 @@ +-- input_history 테이블 +CREATE TABLE input_history ( + id INT AUTO_INCREMENT PRIMARY KEY, + customer_name VARCHAR(255) NOT NULL, + region VARCHAR(100) NOT NULL, + task_id CHAR(36) NOT NULL UNIQUE, -- 유니크 UUID + detail_region_info TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- upload_img_url 테이블 +CREATE TABLE upload_img_url ( + id INT AUTO_INCREMENT PRIMARY KEY, + task_id CHAR(36) NOT NULL, -- input_history와 연결 + img_uid INT NOT NULL, + img_url VARCHAR(2048) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY unique_img_task_image (task_id, img_uid), + FOREIGN KEY (task_id) REFERENCES input_history(task_id) ON DELETE CASCADE +); + +-- lyrics 테이블 +CREATE TABLE lyrics ( + id INT AUTO_INCREMENT PRIMARY KEY, + input_history_id INT NOT NULL, + task_id CHAR(36) NOT NULL UNIQUE, + status VARCHAR(50) NOT NULL, + lyrics_prompt TEXT NOT NULL, + lyrics_result LONGTEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (input_history_id) REFERENCES input_history(id) ON DELETE CASCADE +); + +-- song 테이블 +CREATE TABLE song ( + id INT AUTO_INCREMENT PRIMARY KEY, + input_history_id INT NOT NULL, + lyrics_id INT NOT NULL, + task_id CHAR(36) NOT NULL UNIQUE, + status VARCHAR(50) NOT NULL, + song_prompt TEXT NOT NULL, + song_result_url_1 VARCHAR(2048), + song_result_url_2 VARCHAR(2048), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (input_history_id) REFERENCES input_history(id) ON DELETE CASCADE, + FOREIGN KEY (lyrics_id) REFERENCES lyrics(id) ON DELETE CASCADE +); + +-- creatomate_result_url 테이블 +CREATE TABLE creatomate_result_url ( + id INT AUTO_INCREMENT PRIMARY KEY, + input_history_id INT NOT NULL, + song_id INT NOT NULL, + task_id CHAR(36) NOT NULL UNIQUE, + status VARCHAR(50) NOT NULL, + result_movie_url VARCHAR(2048), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (input_history_id) REFERENCES input_history(id) ON DELETE CASCADE, + FOREIGN KEY (song_id) REFERENCES song(id) ON DELETE CASCADE +); + +-- ===== 인덱스 추가 (쿼리 성능 최적화) ===== + +-- input_history +CREATE INDEX idx_input_history_task_id ON input_history(task_id); + +-- upload_img_url (task_id 인덱스 + 복합 인덱스) +CREATE INDEX idx_upload_img_url_task_id ON upload_img_url(task_id); +CREATE INDEX idx_upload_img_url_task_id_img_uid ON upload_img_url(task_id, img_uid); + +-- lyrics (input_history_id + task_id 인덱스) +CREATE INDEX idx_lyrics_input_history_id ON lyrics(input_history_id); +CREATE INDEX idx_lyrics_task_id ON lyrics(task_id); + +-- song (input_history_id + lyrics_id + task_id 인덱스) +CREATE INDEX idx_song_input_history_id ON song(input_history_id); +CREATE INDEX idx_song_lyrics_id ON song(lyrics_id); +CREATE INDEX idx_song_task_id ON song(task_id); + +-- creatomate_result_url (input_history_id + song_id + task_id 인덱스) +CREATE INDEX idx_creatomate_input_history_id ON creatomate_result_url(input_history_id); +CREATE INDEX idx_creatomate_song_id ON creatomate_result_url(song_id); +CREATE INDEX idx_creatomate_task_id ON creatomate_result_url(task_id); \ No newline at end of file diff --git a/app/lyrics/models-refer.py b/docs/sample/models.py similarity index 100% rename from app/lyrics/models-refer.py rename to docs/sample/models.py diff --git a/main.py b/main.py index fd39013..4d1a1bb 100644 --- a/main.py +++ b/main.py @@ -7,7 +7,7 @@ from app.admin_manager import init_admin from app.core.common import lifespan from app.database.session import engine from app.home.api.routers.v1.home import router as home_router -from app.lyrics.api.routers.v1.router import router as lyrics_router +from app.lyric.api.routers.v1.router import router as lyrics_router from app.utils.cors import CustomCORSMiddleware from config import prj_settings diff --git a/o2o_castad_backend.egg-info/PKG-INFO b/o2o_castad_backend.egg-info/PKG-INFO new file mode 100644 index 0000000..3a7dbfb --- /dev/null +++ b/o2o_castad_backend.egg-info/PKG-INFO @@ -0,0 +1,17 @@ +Metadata-Version: 2.4 +Name: o2o-castad-backend +Version: 0.1.0 +Summary: Add your description here +Requires-Python: >=3.13 +Description-Content-Type: text/markdown +Requires-Dist: aiomysql>=0.3.2 +Requires-Dist: asyncmy>=0.2.10 +Requires-Dist: fastapi-cli>=0.0.16 +Requires-Dist: fastapi[standard]>=0.125.0 +Requires-Dist: openai>=2.13.0 +Requires-Dist: pydantic-settings>=2.12.0 +Requires-Dist: redis>=7.1.0 +Requires-Dist: ruff>=0.14.9 +Requires-Dist: scalar-fastapi>=1.5.0 +Requires-Dist: sqladmin[full]>=0.22.0 +Requires-Dist: sqlalchemy[asyncio]>=2.0.45 diff --git a/o2o_castad_backend.egg-info/SOURCES.txt b/o2o_castad_backend.egg-info/SOURCES.txt new file mode 100644 index 0000000..355cb02 --- /dev/null +++ b/o2o_castad_backend.egg-info/SOURCES.txt @@ -0,0 +1,99 @@ +README.md +pyproject.toml +app/__init__.py +app/admin_manager.py +app/core/__init__.py +app/core/common.py +app/core/exceptions.py +app/core/logging.py +app/database/__init__.py +app/database/redis.py +app/database/session-prod.py +app/database/session.py +app/dependencies/__init__.py +app/dependencies/auth.py +app/dependencies/common.py +app/dependencies/database.py +app/dependencies/pagination.py +app/dependencies/permissions.py +app/dependencies/rate_limit.py +app/home/__init__.py +app/home/dependency.py +app/home/models.py +app/home/api/__init__.py +app/home/api/home_admin.py +app/home/api/routers/__init__.py +app/home/api/routers/v1/__init__.py +app/home/api/routers/v1/home.py +app/home/schemas/__init__.py +app/home/services/__init__.py +app/home/services/base.py +app/home/tests/__init__.py +app/home/tests/conftest.py +app/home/tests/test_db.py +app/home/tests/home/__init__.py +app/home/tests/home/conftest.py +app/home/tests/home/test_db.py +app/home/worker/__init__.py +app/lyric/__init__.py +app/lyric/dependencies.py +app/lyric/models.py +app/lyric/api/__init__.py +app/lyric/api/lyrics_admin.py +app/lyric/api/routers/__init__.py +app/lyric/api/routers/v1/__init__.py +app/lyric/api/routers/v1/router.py +app/lyric/schemas/__init__.py +app/lyric/schemas/lyrics_schema.py +app/lyric/services/__init__.py +app/lyric/services/base.py +app/lyric/services/lyrics.py +app/lyric/tests/__init__.py +app/lyric/tests/conftest.py +app/lyric/tests/lyrics/__init__.py +app/lyric/tests/lyrics/test_module.py +app/lyric/worker/__init__.py +app/song/__init__.py +app/song/dependencies.py +app/song/models.py +app/song/api/__init__.py +app/song/api/song_admin.py +app/song/api/routers/__init__.py +app/song/api/routers/v1/__init__.py +app/song/api/routers/v1/router.py +app/song/schemas/__init__.py +app/song/schemas/song_schema.py +app/song/services/__init__.py +app/song/services/base.py +app/song/services/song.py +app/song/tests/__init__.py +app/song/tests/conftest.py +app/song/tests/lyrics/__init__.py +app/song/tests/lyrics/test_module.py +app/song/worker/__init__.py +app/utils/__init__.py +app/utils/chatgpt_prompt.py +app/utils/cors.py +app/video/__init__.py +app/video/dependencies.py +app/video/models.py +app/video/api/__init__.py +app/video/api/video_admin.py +app/video/api/routers/__init__.py +app/video/api/routers/v1/__init__.py +app/video/api/routers/v1/router.py +app/video/schemas/__init__.py +app/video/schemas/video_schema.py +app/video/services/__init__.py +app/video/services/base.py +app/video/services/video.py +app/video/tests/__init__.py +app/video/tests/conftest.py +app/video/tests/lyrics/__init__.py +app/video/tests/lyrics/test_module.py +app/video/worker/__init__.py +o2o_castad_backend.egg-info/PKG-INFO +o2o_castad_backend.egg-info/SOURCES.txt +o2o_castad_backend.egg-info/dependency_links.txt +o2o_castad_backend.egg-info/requires.txt +o2o_castad_backend.egg-info/top_level.txt \ No newline at end of file diff --git a/o2o_castad_backend.egg-info/dependency_links.txt b/o2o_castad_backend.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/o2o_castad_backend.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/o2o_castad_backend.egg-info/requires.txt b/o2o_castad_backend.egg-info/requires.txt new file mode 100644 index 0000000..659cbd7 --- /dev/null +++ b/o2o_castad_backend.egg-info/requires.txt @@ -0,0 +1,11 @@ +aiomysql>=0.3.2 +asyncmy>=0.2.10 +fastapi-cli>=0.0.16 +fastapi[standard]>=0.125.0 +openai>=2.13.0 +pydantic-settings>=2.12.0 +redis>=7.1.0 +ruff>=0.14.9 +scalar-fastapi>=1.5.0 +sqladmin[full]>=0.22.0 +sqlalchemy[asyncio]>=2.0.45 diff --git a/o2o_castad_backend.egg-info/top_level.txt b/o2o_castad_backend.egg-info/top_level.txt new file mode 100644 index 0000000..b80f0bd --- /dev/null +++ b/o2o_castad_backend.egg-info/top_level.txt @@ -0,0 +1 @@ +app