From 229e6ac7855daad2ba0e1ec25bb2fae1e57d8021 Mon Sep 17 00:00:00 2001 From: hann Date: Wed, 29 Oct 2025 15:08:57 +0900 Subject: [PATCH 01/16] feat : get specific voice API --- app/db_service.py | 15 +++++++++++++++ app/dto.py | 7 +++++++ app/main.py | 19 ++++++++++++++++++- app/voice_service.py | 31 +++++++++++++++++++++++++++++++ 4 files changed, 71 insertions(+), 1 deletion(-) diff --git a/app/db_service.py b/app/db_service.py index 687d1c6..6ea9780 100644 --- a/app/db_service.py +++ b/app/db_service.py @@ -58,6 +58,21 @@ def create_voice(self, voice_key: str, voice_name: str, duration_ms: int, def get_voice_by_id(self, voice_id: int) -> Optional[Voice]: """ID로 음성 파일 조회""" return self.db.query(Voice).filter(Voice.voice_id == voice_id).first() + + def get_voice_detail_for_user(self, voice_id: int, user_code: str) -> Optional[Voice]: + """유저 코드로 소유권을 검증하며 상세를 로드(joinedload)""" + from sqlalchemy.orm import joinedload + return ( + self.db.query(Voice) + .join(User, Voice.user_id == User.user_id) + .filter(Voice.voice_id == voice_id, User.user_code == user_code) + .options( + joinedload(Voice.questions), + joinedload(Voice.voice_content), + joinedload(Voice.voice_analyze), + ) + .first() + ) def get_voice_by_key(self, voice_key: str) -> Optional[Voice]: """S3 키로 음성 파일 조회""" diff --git a/app/dto.py b/app/dto.py index a048ab4..ab42142 100644 --- a/app/dto.py +++ b/app/dto.py @@ -74,6 +74,13 @@ class UserVoiceListResponse(BaseModel): voices: list[VoiceListItem] +class UserVoiceDetailResponse(BaseModel): + title: Optional[str] = None + top_emotion: Optional[str] = None + created_at: str + voice_content: Optional[str] = None + + class VoiceDetailResponse(BaseModel): voice_id: str filename: str diff --git a/app/main.py b/app/main.py index 961e9b0..b04845d 100644 --- a/app/main.py +++ b/app/main.py @@ -17,7 +17,7 @@ SigninRequest, SigninResponse, UserVoiceUploadRequest, UserVoiceUploadResponse, VoiceQuestionUploadResponse, - UserVoiceListResponse, + UserVoiceListResponse, UserVoiceDetailResponse, EmotionAnalysisResponse, TranscribeResponse, SentimentResponse, EntitiesResponse, SyntaxResponse, ComprehensiveAnalysisResponse ) @@ -202,6 +202,23 @@ async def get_user_voice_list(username: str): ) +# GET : 사용자 음성 상세 조회 +@app.get("/users/voices/{voice_id}", response_model=UserVoiceDetailResponse) +async def get_user_voice_detail(voice_id: int, user_code: str): + """voice_id와 user_code로 상세 조회""" + db = next(get_db()) + voice_service = get_voice_service(db) + result = voice_service.get_user_voice_detail(voice_id, user_code) + if not result.get("success"): + raise HTTPException(status_code=404, detail=result.get("error", "Not Found")) + return UserVoiceDetailResponse( + title=result.get("title"), + top_emotion=result.get("top_emotion"), + created_at=result.get("created_at", ""), + voice_content=result.get("voice_content"), + ) + + # POST : 질문과 함께 음성 업로드 @app.post("/users/voices", response_model=VoiceQuestionUploadResponse) async def upload_voice_with_question( diff --git a/app/voice_service.py b/app/voice_service.py index 7fca548..940d1d5 100644 --- a/app/voice_service.py +++ b/app/voice_service.py @@ -201,6 +201,37 @@ def get_user_voice_list(self, username: str) -> Dict[str, Any]: "success": False, "voices": [] } + + def get_user_voice_detail(self, voice_id: int, user_code: str) -> Dict[str, Any]: + """voice_id와 user_code로 상세 정보 조회""" + try: + voice = self.db_service.get_voice_detail_for_user(voice_id, user_code) + if not voice: + return {"success": False, "error": "Voice not found or not owned by user"} + + title = None + if voice.questions: + title = voice.questions[0].content + + top_emotion = None + if voice.voice_analyze: + top_emotion = voice.voice_analyze.top_emotion + + created_at = voice.created_at.isoformat() if voice.created_at else "" + + voice_content = None + if voice.voice_content: + voice_content = voice.voice_content.content + + return { + "success": True, + "title": title, + "top_emotion": top_emotion, + "created_at": created_at, + "voice_content": voice_content, + } + except Exception: + return {"success": False, "error": "Failed to fetch voice detail"} async def upload_voice_with_question(self, file: UploadFile, username: str, question_id: int) -> Dict[str, Any]: """ From 7d91d412a89f8389fb393157cc487b186589ab4b Mon Sep 17 00:00:00 2001 From: hann Date: Wed, 29 Oct 2025 15:16:48 +0900 Subject: [PATCH 02/16] refactor : add voice_id field when get voice list --- app/dto.py | 1 + app/voice_service.py | 1 + 2 files changed, 2 insertions(+) diff --git a/app/dto.py b/app/dto.py index ab42142..2a7c7cf 100644 --- a/app/dto.py +++ b/app/dto.py @@ -63,6 +63,7 @@ class VoiceQuestionUploadResponse(BaseModel): class VoiceListItem(BaseModel): + voice_id: int created_at: str emotion: Optional[str] = None question_title: Optional[str] = None diff --git a/app/voice_service.py b/app/voice_service.py index 940d1d5..3f462c7 100644 --- a/app/voice_service.py +++ b/app/voice_service.py @@ -185,6 +185,7 @@ def get_user_voice_list(self, username: str) -> Dict[str, Any]: content = voice.voice_content.content voice_list.append({ + "voice_id": voice.voice_id, "created_at": created_at, "emotion": emotion, "question_title": question_title, From 054a25a3c2c670b1d6a6655216b15694742a71c5 Mon Sep 17 00:00:00 2001 From: hann Date: Wed, 29 Oct 2025 15:26:25 +0900 Subject: [PATCH 03/16] refactor : edit requestParam of username --- app/db_service.py | 6 +++--- app/main.py | 13 ++++++++----- app/voice_service.py | 6 +++--- 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/app/db_service.py b/app/db_service.py index 6ea9780..11cd249 100644 --- a/app/db_service.py +++ b/app/db_service.py @@ -59,13 +59,13 @@ def get_voice_by_id(self, voice_id: int) -> Optional[Voice]: """ID로 음성 파일 조회""" return self.db.query(Voice).filter(Voice.voice_id == voice_id).first() - def get_voice_detail_for_user(self, voice_id: int, user_code: str) -> Optional[Voice]: - """유저 코드로 소유권을 검증하며 상세를 로드(joinedload)""" + def get_voice_detail_for_username(self, voice_id: int, username: str) -> Optional[Voice]: + """username으로 소유권을 검증하며 상세를 로드(joinedload)""" from sqlalchemy.orm import joinedload return ( self.db.query(Voice) .join(User, Voice.user_id == User.user_id) - .filter(Voice.voice_id == voice_id, User.user_code == user_code) + .filter(Voice.voice_id == voice_id, User.username == username) .options( joinedload(Voice.questions), joinedload(Voice.voice_content), diff --git a/app/main.py b/app/main.py index b04845d..a13955f 100644 --- a/app/main.py +++ b/app/main.py @@ -204,11 +204,11 @@ async def get_user_voice_list(username: str): # GET : 사용자 음성 상세 조회 @app.get("/users/voices/{voice_id}", response_model=UserVoiceDetailResponse) -async def get_user_voice_detail(voice_id: int, user_code: str): - """voice_id와 user_code로 상세 조회""" +async def get_user_voice_detail(voice_id: int, username: str): + """voice_id와 username으로 상세 조회""" db = next(get_db()) voice_service = get_voice_service(db) - result = voice_service.get_user_voice_detail(voice_id, user_code) + result = voice_service.get_user_voice_detail(voice_id, username) if not result.get("success"): raise HTTPException(status_code=404, detail=result.get("error", "Not Found")) return UserVoiceDetailResponse( @@ -223,13 +223,16 @@ async def get_user_voice_detail(voice_id: int, user_code: str): @app.post("/users/voices", response_model=VoiceQuestionUploadResponse) async def upload_voice_with_question( file: UploadFile = File(...), - username: str = Form(...), - question_id: int = Form(...) + question_id: int = Form(...), + username: str = None, ): """질문과 함께 음성 파일 업로드 (S3 + DB 저장 + STT + voice_question 매핑)""" db = next(get_db()) voice_service = get_voice_service(db) + if not username: + raise HTTPException(status_code=400, detail="username is required as query parameter") + result = await voice_service.upload_voice_with_question(file, username, question_id) if result["success"]: diff --git a/app/voice_service.py b/app/voice_service.py index 3f462c7..9ccec3e 100644 --- a/app/voice_service.py +++ b/app/voice_service.py @@ -203,10 +203,10 @@ def get_user_voice_list(self, username: str) -> Dict[str, Any]: "voices": [] } - def get_user_voice_detail(self, voice_id: int, user_code: str) -> Dict[str, Any]: - """voice_id와 user_code로 상세 정보 조회""" + def get_user_voice_detail(self, voice_id: int, username: str) -> Dict[str, Any]: + """voice_id와 username으로 상세 정보 조회""" try: - voice = self.db_service.get_voice_detail_for_user(voice_id, user_code) + voice = self.db_service.get_voice_detail_for_username(voice_id, username) if not voice: return {"success": False, "error": "Voice not found or not owned by user"} From da8c05d70cab2849240d35964a9af6a86a0e28af Mon Sep 17 00:00:00 2001 From: hann Date: Wed, 29 Oct 2025 15:34:38 +0900 Subject: [PATCH 04/16] feat : remove voice file API --- app/db_service.py | 23 +++++++++++++++++++++++ app/dto.py | 1 + app/main.py | 12 ++++++++++++ app/voice_service.py | 14 ++++++++++++++ 4 files changed, 50 insertions(+) diff --git a/app/db_service.py b/app/db_service.py index 11cd249..a5436ee 100644 --- a/app/db_service.py +++ b/app/db_service.py @@ -244,6 +244,29 @@ def unlink_voice_question(self, voice_id: int, question_id: int) -> bool: return True return False + # 삭제 관련 + def get_voice_owned_by_username(self, voice_id: int, username: str) -> Optional[Voice]: + """username 소유의 voice 조회""" + return ( + self.db.query(Voice) + .join(User, Voice.user_id == User.user_id) + .filter(Voice.voice_id == voice_id, User.username == username) + .first() + ) + + def delete_voice_with_relations(self, voice_id: int) -> bool: + """연관 데이터(voice_question, voice_content, voice_analyze) 삭제 후 voice 삭제""" + # voice_question + self.db.query(VoiceQuestion).filter(VoiceQuestion.voice_id == voice_id).delete(synchronize_session=False) + # voice_content + self.db.query(VoiceContent).filter(VoiceContent.voice_id == voice_id).delete(synchronize_session=False) + # voice_analyze + self.db.query(VoiceAnalyze).filter(VoiceAnalyze.voice_id == voice_id).delete(synchronize_session=False) + # voice + deleted = self.db.query(Voice).filter(Voice.voice_id == voice_id).delete(synchronize_session=False) + self.db.commit() + return deleted > 0 + def get_db_service(db: Session) -> DatabaseService: """데이터베이스 서비스 인스턴스 생성""" diff --git a/app/dto.py b/app/dto.py index 2a7c7cf..cb1e353 100644 --- a/app/dto.py +++ b/app/dto.py @@ -76,6 +76,7 @@ class UserVoiceListResponse(BaseModel): class UserVoiceDetailResponse(BaseModel): + voice_id: int title: Optional[str] = None top_emotion: Optional[str] = None created_at: str diff --git a/app/main.py b/app/main.py index a13955f..751f1b3 100644 --- a/app/main.py +++ b/app/main.py @@ -212,6 +212,7 @@ async def get_user_voice_detail(voice_id: int, username: str): if not result.get("success"): raise HTTPException(status_code=404, detail=result.get("error", "Not Found")) return UserVoiceDetailResponse( + voice_id=voice_id, title=result.get("title"), top_emotion=result.get("top_emotion"), created_at=result.get("created_at", ""), @@ -219,6 +220,17 @@ async def get_user_voice_detail(voice_id: int, username: str): ) +# DELETE : 유저 특정 음성 삭제 +@app.delete("/users/voices/{voice_id}") +async def delete_user_voice(voice_id: int, username: str): + db = next(get_db()) + voice_service = get_voice_service(db) + result = voice_service.delete_user_voice(voice_id, username) + if result.get("success"): + return {"success": True} + raise HTTPException(status_code=400, detail=result.get("message", "Delete failed")) + + # POST : 질문과 함께 음성 업로드 @app.post("/users/voices", response_model=VoiceQuestionUploadResponse) async def upload_voice_with_question( diff --git a/app/voice_service.py b/app/voice_service.py index 9ccec3e..0a464b5 100644 --- a/app/voice_service.py +++ b/app/voice_service.py @@ -233,6 +233,20 @@ def get_user_voice_detail(self, voice_id: int, username: str) -> Dict[str, Any]: } except Exception: return {"success": False, "error": "Failed to fetch voice detail"} + + def delete_user_voice(self, voice_id: int, username: str) -> Dict[str, Any]: + """사용자 소유 검증 후 음성 및 연관 데이터 삭제""" + try: + voice = self.db_service.get_voice_owned_by_username(voice_id, username) + if not voice: + return {"success": False, "message": "Voice not found or not owned by user"} + + ok = self.db_service.delete_voice_with_relations(voice_id) + if not ok: + return {"success": False, "message": "Delete failed"} + return {"success": True, "message": "Deleted"} + except Exception as e: + return {"success": False, "message": f"Delete error: {str(e)}"} async def upload_voice_with_question(self, file: UploadFile, username: str, question_id: int) -> Dict[str, Any]: """ From b5801d6affe04038ebf6c90fbf99a490d535ec1e Mon Sep 17 00:00:00 2001 From: hann Date: Thu, 30 Oct 2025 14:37:45 +0900 Subject: [PATCH 05/16] feat : add emotino analyzing method in upload file --- README.md | 9 +++++ app/main.py | 94 +++++++++++++++++++++++++------------------- app/stt_service.py | 30 ++++++++++++-- app/voice_service.py | 76 ++++++++++++++++++++++++++++++++++- 4 files changed, 163 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index 7c68516..719dbec 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,15 @@ uvicorn app.main:app --reload --port 8000 API 문서: `http://127.0.0.1:8000/docs` +### 비동기 처리 흐름(업로드 이후) + +업로드 API(`/users/voices`) 호출 후 서버는 다음을 비동기로 수행합니다. + +- STT → 텍스트 감정 분석 → `voice_content` 저장 +- 음성 감정 분석(오디오 자체) → `voice_analyze` 저장 + +응답은 업로드 및 메타/매핑 저장 후 즉시 반환되며, 분석 결과는 나중에 조회 API에서 확인할 수 있습니다. + ### Docker를 사용한 실행 #### 1. 환경 변수 설정 diff --git a/app/main.py b/app/main.py index 751f1b3..875083b 100644 --- a/app/main.py +++ b/app/main.py @@ -238,7 +238,14 @@ async def upload_voice_with_question( question_id: int = Form(...), username: str = None, ): - """질문과 함께 음성 파일 업로드 (S3 + DB 저장 + STT + voice_question 매핑)""" + """ + 질문과 함께 음성 파일 업로드 + + 처리 흐름(비동기 후처리 포함): + - 즉시: S3 업로드, voice 생성, voice_question 매핑 저장 후 성공 응답 반환 + - 백그라운드1: STT→텍스트 감정분석 실행 후 voice_content insert/update + - 백그라운드2: 음성 감정분석 실행 후 voice_analyze insert + """ db = next(get_db()) voice_service = get_voice_service(db) @@ -258,52 +265,59 @@ async def upload_voice_with_question( raise HTTPException(status_code=400, detail=result["message"]) -# POST : upload voice with STT -@app.post("/voices/upload") -async def upload_voice( - file: UploadFile = File(...), - folder: Optional[str] = Form(default=None), - language_code: str = Form(default="ko-KR") -): - """음성 파일을 업로드하고 STT를 수행합니다.""" - bucket = os.getenv("S3_BUCKET_NAME") - if not bucket: - raise HTTPException(status_code=500, detail="S3_BUCKET_NAME not configured") - - # 파일 내용을 메모리에 읽기 (두 번 사용하기 위해) - file_content = await file.read() +# # POST : upload voice with STT +# @app.post("/voices/upload") +# async def upload_voice( +# file: UploadFile = File(...), +# folder: Optional[str] = Form(default=None), +# language_code: str = Form(default="ko-KR") +# ): +# """ +# 음성 파일 업로드 및 STT 수행 + +# 참고: `/users/voices` 경로는 업로드와 동시에 비동기로 +# - STT→텍스트 감정분석(`voice_content` 저장) +# - 음성 감정분석(`voice_analyze` 저장) +# 를 실행합니다. +# """ +# bucket = os.getenv("S3_BUCKET_NAME") +# if not bucket: +# raise HTTPException(status_code=500, detail="S3_BUCKET_NAME not configured") + +# # 파일 내용을 메모리에 읽기 (두 번 사용하기 위해) +# file_content = await file.read() - # S3 업로드 - base_prefix = VOICE_BASE_PREFIX.rstrip("/") - effective_prefix = f"{base_prefix}/{folder or DEFAULT_UPLOAD_FOLDER}".rstrip("/") - key = f"{effective_prefix}/{file.filename}" +# # S3 업로드 +# base_prefix = VOICE_BASE_PREFIX.rstrip("/") +# effective_prefix = f"{base_prefix}/{folder or DEFAULT_UPLOAD_FOLDER}".rstrip("/") +# key = f"{effective_prefix}/{file.filename}" - from io import BytesIO - file_obj_for_s3 = BytesIO(file_content) - upload_fileobj(bucket=bucket, key=key, fileobj=file_obj_for_s3) +# from io import BytesIO +# file_obj_for_s3 = BytesIO(file_content) +# upload_fileobj(bucket=bucket, key=key, fileobj=file_obj_for_s3) - # STT 변환 - 파일 내용을 직접 사용 - from io import BytesIO - temp_file_obj = BytesIO(file_content) +# # STT 변환 - 파일 내용을 직접 사용 +# from io import BytesIO +# temp_file_obj = BytesIO(file_content) - # UploadFile과 유사한 객체 생성 - class TempUploadFile: - def __init__(self, content, filename): - self.file = content - self.filename = filename - self.content_type = "audio/wav" +# # UploadFile과 유사한 객체 생성 +# class TempUploadFile: +# def __init__(self, content, filename): +# self.file = content +# self.filename = filename +# self.content_type = "audio/wav" - temp_upload_file = TempUploadFile(temp_file_obj, file.filename) - stt_result = transcribe_voice(temp_upload_file, language_code) +# temp_upload_file = TempUploadFile(temp_file_obj, file.filename) +# stt_result = transcribe_voice(temp_upload_file, language_code) - # 파일 목록 조회 - names = list_bucket_objects(bucket=bucket, prefix=effective_prefix) +# # 파일 목록 조회 +# names = list_bucket_objects(bucket=bucket, prefix=effective_prefix) - return { - "uploaded": key, - "files": names, - "transcription": stt_result - } +# return { +# "uploaded": key, +# "files": names, +# "transcription": stt_result +# } # GET : query my voice histories diff --git a/app/stt_service.py b/app/stt_service.py index ef2a394..c3aa2fa 100644 --- a/app/stt_service.py +++ b/app/stt_service.py @@ -6,6 +6,7 @@ from google.oauth2 import service_account import librosa import numpy as np +import soundfile as sf class GoogleSTTService: @@ -53,15 +54,36 @@ def transcribe_audio(self, audio_file, language_code: str = "ko-KR") -> Dict[str } try: - # 임시 파일로 저장 - with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp_file: + # 업로드 확장자에 맞춰 임시 파일로 저장 (기본: .wav) + orig_name = getattr(audio_file, "filename", "") or "" + _, ext = os.path.splitext(orig_name) + suffix = ext if ext.lower() in [".wav", ".m4a", ".mp3", ".flac", ".ogg", ".aac", ".caf"] else ".wav" + + with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp_file: content = audio_file.file.read() audio_file.file.seek(0) tmp_file.write(content) tmp_file_path = tmp_file.name - # 오디오 파일 로드 및 전처리 - audio_data, sample_rate = librosa.load(tmp_file_path, sr=16000) + # 오디오 파일 로드 및 전처리 (견고한 로더) + def robust_load(path: str, target_sr: int = 16000): + """soundfile 우선, 실패 시 librosa로 폴백. 모노, 정규화 반환.""" + try: + data, sr = sf.read(path, always_2d=True, dtype="float32") # (N, C) + if data.ndim == 2 and data.shape[1] > 1: + data = data.mean(axis=1) # mono + else: + data = data.reshape(-1) + if sr != target_sr: + data = librosa.resample(data, orig_sr=sr, target_sr=target_sr) + sr = target_sr + return data, sr + except Exception: + # 폴백: librosa가 내부적으로 audioread/ffmpeg 사용 + y, sr = librosa.load(path, sr=target_sr, mono=True) + return y.astype("float32"), sr + + audio_data, sample_rate = robust_load(tmp_file_path, 16000) # 오디오 데이터를 bytes로 변환 audio_data = np.clip(audio_data, -1.0, 1.0) diff --git a/app/voice_service.py b/app/voice_service.py index 0a464b5..554c8c3 100644 --- a/app/voice_service.py +++ b/app/voice_service.py @@ -7,6 +7,7 @@ from .s3_service import upload_fileobj from .stt_service import transcribe_voice from .nlp_service import analyze_text_sentiment +from .emotion_service import analyze_voice_emotion from .constants import VOICE_BASE_PREFIX, DEFAULT_UPLOAD_FOLDER from .db_service import get_db_service from .auth_service import get_auth_service @@ -77,8 +78,9 @@ async def upload_user_voice(self, file: UploadFile, username: str) -> Dict[str, sample_rate=16000 # 기본값 ) - # 5. STT → NLP 순차 처리 (백그라운드 비동기) + # 5. 비동기 후처리 (STT→NLP, 음성 감정 분석) asyncio.create_task(self._process_stt_and_nlp_background(file_content, file.filename, voice.voice_id)) + asyncio.create_task(self._process_audio_emotion_background(file_content, file.filename, voice.voice_id)) return { "success": True, @@ -140,6 +142,75 @@ def __init__(self, content, filename): except Exception as e: print(f"STT → NLP 처리 중 오류 발생: {e}") + + async def _process_audio_emotion_background(self, file_content: bytes, filename: str, voice_id: int): + """음성 파일 자체의 감정 분석을 백그라운드에서 수행하여 voice_analyze 저장""" + try: + file_obj = BytesIO(file_content) + + class TempUploadFile: + def __init__(self, content, filename): + self.file = content + self.filename = filename + self.content_type = "audio/m4a" if filename.endswith('.m4a') else "audio/wav" + + emotion_file = TempUploadFile(file_obj, filename) + result = analyze_voice_emotion(emotion_file) + + def to_bps(v: float) -> int: + try: + return max(0, min(10000, int(round(float(v) * 10000)))) + except Exception: + return 0 + + probs = result.get("probabilities") or result.get("scores") or {} + happy = to_bps(probs.get("happy", probs.get("happiness", 0))) + sad = to_bps(probs.get("sad", probs.get("sadness", 0))) + neutral = to_bps(probs.get("neutral", 0)) + angry = to_bps(probs.get("angry", probs.get("anger", 0))) + fear = to_bps(probs.get("fear", probs.get("fearful", 0))) + + top_emotion = result.get("top_emotion") or result.get("label") + top_conf = result.get("top_confidence") or result.get("confidence", 0) + top_conf_bps = to_bps(top_conf) + model_version = result.get("model_version") + + total_raw = happy + sad + neutral + angry + fear + if total_raw == 0: + # 모델이 확률을 반환하지 못한 경우: 중립 100% + happy, sad, neutral, angry, fear = 0, 0, 10000, 0, 0 + else: + # 비율 보정(라운딩 후 합 10000로 맞춤) + scale = 10000 / float(total_raw) + vals = { + "happy": int(round(happy * scale)), + "sad": int(round(sad * scale)), + "neutral": int(round(neutral * scale)), + "angry": int(round(angry * scale)), + "fear": int(round(fear * scale)), + } + diff = 10000 - sum(vals.values()) + if diff != 0: + # 가장 큰 항목에 차이를 보정(음수/양수 모두 처리) + key_max = max(vals, key=lambda k: vals[k]) + vals[key_max] = max(0, min(10000, vals[key_max] + diff)) + happy, sad, neutral, angry, fear = ( + vals["happy"], vals["sad"], vals["neutral"], vals["angry"], vals["fear"] + ) + + self.db_service.create_voice_analyze( + voice_id=voice_id, + happy_bps=happy, + sad_bps=sad, + neutral_bps=neutral, + angry_bps=angry, + fear_bps=fear, + top_emotion=top_emotion, + top_confidence_bps=top_conf_bps, + model_version=model_version, + ) + except Exception as e: + print(f"Audio emotion background error: {e}") def get_user_voice_list(self, username: str) -> Dict[str, Any]: """ @@ -312,8 +383,9 @@ async def upload_voice_with_question(self, file: UploadFile, username: str, ques sample_rate=16000 ) - # 6. STT + NLP 순차 처리 (백그라운드 비동기) + # 6. 비동기 후처리 (STT→NLP, 음성 감정 분석) asyncio.create_task(self._process_stt_and_nlp_background(file_content, file.filename, voice.voice_id)) + asyncio.create_task(self._process_audio_emotion_background(file_content, file.filename, voice.voice_id)) # 7. Voice-Question 매핑 저장 self.db_service.link_voice_question(voice.voice_id, question_id) From 25e8ff19f4c4eb6fbc3c00b138b8c0e0dac8acbe Mon Sep 17 00:00:00 2001 From: hann Date: Thu, 30 Oct 2025 15:27:37 +0900 Subject: [PATCH 06/16] feat : get emotion analyzing list API & test analyzing API --- app/db_service.py | 27 +++++++++++++++++++++++++++ app/dto.py | 11 +++++++++++ app/main.py | 30 ++++++++++++++++++++++++++++++ app/voice_service.py | 17 +++++++++++++++++ 4 files changed, 85 insertions(+) diff --git a/app/db_service.py b/app/db_service.py index a5436ee..6da1b38 100644 --- a/app/db_service.py +++ b/app/db_service.py @@ -36,6 +36,10 @@ def get_user_by_username(self, username: str) -> Optional[User]: def get_users(self, skip: int = 0, limit: int = 100) -> List[User]: """사용자 목록 조회""" return self.db.query(User).offset(skip).limit(limit).all() + + def get_user_by_user_code(self, user_code: str) -> Optional[User]: + """user_code로 사용자 조회""" + return self.db.query(User).filter(User.user_code == user_code).first() # Voice 관련 메서드 def create_voice(self, voice_key: str, voice_name: str, duration_ms: int, @@ -84,6 +88,29 @@ def get_voices_by_user(self, user_id: int, skip: int = 0, limit: int = 50) -> Li return self.db.query(Voice).filter(Voice.user_id == user_id)\ .options(joinedload(Voice.questions))\ .order_by(Voice.created_at.desc()).offset(skip).limit(limit).all() + + def get_care_voices(self, care_username: str, skip: int = 0, limit: int = 20) -> List[Voice]: + """보호자(care)의 연결 사용자 음성 중 voice_analyze가 존재하는 항목만 최신순 조회""" + from sqlalchemy.orm import joinedload + # 1) 보호자 조회 + care = self.get_user_by_username(care_username) + if not care or not care.connecting_user_code: + return [] + # 2) 연결된 사용자 조회 + linked_user = self.get_user_by_user_code(care.connecting_user_code) + if not linked_user: + return [] + # 3) 연결 사용자 음성 중 분석 완료만(join) 페이징 + q = ( + self.db.query(Voice) + .join(VoiceAnalyze, VoiceAnalyze.voice_id == Voice.voice_id) + .filter(Voice.user_id == linked_user.user_id) + .options(joinedload(Voice.questions), joinedload(Voice.voice_analyze)) + .order_by(Voice.created_at.desc()) + .offset(skip) + .limit(limit) + ) + return q.all() def get_all_voices(self, skip: int = 0, limit: int = 50) -> List[Voice]: """전체 음성 파일 목록 조회""" diff --git a/app/dto.py b/app/dto.py index cb1e353..1c98214 100644 --- a/app/dto.py +++ b/app/dto.py @@ -75,6 +75,17 @@ class UserVoiceListResponse(BaseModel): voices: list[VoiceListItem] +class CareVoiceListItem(BaseModel): + voice_id: int + created_at: str + emotion: Optional[str] = None + + +class CareUserVoiceListResponse(BaseModel): + success: bool + voices: list[CareVoiceListItem] + + class UserVoiceDetailResponse(BaseModel): voice_id: int title: Optional[str] = None diff --git a/app/main.py b/app/main.py index 875083b..4872431 100644 --- a/app/main.py +++ b/app/main.py @@ -18,6 +18,7 @@ UserVoiceUploadRequest, UserVoiceUploadResponse, VoiceQuestionUploadResponse, UserVoiceListResponse, UserVoiceDetailResponse, + CareUserVoiceListResponse, EmotionAnalysisResponse, TranscribeResponse, SentimentResponse, EntitiesResponse, SyntaxResponse, ComprehensiveAnalysisResponse ) @@ -202,6 +203,15 @@ async def get_user_voice_list(username: str): ) +# GET : 보호자 페이지 - 연결 사용자 음성 리스트(분석 완료만) +@app.get("/care/users/voices", response_model=CareUserVoiceListResponse) +async def get_care_user_voice_list(care_username: str, skip: int = 0, limit: int = 20): + db = next(get_db()) + voice_service = get_voice_service(db) + result = voice_service.get_care_voice_list(care_username, skip=skip, limit=limit) + return CareUserVoiceListResponse(success=result["success"], voices=result.get("voices", [])) + + # GET : 사용자 음성 상세 조회 @app.get("/users/voices/{voice_id}", response_model=UserVoiceDetailResponse) async def get_user_voice_detail(voice_id: int, username: str): @@ -335,6 +345,26 @@ async def list_voices(skip: int = 0, limit: int = 50, folder: Optional[str] = No return {"items": sliced, "count": len(sliced), "next": skip + len(sliced)} +# TEST: emotion_service 모델로 음성 감정 분석만 수행 (저장 없음) +@app.post("/test/voice/analyze") +async def test_emotion_analyze(file: UploadFile = File(...)): + """업로드된 파일을 emotion_service 모델로 분석하여 결과만 반환합니다.""" + try: + data = await file.read() + from io import BytesIO + + class FileWrapper: + def __init__(self, content, filename): + self.file = content + self.filename = filename + self.content_type = "audio/m4a" if filename.lower().endswith(".m4a") else "audio/wav" + + wrapped = FileWrapper(BytesIO(data), file.filename) + result = analyze_voice_emotion(wrapped) + return result + except Exception as e: + raise HTTPException(status_code=400, detail=f"emotion analyze failed: {str(e)}") + # GET : query specific voice & show result @app.get("/voices/{voice_id}") async def get_voice(voice_id: str): diff --git a/app/voice_service.py b/app/voice_service.py index 554c8c3..a8a8ebf 100644 --- a/app/voice_service.py +++ b/app/voice_service.py @@ -274,6 +274,23 @@ def get_user_voice_list(self, username: str) -> Dict[str, Any]: "voices": [] } + def get_care_voice_list(self, care_username: str, skip: int = 0, limit: int = 20) -> Dict[str, Any]: + """보호자 페이지: 연결된 사용자의 분석 완료 음성 목록 조회(페이징)""" + try: + voices = self.db_service.get_care_voices(care_username, skip=skip, limit=limit) + items = [] + for v in voices: + created_at = v.created_at.isoformat() if v.created_at else "" + emotion = v.voice_analyze.top_emotion if v.voice_analyze else None + items.append({ + "voice_id": v.voice_id, + "created_at": created_at, + "emotion": emotion, + }) + return {"success": True, "voices": items} + except Exception: + return {"success": False, "voices": []} + def get_user_voice_detail(self, voice_id: int, username: str) -> Dict[str, Any]: """voice_id와 username으로 상세 정보 조회""" try: From 3eefe17c4b8dbbd1f06c4414d7ce85c581141fe1 Mon Sep 17 00:00:00 2001 From: hann Date: Thu, 30 Oct 2025 15:50:47 +0900 Subject: [PATCH 07/16] feat : convert emotion analyzing model --- app/db_service.py | 7 ++-- app/dto.py | 13 +++++++ app/emotion_service.py | 78 ++++++++++++++++++++++++++++++++++++------ app/main.py | 55 +++++++++++++++++++++++++++-- app/models.py | 5 +-- app/voice_service.py | 14 +++++--- 6 files changed, 149 insertions(+), 23 deletions(-) diff --git a/app/db_service.py b/app/db_service.py index 6da1b38..0d29a5c 100644 --- a/app/db_service.py +++ b/app/db_service.py @@ -168,7 +168,7 @@ def update_voice_content(self, voice_id: int, content: str, # VoiceAnalyze 관련 메서드 def create_voice_analyze(self, voice_id: int, happy_bps: int, sad_bps: int, - neutral_bps: int, angry_bps: int, fear_bps: int, + neutral_bps: int, angry_bps: int, fear_bps: int, surprise_bps: int = 0, top_emotion: Optional[str] = None, top_confidence_bps: Optional[int] = None, model_version: Optional[str] = None) -> VoiceAnalyze: """음성 감정 분석 데이터 생성""" @@ -179,6 +179,7 @@ def create_voice_analyze(self, voice_id: int, happy_bps: int, sad_bps: int, neutral_bps=neutral_bps, angry_bps=angry_bps, fear_bps=fear_bps, + surprise_bps=surprise_bps, top_emotion=top_emotion, top_confidence_bps=top_confidence_bps, model_version=model_version @@ -193,7 +194,7 @@ def get_voice_analyze_by_voice_id(self, voice_id: int) -> Optional[VoiceAnalyze] return self.db.query(VoiceAnalyze).filter(VoiceAnalyze.voice_id == voice_id).first() def update_voice_analyze(self, voice_id: int, happy_bps: int, sad_bps: int, - neutral_bps: int, angry_bps: int, fear_bps: int, + neutral_bps: int, angry_bps: int, fear_bps: int, surprise_bps: Optional[int] = None, top_emotion: Optional[str] = None, top_confidence_bps: Optional[int] = None, model_version: Optional[str] = None) -> Optional[VoiceAnalyze]: """음성 감정 분석 결과 업데이트""" @@ -204,6 +205,8 @@ def update_voice_analyze(self, voice_id: int, happy_bps: int, sad_bps: int, voice_analyze.neutral_bps = neutral_bps voice_analyze.angry_bps = angry_bps voice_analyze.fear_bps = fear_bps + if surprise_bps is not None: + voice_analyze.surprise_bps = surprise_bps if top_emotion is not None: voice_analyze.top_emotion = top_emotion if top_confidence_bps is not None: diff --git a/app/dto.py b/app/dto.py index 1c98214..fb9781b 100644 --- a/app/dto.py +++ b/app/dto.py @@ -94,6 +94,19 @@ class UserVoiceDetailResponse(BaseModel): voice_content: Optional[str] = None +class VoiceAnalyzePreviewResponse(BaseModel): + voice_id: Optional[int] = None + happy_bps: int + sad_bps: int + neutral_bps: int + angry_bps: int + fear_bps: int + surprise_bps: int + top_emotion: Optional[str] = None + top_confidence_bps: Optional[int] = None + model_version: Optional[str] = None + + class VoiceDetailResponse(BaseModel): voice_id: str filename: str diff --git a/app/emotion_service.py b/app/emotion_service.py index 5ff363a..53eb6ff 100644 --- a/app/emotion_service.py +++ b/app/emotion_service.py @@ -4,6 +4,7 @@ import librosa import torch from transformers import Wav2Vec2ForSequenceClassification, Wav2Vec2FeatureExtractor +import soundfile as sf import numpy as np @@ -16,7 +17,9 @@ def __init__(self): def _load_model(self): """Hugging Face 모델 로드""" - model_name = "jungjongho/wav2vec2-xlsr-korean-speech-emotion-recognition" + # rebalanced 모델로 교체 + # https://huggingface.co/jungjongho/wav2vec2-xlsr-korean-speech-emotion-recognition2_data_rebalance + model_name = "jungjongho/wav2vec2-xlsr-korean-speech-emotion-recognition2_data_rebalance" try: self.model = Wav2Vec2ForSequenceClassification.from_pretrained(model_name) @@ -46,14 +49,33 @@ def analyze_emotion(self, audio_file) -> Dict[str, Any]: } try: - # 임시 파일로 저장 - with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp_file: + # 업로드 확장자 반영하여 임시 파일로 저장 + import os + orig_name = getattr(audio_file, "filename", "") or "" + _, ext = os.path.splitext(orig_name) + suffix = ext if ext.lower() in [".wav", ".m4a", ".mp3", ".flac", ".ogg", ".aac", ".caf"] else ".wav" + with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp_file: content = audio_file.file.read() tmp_file.write(content) tmp_file_path = tmp_file.name - # 오디오 로드 (16kHz로 리샘플링) - audio, sr = librosa.load(tmp_file_path, sr=16000) + # 오디오 로드 (16kHz, 견고한 로더) + def robust_load(path: str, target_sr: int = 16000): + try: + data, sr = sf.read(path, always_2d=True, dtype="float32") + if data.ndim == 2 and data.shape[1] > 1: + data = data.mean(axis=1) + else: + data = data.reshape(-1) + if sr != target_sr: + data = librosa.resample(data, orig_sr=sr, target_sr=target_sr) + sr = target_sr + return data, sr + except Exception: + y, sr = librosa.load(path, sr=target_sr, mono=True) + return y.astype("float32"), sr + + audio, sr = robust_load(tmp_file_path, 16000) # 특성 추출 inputs = self.feature_extractor( @@ -71,8 +93,18 @@ def analyze_emotion(self, audio_file) -> Dict[str, Any]: outputs = self.model(**inputs) predictions = torch.nn.functional.softmax(outputs.logits, dim=-1) - # 감정 라벨 (모델에 따라 조정 필요) - emotion_labels = ["neutral", "happy", "sad", "angry", "fear", "surprise", "disgust"] + # 감정 라벨 매핑: 모델 config 우선, 숫자형 값이면 사람이 읽을 수 있는 이름으로 대체 + default_labels = ["neutral", "happy", "sad", "angry", "fear", "surprise"] + id2label = getattr(self.model.config, "id2label", None) + if isinstance(id2label, dict) and predictions.shape[1] == len(id2label): + labels = [id2label.get(str(i), id2label.get(i, str(i))) for i in range(predictions.shape[1])] + # 값이 전부 숫자 형태라면 사람이 읽을 수 있는 기본 라벨로 대체 + if all(isinstance(v, (int, float)) or (isinstance(v, str) and v.isdigit()) for v in labels): + emotion_labels = default_labels[:predictions.shape[1]] + else: + emotion_labels = labels + else: + emotion_labels = default_labels[:predictions.shape[1]] # 가장 높은 확률의 감정 predicted_class = torch.argmax(predictions, dim=-1).item() @@ -81,14 +113,38 @@ def analyze_emotion(self, audio_file) -> Dict[str, Any]: # 모든 감정의 확률 emotion_scores = { - emotion_labels[i]: predictions[0][i].item() + emotion_labels[i]: predictions[0][i].item() for i in range(min(len(emotion_labels), predictions.shape[1])) } + # 한국어 라벨 → 영어 라벨 매핑 + ko2en = { + "중립": "neutral", + "기쁨": "happy", + "행복": "happy", + "슬픔": "sad", + "분노": "angry", + "화남": "angry", + "불안": "anxiety", + "두려움": "fear", + "공포": "fear", + "놀람": "surprise", + "당황": "surprise", + } + + def to_en(label: str) -> str: + if not isinstance(label, str): + return str(label) + return ko2en.get(label, label) + + emotion_en = to_en(emotion) + emotion_scores_en = {to_en(k): v for k, v in emotion_scores.items()} + return { - "emotion": emotion, - "confidence": confidence, - "emotion_scores": emotion_scores, + "emotion": emotion_en, # 대표 감정 (영문) + "top_emotion": emotion_en, # 동일 표기(영문) + "confidence": confidence, # 대표 감정 확률 + "emotion_scores": emotion_scores_en, # 영문 라벨명→확률 "audio_duration": len(audio) / sr, "sample_rate": sr } diff --git a/app/main.py b/app/main.py index 4872431..39e792f 100644 --- a/app/main.py +++ b/app/main.py @@ -20,7 +20,8 @@ UserVoiceListResponse, UserVoiceDetailResponse, CareUserVoiceListResponse, EmotionAnalysisResponse, TranscribeResponse, - SentimentResponse, EntitiesResponse, SyntaxResponse, ComprehensiveAnalysisResponse + SentimentResponse, EntitiesResponse, SyntaxResponse, ComprehensiveAnalysisResponse, + VoiceAnalyzePreviewResponse ) app = FastAPI(title="Caring API") @@ -346,7 +347,7 @@ async def list_voices(skip: int = 0, limit: int = 50, folder: Optional[str] = No # TEST: emotion_service 모델로 음성 감정 분석만 수행 (저장 없음) -@app.post("/test/voice/analyze") +@app.post("/test/voice/analyze", response_model=VoiceAnalyzePreviewResponse) async def test_emotion_analyze(file: UploadFile = File(...)): """업로드된 파일을 emotion_service 모델로 분석하여 결과만 반환합니다.""" try: @@ -361,7 +362,55 @@ def __init__(self, content, filename): wrapped = FileWrapper(BytesIO(data), file.filename) result = analyze_voice_emotion(wrapped) - return result + # probs dict에서 6개 감정 추출 → bps 정규화(합 10000) + probs = result.get("probabilities") or result.get("scores") or result.get("emotion_scores") or {} + def to_bps(x): + try: + return max(0, min(10000, int(round(float(x) * 10000)))) + except Exception: + return 0 + happy = to_bps(probs.get("happy", 0)) + sad = to_bps(probs.get("sad", 0)) + neutral = to_bps(probs.get("neutral", 0)) + angry = to_bps(probs.get("angry", 0)) + fear = to_bps(probs.get("fear", 0)) + surprise = to_bps(probs.get("surprise", 0)) + total = happy + sad + neutral + angry + fear + surprise + if total == 0: + neutral = 10000 + happy = sad = angry = fear = surprise = 0 + else: + scale = 10000 / float(total) + vals = { + "happy": int(round(happy * scale)), + "sad": int(round(sad * scale)), + "neutral": int(round(neutral * scale)), + "angry": int(round(angry * scale)), + "fear": int(round(fear * scale)), + "surprise": int(round(surprise * scale)), + } + diff = 10000 - sum(vals.values()) + if diff != 0: + k = max(vals, key=lambda k: vals[k]) + vals[k] = max(0, min(10000, vals[k] + diff)) + happy, sad, neutral, angry, fear, surprise = ( + vals["happy"], vals["sad"], vals["neutral"], vals["angry"], vals["fear"], vals["surprise"] + ) + + top_emotion = result.get("top_emotion") or result.get("label") or result.get("emotion") + top_conf_bps = to_bps(result.get("top_confidence") or result.get("confidence", 0)) + return VoiceAnalyzePreviewResponse( + voice_id=None, + happy_bps=happy, + sad_bps=sad, + neutral_bps=neutral, + angry_bps=angry, + fear_bps=fear, + surprise_bps=surprise, + top_emotion=top_emotion, + top_confidence_bps=top_conf_bps, + model_version=result.get("model_version") + ) except Exception as e: raise HTTPException(status_code=400, detail=f"emotion analyze failed: {str(e)}") diff --git a/app/models.py b/app/models.py index ce340b9..fd76801 100644 --- a/app/models.py +++ b/app/models.py @@ -91,6 +91,7 @@ class VoiceAnalyze(Base): neutral_bps = Column(SmallInteger, nullable=False) # 0~10000 angry_bps = Column(SmallInteger, nullable=False) # 0~10000 fear_bps = Column(SmallInteger, nullable=False) # 0~10000 + surprise_bps = Column(SmallInteger, nullable=False, default=0) # 0~10000 top_emotion = Column(String(16), nullable=True) # 'neutral' 등 top_confidence_bps = Column(SmallInteger, nullable=True) # 0~10000 model_version = Column(String(32), nullable=True) @@ -102,8 +103,8 @@ class VoiceAnalyze(Base): # 제약 조건 __table_args__ = ( UniqueConstraint('voice_id', name='uq_va_voice'), - CheckConstraint("happy_bps <= 10000 AND sad_bps <= 10000 AND neutral_bps <= 10000 AND angry_bps <= 10000 AND fear_bps <= 10000", name='check_emotion_bps_range'), - CheckConstraint("happy_bps + sad_bps + neutral_bps + angry_bps + fear_bps = 10000", name='check_emotion_bps_sum'), + CheckConstraint("happy_bps <= 10000 AND sad_bps <= 10000 AND neutral_bps <= 10000 AND angry_bps <= 10000 AND fear_bps <= 10000 AND surprise_bps <= 10000", name='check_emotion_bps_range'), + CheckConstraint("happy_bps + sad_bps + neutral_bps + angry_bps + fear_bps + surprise_bps = 10000", name='check_emotion_bps_sum'), ) diff --git a/app/voice_service.py b/app/voice_service.py index a8a8ebf..e9646ab 100644 --- a/app/voice_service.py +++ b/app/voice_service.py @@ -169,16 +169,18 @@ def to_bps(v: float) -> int: neutral = to_bps(probs.get("neutral", 0)) angry = to_bps(probs.get("angry", probs.get("anger", 0))) fear = to_bps(probs.get("fear", probs.get("fearful", 0))) + surprise = to_bps(probs.get("surprise", probs.get("surprised", 0))) - top_emotion = result.get("top_emotion") or result.get("label") + # 모델 응답 키 보정: emotion_service는 기본적으로 "emotion"을 반환 + top_emotion = result.get("top_emotion") or result.get("label") or result.get("emotion") top_conf = result.get("top_confidence") or result.get("confidence", 0) top_conf_bps = to_bps(top_conf) model_version = result.get("model_version") - total_raw = happy + sad + neutral + angry + fear + total_raw = happy + sad + neutral + angry + fear + surprise if total_raw == 0: # 모델이 확률을 반환하지 못한 경우: 중립 100% - happy, sad, neutral, angry, fear = 0, 0, 10000, 0, 0 + happy, sad, neutral, angry, fear, surprise = 0, 0, 10000, 0, 0, 0 else: # 비율 보정(라운딩 후 합 10000로 맞춤) scale = 10000 / float(total_raw) @@ -188,14 +190,15 @@ def to_bps(v: float) -> int: "neutral": int(round(neutral * scale)), "angry": int(round(angry * scale)), "fear": int(round(fear * scale)), + "surprise": int(round(surprise * scale)), } diff = 10000 - sum(vals.values()) if diff != 0: # 가장 큰 항목에 차이를 보정(음수/양수 모두 처리) key_max = max(vals, key=lambda k: vals[k]) vals[key_max] = max(0, min(10000, vals[key_max] + diff)) - happy, sad, neutral, angry, fear = ( - vals["happy"], vals["sad"], vals["neutral"], vals["angry"], vals["fear"] + happy, sad, neutral, angry, fear, surprise = ( + vals["happy"], vals["sad"], vals["neutral"], vals["angry"], vals["fear"], vals["surprise"] ) self.db_service.create_voice_analyze( @@ -205,6 +208,7 @@ def to_bps(v: float) -> int: neutral_bps=neutral, angry_bps=angry, fear_bps=fear, + surprise_bps=surprise, top_emotion=top_emotion, top_confidence_bps=top_conf_bps, model_version=model_version, From d010073d6a7d83a1e8c69cb96ddbae99743d646a Mon Sep 17 00:00:00 2001 From: hann Date: Thu, 30 Oct 2025 16:08:16 +0900 Subject: [PATCH 08/16] feat : defenitly insert emotion analyzing data --- app/voice_service.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/app/voice_service.py b/app/voice_service.py index e9646ab..d6ffd07 100644 --- a/app/voice_service.py +++ b/app/voice_service.py @@ -163,7 +163,7 @@ def to_bps(v: float) -> int: except Exception: return 0 - probs = result.get("probabilities") or result.get("scores") or {} + probs = result.get("emotion_scores", {}) happy = to_bps(probs.get("happy", probs.get("happiness", 0))) sad = to_bps(probs.get("sad", probs.get("sadness", 0))) neutral = to_bps(probs.get("neutral", 0)) @@ -178,12 +178,18 @@ def to_bps(v: float) -> int: model_version = result.get("model_version") total_raw = happy + sad + neutral + angry + fear + surprise + print(f"[voice_analyze] ROUND 이전: happy={happy}, sad={sad}, neutral={neutral}, angry={angry}, fear={fear}, surprise={surprise} → 합계={total_raw}") if total_raw == 0: # 모델이 확률을 반환하지 못한 경우: 중립 100% + print(f"[voice_analyze] 확률 없음: 모두 0 → neutral=10000") happy, sad, neutral, angry, fear, surprise = 0, 0, 10000, 0, 0, 0 else: # 비율 보정(라운딩 후 합 10000로 맞춤) scale = 10000 / float(total_raw) + before_vals = { + "happy": happy, "sad": sad, "neutral": neutral, + "angry": angry, "fear": fear, "surprise": surprise, + } vals = { "happy": int(round(happy * scale)), "sad": int(round(sad * scale)), @@ -192,11 +198,14 @@ def to_bps(v: float) -> int: "fear": int(round(fear * scale)), "surprise": int(round(surprise * scale)), } + print(f"[voice_analyze] ROUND: raw={before_vals} scale={scale:.5f} → after={vals}") diff = 10000 - sum(vals.values()) if diff != 0: # 가장 큰 항목에 차이를 보정(음수/양수 모두 처리) key_max = max(vals, key=lambda k: vals[k]) + print(f"[voice_analyze] DIFF 보정: {diff} → max_emotion={key_max} ({vals[key_max]}) before") vals[key_max] = max(0, min(10000, vals[key_max] + diff)) + print(f"[voice_analyze] DIFF 보정: {diff} → max_emotion={key_max} after={vals[key_max]}") happy, sad, neutral, angry, fear, surprise = ( vals["happy"], vals["sad"], vals["neutral"], vals["angry"], vals["fear"], vals["surprise"] ) From dbb1a6fed93dc5de47f939ad7d8b9419f32a85f4 Mon Sep 17 00:00:00 2001 From: hann Date: Thu, 30 Oct 2025 17:17:10 +0900 Subject: [PATCH 09/16] chore : divide swagger by prefix --- app/main.py | 367 +++++++++------------------------------------------- 1 file changed, 63 insertions(+), 304 deletions(-) diff --git a/app/main.py b/app/main.py index 39e792f..c784f63 100644 --- a/app/main.py +++ b/app/main.py @@ -1,6 +1,6 @@ import os from typing import Optional -from fastapi import FastAPI, UploadFile, File, HTTPException, Form +from fastapi import FastAPI, UploadFile, File, HTTPException, Form, APIRouter from fastapi.responses import JSONResponse from typing import List from .s3_service import upload_fileobj, list_bucket_objects @@ -26,101 +26,73 @@ app = FastAPI(title="Caring API") +users_router = APIRouter(prefix="/users", tags=["users"]) +care_router = APIRouter(prefix="/care", tags=["care"]) +admin_router = APIRouter(prefix="/admin", tags=["admin"]) +nlp_router = APIRouter(prefix="/nlp", tags=["nlp"]) +test_router = APIRouter(prefix="/test", tags=["test"]) +# Health @app.get("/health") def health(): return {"status": "ok"} - -# ==================== 데이터베이스 관리 API ==================== - -@app.post("/admin/db/migrate") +# ============ Admin 영역 ============ +@admin_router.post("/db/migrate") async def run_migration(): - """데이터베이스 마이그레이션 실행""" try: from alembic import command from alembic.config import Config - print("🔄 마이그레이션 실행 중...") alembic_cfg = Config("alembic.ini") command.upgrade(alembic_cfg, "head") - - return { - "success": True, - "message": "마이그레이션이 성공적으로 실행되었습니다." - } + return {"success": True, "message": "마이그레이션이 성공적으로 실행되었습니다."} except Exception as e: raise HTTPException(status_code=500, detail=f"마이그레이션 실패: {str(e)}") - -@app.post("/admin/db/init") +@admin_router.post("/db/init") async def init_database(): - """데이터베이스 초기화 (테이블 생성)""" try: from sqlalchemy import inspect inspector = inspect(engine) existing_tables = inspector.get_table_names() all_tables = set(Base.metadata.tables.keys()) missing_tables = all_tables - set(existing_tables) - if missing_tables: print(f"🔨 테이블 생성 중: {', '.join(missing_tables)}") table_order = ['user', 'voice', 'voice_content', 'voice_analyze', 'question', 'voice_question'] - for table_name in table_order: if table_name in missing_tables: table = Base.metadata.tables[table_name] table.create(bind=engine, checkfirst=True) - other_tables = missing_tables - set(table_order) if other_tables: for table_name in other_tables: table = Base.metadata.tables[table_name] table.create(bind=engine, checkfirst=True) - - return { - "success": True, - "message": "테이블이 생성되었습니다.", - "created_tables": list(missing_tables) - } + return {"success": True, "message": "테이블이 생성되었습니다.", "created_tables": list(missing_tables)} else: - return { - "success": True, - "message": "모든 테이블이 이미 존재합니다." - } + return {"success": True, "message": "모든 테이블이 이미 존재합니다."} except Exception as e: raise HTTPException(status_code=500, detail=f"데이터베이스 초기화 실패: {str(e)}") - -@app.get("/admin/db/status") +@admin_router.get("/db/status") async def get_database_status(): - """데이터베이스 상태 확인""" try: from sqlalchemy import inspect inspector = inspect(engine) existing_tables = inspector.get_table_names() all_tables = set(Base.metadata.tables.keys()) missing_tables = all_tables - set(existing_tables) - - return { - "success": True, - "total_tables": len(all_tables), - "existing_tables": existing_tables, - "missing_tables": list(missing_tables), - "is_sync": len(missing_tables) == 0 - } + return {"success": True, "total_tables": len(all_tables), "existing_tables": existing_tables, "missing_tables": list(missing_tables), "is_sync": len(missing_tables) == 0} except Exception as e: raise HTTPException(status_code=500, detail=f"상태 확인 실패: {str(e)}") -# --------------------------------------auth API-------------------------------------- - -# POST : 회원가입 +# ============ Auth 전용(signup, signin)은 루트에 남김 =========== @app.post("/sign-up", response_model=SignupResponse) async def sign_up(request: SignupRequest): - """회원가입 API""" db = next(get_db()) auth_service = get_auth_service(db) - result = auth_service.signup( name=request.name, birthdate=request.birthdate, @@ -129,7 +101,6 @@ async def sign_up(request: SignupRequest): role=request.role, connecting_user_code=request.connecting_user_code ) - if result["success"]: return SignupResponse( message="회원가입이 완료되었습니다.", @@ -141,20 +112,15 @@ async def sign_up(request: SignupRequest): else: raise HTTPException(status_code=400, detail=result["error"]) - -# POST : 로그인 @app.post("/sign-in", response_model=SigninResponse) async def sign_in(request: SigninRequest, role: str): - """로그인 API (role은 Request Parameter)""" db = next(get_db()) auth_service = get_auth_service(db) - result = auth_service.signin( username=request.username, password=request.password, role=role ) - if result["success"]: return SigninResponse( message="로그인 성공", @@ -165,58 +131,16 @@ async def sign_in(request: SigninRequest, role: str): else: raise HTTPException(status_code=401, detail=result["error"]) - -# POST : 사용자 음성 업로드 -# @app.post("/users/voices", response_model=UserVoiceUploadResponse) -# async def upload_user_voice( -# file: UploadFile = File(...), -# username: str = Form(...) -# ): -# """사용자 음성 파일 업로드 (S3 + DB 저장)""" -# db = next(get_db()) -# voice_service = get_voice_service(db) - -# result = await voice_service.upload_user_voice(file, username) - -# if result["success"]: -# return UserVoiceUploadResponse( -# success=True, -# message=result["message"], -# voice_id=result.get("voice_id") -# ) -# else: -# raise HTTPException(status_code=400, detail=result["message"]) - - -# --------------------------------------voice API-------------------------------------- -# GET : 사용자 음성 리스트 조회 -@app.get("/users/voices", response_model=UserVoiceListResponse) +# ============== users 영역 (음성 업로드/조회/삭제 등) ============= +@users_router.get("/voices", response_model=UserVoiceListResponse) async def get_user_voice_list(username: str): - """사용자 음성 리스트 조회""" db = next(get_db()) voice_service = get_voice_service(db) - result = voice_service.get_user_voice_list(username) - - return UserVoiceListResponse( - success=result["success"], - voices=result.get("voices", []) - ) + return UserVoiceListResponse(success=result["success"], voices=result.get("voices", [])) - -# GET : 보호자 페이지 - 연결 사용자 음성 리스트(분석 완료만) -@app.get("/care/users/voices", response_model=CareUserVoiceListResponse) -async def get_care_user_voice_list(care_username: str, skip: int = 0, limit: int = 20): - db = next(get_db()) - voice_service = get_voice_service(db) - result = voice_service.get_care_voice_list(care_username, skip=skip, limit=limit) - return CareUserVoiceListResponse(success=result["success"], voices=result.get("voices", [])) - - -# GET : 사용자 음성 상세 조회 -@app.get("/users/voices/{voice_id}", response_model=UserVoiceDetailResponse) +@users_router.get("/voices/{voice_id}", response_model=UserVoiceDetailResponse) async def get_user_voice_detail(voice_id: int, username: str): - """voice_id와 username으로 상세 조회""" db = next(get_db()) voice_service = get_voice_service(db) result = voice_service.get_user_voice_detail(voice_id, username) @@ -230,9 +154,7 @@ async def get_user_voice_detail(voice_id: int, username: str): voice_content=result.get("voice_content"), ) - -# DELETE : 유저 특정 음성 삭제 -@app.delete("/users/voices/{voice_id}") +@users_router.delete("/voices/{voice_id}") async def delete_user_voice(voice_id: int, username: str): db = next(get_db()) voice_service = get_voice_service(db) @@ -241,30 +163,17 @@ async def delete_user_voice(voice_id: int, username: str): return {"success": True} raise HTTPException(status_code=400, detail=result.get("message", "Delete failed")) - -# POST : 질문과 함께 음성 업로드 -@app.post("/users/voices", response_model=VoiceQuestionUploadResponse) +@users_router.post("/voices", response_model=VoiceQuestionUploadResponse) async def upload_voice_with_question( file: UploadFile = File(...), question_id: int = Form(...), username: str = None, ): - """ - 질문과 함께 음성 파일 업로드 - - 처리 흐름(비동기 후처리 포함): - - 즉시: S3 업로드, voice 생성, voice_question 매핑 저장 후 성공 응답 반환 - - 백그라운드1: STT→텍스트 감정분석 실행 후 voice_content insert/update - - 백그라운드2: 음성 감정분석 실행 후 voice_analyze insert - """ db = next(get_db()) voice_service = get_voice_service(db) - if not username: raise HTTPException(status_code=400, detail="username is required as query parameter") - result = await voice_service.upload_voice_with_question(file, username, question_id) - if result["success"]: return VoiceQuestionUploadResponse( success=True, @@ -275,95 +184,57 @@ async def upload_voice_with_question( else: raise HTTPException(status_code=400, detail=result["message"]) +# ============== care 영역 (보호자전용) ============= +@care_router.get("/users/voices", response_model=CareUserVoiceListResponse) +async def get_care_user_voice_list(care_username: str, skip: int = 0, limit: int = 20): + db = next(get_db()) + voice_service = get_voice_service(db) + result = voice_service.get_care_voice_list(care_username, skip=skip, limit=limit) + return CareUserVoiceListResponse(success=result["success"], voices=result.get("voices", [])) -# # POST : upload voice with STT -# @app.post("/voices/upload") -# async def upload_voice( -# file: UploadFile = File(...), -# folder: Optional[str] = Form(default=None), -# language_code: str = Form(default="ko-KR") -# ): -# """ -# 음성 파일 업로드 및 STT 수행 - -# 참고: `/users/voices` 경로는 업로드와 동시에 비동기로 -# - STT→텍스트 감정분석(`voice_content` 저장) -# - 음성 감정분석(`voice_analyze` 저장) -# 를 실행합니다. -# """ -# bucket = os.getenv("S3_BUCKET_NAME") -# if not bucket: -# raise HTTPException(status_code=500, detail="S3_BUCKET_NAME not configured") - -# # 파일 내용을 메모리에 읽기 (두 번 사용하기 위해) -# file_content = await file.read() - -# # S3 업로드 -# base_prefix = VOICE_BASE_PREFIX.rstrip("/") -# effective_prefix = f"{base_prefix}/{folder or DEFAULT_UPLOAD_FOLDER}".rstrip("/") -# key = f"{effective_prefix}/{file.filename}" - -# from io import BytesIO -# file_obj_for_s3 = BytesIO(file_content) -# upload_fileobj(bucket=bucket, key=key, fileobj=file_obj_for_s3) - -# # STT 변환 - 파일 내용을 직접 사용 -# from io import BytesIO -# temp_file_obj = BytesIO(file_content) - -# # UploadFile과 유사한 객체 생성 -# class TempUploadFile: -# def __init__(self, content, filename): -# self.file = content -# self.filename = filename -# self.content_type = "audio/wav" - -# temp_upload_file = TempUploadFile(temp_file_obj, file.filename) -# stt_result = transcribe_voice(temp_upload_file, language_code) - -# # 파일 목록 조회 -# names = list_bucket_objects(bucket=bucket, prefix=effective_prefix) - -# return { -# "uploaded": key, -# "files": names, -# "transcription": stt_result -# } - +# ============== nlp 영역 (구글 NLP) ============= +@nlp_router.post("/sentiment") +async def analyze_sentiment(text: str, language_code: str = "ko"): + sentiment_result = analyze_text_sentiment(text, language_code) + return sentiment_result -# GET : query my voice histories -@app.get("/voices") -async def list_voices(skip: int = 0, limit: int = 50, folder: Optional[str] = None): - bucket = os.getenv("S3_BUCKET_NAME") - if not bucket: - raise HTTPException(status_code=500, detail="S3_BUCKET_NAME not configured") - base_prefix = VOICE_BASE_PREFIX.rstrip("/") - effective_prefix = f"{base_prefix}/{folder or DEFAULT_UPLOAD_FOLDER}".rstrip("/") +@nlp_router.post("/entities") +async def extract_entities(text: str, language_code: str = "ko"): + entities_result = analyze_text_entities(text, language_code) + return entities_result - keys = list_bucket_objects(bucket=bucket, prefix=effective_prefix) - # 페이징 비슷하게 slice만 적용 - sliced = keys[skip: skip + limit] - return {"items": sliced, "count": len(sliced), "next": skip + len(sliced)} +@nlp_router.post("/syntax") +async def analyze_syntax(text: str, language_code: str = "ko"): + syntax_result = analyze_text_syntax(text, language_code) + return syntax_result +@nlp_router.post("/analyze") +async def analyze_text_comprehensive(text: str, language_code: str = "ko"): + sentiment_result = analyze_text_sentiment(text, language_code) + entities_result = analyze_text_entities(text, language_code) + syntax_result = analyze_text_syntax(text, language_code) + return { + "text": text, + "language_code": language_code, + "sentiment_analysis": sentiment_result, + "entity_analysis": entities_result, + "syntax_analysis": syntax_result + } -# TEST: emotion_service 모델로 음성 감정 분석만 수행 (저장 없음) -@app.post("/test/voice/analyze", response_model=VoiceAnalyzePreviewResponse) +# ============== test 영역 ============= +@test_router.post("/voice/analyze", response_model=VoiceAnalyzePreviewResponse) async def test_emotion_analyze(file: UploadFile = File(...)): - """업로드된 파일을 emotion_service 모델로 분석하여 결과만 반환합니다.""" try: data = await file.read() from io import BytesIO - class FileWrapper: def __init__(self, content, filename): self.file = content self.filename = filename self.content_type = "audio/m4a" if filename.lower().endswith(".m4a") else "audio/wav" - wrapped = FileWrapper(BytesIO(data), file.filename) result = analyze_voice_emotion(wrapped) - # probs dict에서 6개 감정 추출 → bps 정규화(합 10000) - probs = result.get("probabilities") or result.get("scores") or result.get("emotion_scores") or {} + probs = result.get("emotion_scores") or {} def to_bps(x): try: return max(0, min(10000, int(round(float(x) * 10000)))) @@ -396,7 +267,6 @@ def to_bps(x): happy, sad, neutral, angry, fear, surprise = ( vals["happy"], vals["sad"], vals["neutral"], vals["angry"], vals["fear"], vals["surprise"] ) - top_emotion = result.get("top_emotion") or result.get("label") or result.get("emotion") top_conf_bps = to_bps(result.get("top_confidence") or result.get("confidence", 0)) return VoiceAnalyzePreviewResponse( @@ -414,120 +284,9 @@ def to_bps(x): except Exception as e: raise HTTPException(status_code=400, detail=f"emotion analyze failed: {str(e)}") -# GET : query specific voice & show result -@app.get("/voices/{voice_id}") -async def get_voice(voice_id: str): - # 내부 로직은 생략, 더미 상세 반환 - result = { - "voice_id": voice_id, - "filename": f"{voice_id}.wav", - "status": "processed", - "duration_sec": 12.34, - "analysis": {"pitch_mean": 220.5, "energy": 0.82} - } - return JSONResponse(content=result) - - -# POST : analyze emotion from S3 file -@app.post("/voices/{voice_key}/analyze-emotion") -async def analyze_emotion_from_s3(voice_key: str): - """S3에 저장된 음성 파일의 감정을 분석합니다.""" - bucket = os.getenv("S3_BUCKET_NAME") - if not bucket: - raise HTTPException(status_code=500, detail="S3_BUCKET_NAME not configured") - - try: - # S3에서 파일 다운로드 - from .s3_service import get_s3_client - s3_client = get_s3_client() - - response = s3_client.get_object(Bucket=bucket, Key=voice_key) - file_content = response['Body'].read() - - # BytesIO로 파일 객체 생성 - from io import BytesIO - file_obj = BytesIO(file_content) - - # 파일명 추출 (키에서 마지막 부분) - filename = voice_key.split('/')[-1] - - class FileWrapper: - def __init__(self, content, filename, content_type): - self.file = content - self.filename = filename - self.content_type = content_type - - emotion_file = FileWrapper(file_obj, filename, "audio/wav") - emotion_result = analyze_voice_emotion(emotion_file) - - return { - "voice_key": voice_key, - "emotion_analysis": emotion_result - } - - except Exception as e: - raise HTTPException(status_code=404, detail=f"파일을 찾을 수 없거나 분석 중 오류 발생: {str(e)}") - - -# POST : convert speech to text using Google STT -@app.post("/voices/transcribe") -async def transcribe_speech( - file: UploadFile = File(...), - language_code: str = "ko-KR" -): - """음성 파일을 텍스트로 변환합니다.""" - stt_result = transcribe_voice(file, language_code) - return stt_result - - -# POST : analyze text sentiment using Google NLP -@app.post("/nlp/sentiment") -async def analyze_sentiment( - text: str, - language_code: str = "ko" -): - """텍스트의 감정을 분석합니다.""" - sentiment_result = analyze_text_sentiment(text, language_code) - return sentiment_result - - -# POST : extract entities from text using Google NLP -@app.post("/nlp/entities") -async def extract_entities( - text: str, - language_code: str = "ko" -): - """텍스트에서 엔티티를 추출합니다.""" - entities_result = analyze_text_entities(text, language_code) - return entities_result - - -# POST : analyze text syntax using Google NLP -@app.post("/nlp/syntax") -async def analyze_syntax( - text: str, - language_code: str = "ko" -): - """텍스트의 구문을 분석합니다.""" - syntax_result = analyze_text_syntax(text, language_code) - return syntax_result - - -# POST : comprehensive text analysis using Google NLP -@app.post("/nlp/analyze") -async def analyze_text_comprehensive( - text: str, - language_code: str = "ko" -): - """텍스트의 감정, 엔티티, 구문을 종합 분석합니다.""" - sentiment_result = analyze_text_sentiment(text, language_code) - entities_result = analyze_text_entities(text, language_code) - syntax_result = analyze_text_syntax(text, language_code) - - return { - "text": text, - "language_code": language_code, - "sentiment_analysis": sentiment_result, - "entity_analysis": entities_result, - "syntax_analysis": syntax_result - } +# ---------------- router 등록 ---------------- +app.include_router(users_router) +app.include_router(care_router) +app.include_router(admin_router) +app.include_router(nlp_router) +app.include_router(test_router) From 5b8199a1b6fad83a5138e99d44c05203f045f838 Mon Sep 17 00:00:00 2001 From: hann Date: Thu, 30 Oct 2025 17:32:00 +0900 Subject: [PATCH 10/16] feat : get count emotions each Moth API --- app/care_service.py | 42 ++++++++++++++++++++++++++++++++++++++++++ app/main.py | 12 ++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 app/care_service.py diff --git a/app/care_service.py b/app/care_service.py new file mode 100644 index 0000000..ef40080 --- /dev/null +++ b/app/care_service.py @@ -0,0 +1,42 @@ +from sqlalchemy import func, extract +from .models import User, Voice, VoiceAnalyze +from .auth_service import get_auth_service + +class CareService: + def __init__(self, db): + self.db = db + self.auth_service = get_auth_service(db) + + def get_emotion_monthly_frequency(self, care_username: str, month: str) -> dict: + """ + 보호자 페이지: 연결 유저의 한달간 top_emotion 집계 반환 + :param care_username: 보호자 아이디 + :param month: 'YYYY-MM' + :return: {success, frequency: {emotion: count, ...}} + """ + try: + care = self.auth_service.get_user_by_username(care_username) + if not care or care.role != 'CARE' or not care.connecting_user_code: + return {"success": False, "frequency": {}, "message": "Care user not found or no connection."} + user = self.db.query(User).filter(User.user_code == care.connecting_user_code).first() + if not user: + return {"success": False, "frequency": {}, "message": "Connected user not found."} + try: + y, m = map(int, month.split("-")) + except Exception: + return {"success": False, "frequency": {}, "message": "month format YYYY-MM required"} + results = ( + self.db.query(VoiceAnalyze.top_emotion, func.count()) + .join(Voice, Voice.voice_id == VoiceAnalyze.voice_id) + .filter( + Voice.user_id == user.user_id, + extract('year', Voice.created_at) == y, + extract('month', Voice.created_at) == m + ) + .group_by(VoiceAnalyze.top_emotion) + .all() + ) + freq = {str(emotion): count for emotion, count in results if emotion} + return {"success": True, "frequency": freq} + except Exception as e: + return {"success": False, "frequency": {}, "message": f"error: {str(e)}"} diff --git a/app/main.py b/app/main.py index c784f63..f4c03da 100644 --- a/app/main.py +++ b/app/main.py @@ -23,6 +23,7 @@ SentimentResponse, EntitiesResponse, SyntaxResponse, ComprehensiveAnalysisResponse, VoiceAnalyzePreviewResponse ) +from .care_service import CareService app = FastAPI(title="Caring API") @@ -192,6 +193,17 @@ async def get_care_user_voice_list(care_username: str, skip: int = 0, limit: int result = voice_service.get_care_voice_list(care_username, skip=skip, limit=limit) return CareUserVoiceListResponse(success=result["success"], voices=result.get("voices", [])) +@care_router.get("/users/voices/analyzing/frequency") +async def get_emotion_monthly_frequency( + care_username: str, month: str +): + """ + 보호자 페이지: 연결된 유저의 한달간 감정 빈도수 집계 (CareService 내부 로직 사용) + """ + db = next(get_db()) + care_service = CareService(db) + return care_service.get_emotion_monthly_frequency(care_username, month) + # ============== nlp 영역 (구글 NLP) ============= @nlp_router.post("/sentiment") async def analyze_sentiment(text: str, language_code: str = "ko"): From a5616e5d0d1e35997af69b8a930ca6822d88f5d2 Mon Sep 17 00:00:00 2001 From: hann Date: Thu, 30 Oct 2025 17:36:00 +0900 Subject: [PATCH 11/16] feat : add Question APIs --- app/main.py | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/app/main.py b/app/main.py index f4c03da..81a9a96 100644 --- a/app/main.py +++ b/app/main.py @@ -9,7 +9,7 @@ from .stt_service import transcribe_voice from .nlp_service import analyze_text_sentiment, analyze_text_entities, analyze_text_syntax from .database import create_tables, engine, get_db -from .models import Base +from .models import Base, Question from .auth_service import get_auth_service from .voice_service import get_voice_service from .dto import ( @@ -24,6 +24,7 @@ VoiceAnalyzePreviewResponse ) from .care_service import CareService +import random app = FastAPI(title="Caring API") @@ -32,6 +33,7 @@ admin_router = APIRouter(prefix="/admin", tags=["admin"]) nlp_router = APIRouter(prefix="/nlp", tags=["nlp"]) test_router = APIRouter(prefix="/test", tags=["test"]) +questions_router = APIRouter(prefix="/questions", tags=["questions"]) # Health @app.get("/health") @@ -185,6 +187,32 @@ async def upload_voice_with_question( else: raise HTTPException(status_code=400, detail=result["message"]) +# 모든 질문 목록 반환 +@questions_router.get("") +async def get_questions(): + db = next(get_db()) + questions = db.query(Question).all() + results = [ + {"question_id": q.question_id, "question_category": q.question_category, "content": q.content} + for q in questions + ] + return {"success": True, "questions": results} + +# 질문 랜덤 반환 +@questions_router.get("/random") +async def get_random_question(): + db = next(get_db()) + question_count = db.query(Question).count() + if question_count == 0: + return {"success": False, "question": None} + import random + offset = random.randint(0, question_count - 1) + q = db.query(Question).offset(offset).first() + if q: + result = {"question_id": q.question_id, "question_category": q.question_category, "content": q.content} + return {"success": True, "question": result} + return {"success": False, "question": None} + # ============== care 영역 (보호자전용) ============= @care_router.get("/users/voices", response_model=CareUserVoiceListResponse) async def get_care_user_voice_list(care_username: str, skip: int = 0, limit: int = 20): @@ -302,3 +330,4 @@ def to_bps(x): app.include_router(admin_router) app.include_router(nlp_router) app.include_router(test_router) +app.include_router(questions_router) From b8669b4257002a3869f001b407e7d2276fb5edf7 Mon Sep 17 00:00:00 2001 From: hann Date: Thu, 30 Oct 2025 21:04:08 +0900 Subject: [PATCH 12/16] feat : get emotion analyzing weekly API --- app/care_service.py | 61 +++++++++++++++++++++++++++++++++++++++++++++ app/main.py | 11 ++++++++ 2 files changed, 72 insertions(+) diff --git a/app/care_service.py b/app/care_service.py index ef40080..a3b542b 100644 --- a/app/care_service.py +++ b/app/care_service.py @@ -1,6 +1,8 @@ from sqlalchemy import func, extract from .models import User, Voice, VoiceAnalyze from .auth_service import get_auth_service +from datetime import datetime, timedelta +from collections import Counter, defaultdict class CareService: def __init__(self, db): @@ -40,3 +42,62 @@ def get_emotion_monthly_frequency(self, care_username: str, month: str) -> dict: return {"success": True, "frequency": freq} except Exception as e: return {"success": False, "frequency": {}, "message": f"error: {str(e)}"} + + def get_emotion_weekly_summary(self, care_username: str, month: str, week: int) -> dict: + """ + 보호자 페이지: 연결 유저의 월/주차별 요일별 top 감정 요약 반환 + :param care_username: 보호자 아이디 + :param month: YYYY-MM + :param week: 1~5 (1주차~5주차) + :return: {success, weekly: [{day: "2025-10-02", weekday: "Thu", top_emotion: "happy"}, ...]} + """ + try: + care = self.auth_service.get_user_by_username(care_username) + if not care or care.role != 'CARE' or not care.connecting_user_code: + return {"success": False, "weekly": [], "message": "Care user not found or no connection."} + user = self.db.query(User).filter(User.user_code == care.connecting_user_code).first() + if not user: + return {"success": False, "weekly": [], "message": "Connected user not found."} + try: + y, m = map(int, month.split("-")) + except Exception: + return {"success": False, "weekly": [], "message": "month format YYYY-MM required"} + # 주차 구간 계산 + from calendar import monthrange + start_day = (week-1)*7+1 + end_day = min(week*7, monthrange(y, m)[1]) + start_date = datetime(y, m, start_day) + end_date = datetime(y, m, end_day, 23, 59, 59) + # 요일별 group + q = ( + self.db.query(Voice, VoiceAnalyze) + .join(VoiceAnalyze, Voice.voice_id == VoiceAnalyze.voice_id) + .filter( + Voice.user_id == user.user_id, + Voice.created_at >= start_date, + Voice.created_at <= end_date, + ).order_by(Voice.created_at.asc()) + ) + days = defaultdict(list) # day: [emotion, ...] + day_first = {} + for v, va in q: + d = v.created_at.date() + em = va.top_emotion + days[d].append(em) + if d not in day_first: + day_first[d] = em # 업로드 빠른 감정 미리 기억 + result = [] + for d in sorted(days.keys()): + cnt = Counter(days[d]) + top, val = cnt.most_common(1)[0] + # 동률 맞추기(동점시 가장 먼저 업로드한 감정을 top으로) + top_emotions = [e for e, c in cnt.items() if c == val] + selected = day_first[d] if len(top_emotions) > 1 and day_first[d] in top_emotions else top + result.append({ + "date": d.isoformat(), + "weekday": d.strftime("%a"), + "top_emotion": selected + }) + return {"success": True, "weekly": result} + except Exception as e: + return {"success": False, "weekly": [], "message": f"error: {str(e)}"} diff --git a/app/main.py b/app/main.py index 81a9a96..1311a7b 100644 --- a/app/main.py +++ b/app/main.py @@ -232,6 +232,17 @@ async def get_emotion_monthly_frequency( care_service = CareService(db) return care_service.get_emotion_monthly_frequency(care_username, month) +@care_router.get("/users/voices/analyzing/weekly") +async def get_emotion_weekly_summary( + care_username: str, + month: str, + week: int +): + """보호자페이지 - 연결유저 월/주차별 요일 top 감정 통계""" + db = next(get_db()) + care_service = CareService(db) + return care_service.get_emotion_weekly_summary(care_username, month, week) + # ============== nlp 영역 (구글 NLP) ============= @nlp_router.post("/sentiment") async def analyze_sentiment(text: str, language_code: str = "ko"): From 238845e3a72a8365d36c9af92dec425e7539b0d7 Mon Sep 17 00:00:00 2001 From: hann Date: Fri, 31 Oct 2025 14:06:07 +0900 Subject: [PATCH 13/16] fix : edit by coderabbit feedback --- app/emotion_service.py | 1 + 1 file changed, 1 insertion(+) diff --git a/app/emotion_service.py b/app/emotion_service.py index 53eb6ff..1fc1208 100644 --- a/app/emotion_service.py +++ b/app/emotion_service.py @@ -57,6 +57,7 @@ def analyze_emotion(self, audio_file) -> Dict[str, Any]: with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp_file: content = audio_file.file.read() tmp_file.write(content) + audio_file.file.seek(0) tmp_file_path = tmp_file.name # 오디오 로드 (16kHz, 견고한 로더) From b06aaa814ba305f7987e399b1ce97896a92f346a Mon Sep 17 00:00:00 2001 From: hann Date: Fri, 31 Oct 2025 14:07:36 +0900 Subject: [PATCH 14/16] fix : edit just line --- app/emotion_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/emotion_service.py b/app/emotion_service.py index 1fc1208..9273eb8 100644 --- a/app/emotion_service.py +++ b/app/emotion_service.py @@ -56,8 +56,8 @@ def analyze_emotion(self, audio_file) -> Dict[str, Any]: suffix = ext if ext.lower() in [".wav", ".m4a", ".mp3", ".flac", ".ogg", ".aac", ".caf"] else ".wav" with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp_file: content = audio_file.file.read() - tmp_file.write(content) audio_file.file.seek(0) + tmp_file.write(content) tmp_file_path = tmp_file.name # 오디오 로드 (16kHz, 견고한 로더) From 0773930b55c185fa2e6d4196e8f9af2bc449b0ec Mon Sep 17 00:00:00 2001 From: hann Date: Fri, 31 Oct 2025 14:18:37 +0900 Subject: [PATCH 15/16] chore : add gitignore --- .gitignore | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.gitignore b/.gitignore index 78d815c..217012f 100644 --- a/.gitignore +++ b/.gitignore @@ -143,3 +143,15 @@ dmypy.json .Trashes ehthumbs.db Thumbs.db + +# Server logs and runtime files +server.log +nohup.out +*.log + +# Local deployment scripts (optional - comment out if you want to commit these) +restart.sh +restart_systemd.sh +SERVERS.md + +*.json From cf8daa7be185b45ca2bef5503b490a638eace27e Mon Sep 17 00:00:00 2001 From: H4nnhoi Date: Fri, 31 Oct 2025 07:59:37 +0000 Subject: [PATCH 16/16] hotfix : error of analyzing emotion --- _schema_fix.py | 22 +++++++++++++ app/emotion_service.py | 75 +++++++++++++++++++++++++++++++++++------- app/voice_service.py | 25 +++++++++++++- restart_dev.sh | 51 ++++++++++++++++++++++++++++ 4 files changed, 161 insertions(+), 12 deletions(-) create mode 100644 _schema_fix.py create mode 100755 restart_dev.sh diff --git a/_schema_fix.py b/_schema_fix.py new file mode 100644 index 0000000..98d6e55 --- /dev/null +++ b/_schema_fix.py @@ -0,0 +1,22 @@ +from app.database import engine +from sqlalchemy import text + + +def ensure_surprise_column() -> None: + with engine.connect() as conn: + res = conn.execute(text("SHOW COLUMNS FROM voice_analyze LIKE 'surprise_bps'")) + row = res.fetchone() + if row is None: + print("Adding surprise_bps column...") + conn.execute(text("ALTER TABLE voice_analyze ADD COLUMN surprise_bps SMALLINT NOT NULL DEFAULT 0")) + print("Added surprise_bps") + else: + print("surprise_bps exists") + conn.commit() + + +if __name__ == "__main__": + ensure_surprise_column() + + + diff --git a/app/emotion_service.py b/app/emotion_service.py index 9273eb8..e9928e3 100644 --- a/app/emotion_service.py +++ b/app/emotion_service.py @@ -49,6 +49,10 @@ def analyze_emotion(self, audio_file) -> Dict[str, Any]: } try: + try: + print(f"[emotion] start analyze filename={getattr(audio_file,'filename',None)}", flush=True) + except Exception: + pass # 업로드 확장자 반영하여 임시 파일로 저장 import os orig_name = getattr(audio_file, "filename", "") or "" @@ -59,6 +63,12 @@ def analyze_emotion(self, audio_file) -> Dict[str, Any]: audio_file.file.seek(0) tmp_file.write(content) tmp_file_path = tmp_file.name + try: + import os as _os + sz = _os.path.getsize(tmp_file_path) + print(f"[emotion] tmp saved path={tmp_file_path} size={sz}", flush=True) + except Exception: + pass # 오디오 로드 (16kHz, 견고한 로더) def robust_load(path: str, target_sr: int = 16000): @@ -71,28 +81,57 @@ def robust_load(path: str, target_sr: int = 16000): if sr != target_sr: data = librosa.resample(data, orig_sr=sr, target_sr=target_sr) sr = target_sr + try: + print(f"[emotion] robust_load: backend=sf sr={sr} len={len(data)} min={float(np.min(data)):.4f} max={float(np.max(data)):.4f}", flush=True) + except Exception: + pass return data, sr except Exception: y, sr = librosa.load(path, sr=target_sr, mono=True) - return y.astype("float32"), sr + y = y.astype("float32") + try: + print(f"[emotion] robust_load: backend=librosa sr={sr} len={len(y)} min={float(np.min(y)):.4f} max={float(np.max(y)):.4f}", flush=True) + except Exception: + pass + return y, sr audio, sr = robust_load(tmp_file_path, 16000) + try: + a_min = float(np.min(audio)) if len(audio) else 0.0 + a_max = float(np.max(audio)) if len(audio) else 0.0 + print(f"[emotion] load ok file={orig_name} sr={sr} len={len(audio)} dur={len(audio)/float(sr):.3f}s range=[{a_min:.4f},{a_max:.4f}]", flush=True) + except Exception as e: + print(f"[emotion] load log err: {e}", flush=True) # 특성 추출 - inputs = self.feature_extractor( - audio, - sampling_rate=16000, - return_tensors="pt", - padding=True - ) + try: + inputs = self.feature_extractor( + audio, + sampling_rate=16000, + return_tensors="pt", + padding=True, + ) + lens = {k: tuple(v.shape) for k, v in inputs.items()} + print(f"[emotion] extract ok shapes={lens}", flush=True) + except Exception as e: + print(f"[emotion] extract error: {e}", flush=True) + raise # GPU로 이동 inputs = {k: v.to(self.device) for k, v in inputs.items()} # 추론 - with torch.no_grad(): - outputs = self.model(**inputs) - predictions = torch.nn.functional.softmax(outputs.logits, dim=-1) + try: + with torch.no_grad(): + outputs = self.model(**inputs) + logits = outputs.logits + predictions = torch.nn.functional.softmax(logits, dim=-1) + print(f"[emotion] forward ok logits_shape={tuple(logits.shape)}", flush=True) + probs = predictions[0].detach().cpu().numpy().tolist() + print(f"[emotion] probs size={len(probs)} sum={round(float(np.sum(probs)),4)} top={int(np.argmax(probs))} max={round(float(np.max(probs)),4)}", flush=True) + except Exception as e: + print(f"[emotion] forward error: {e}", flush=True) + raise # 감정 라벨 매핑: 모델 config 우선, 숫자형 값이면 사람이 읽을 수 있는 이름으로 대체 default_labels = ["neutral", "happy", "sad", "angry", "fear", "surprise"] @@ -117,6 +156,11 @@ def robust_load(path: str, target_sr: int = 16000): emotion_labels[i]: predictions[0][i].item() for i in range(min(len(emotion_labels), predictions.shape[1])) } + try: + dbg_scores = {k: round(v, 4) for k, v in list(emotion_scores.items())} + print(f"[emotion] scores={dbg_scores} top={emotion} conf={confidence:.4f}") + except Exception: + pass # 한국어 라벨 → 영어 라벨 매핑 ko2en = { @@ -141,16 +185,25 @@ def to_en(label: str) -> str: emotion_en = to_en(emotion) emotion_scores_en = {to_en(k): v for k, v in emotion_scores.items()} + # 모델 버전 표기(추적용) + model_version = None + try: + model_version = getattr(self.model.config, "name_or_path", None) or "unknown" + except Exception: + model_version = "unknown" + return { "emotion": emotion_en, # 대표 감정 (영문) "top_emotion": emotion_en, # 동일 표기(영문) "confidence": confidence, # 대표 감정 확률 "emotion_scores": emotion_scores_en, # 영문 라벨명→확률 "audio_duration": len(audio) / sr, - "sample_rate": sr + "sample_rate": sr, + "model_version": model_version, } except Exception as e: + print(f"[emotion] analyze error: {e} filename={getattr(audio_file,'filename',None)}") return { "error": f"분석 중 오류 발생: {str(e)}", "emotion": "unknown", diff --git a/app/voice_service.py b/app/voice_service.py index d6ffd07..604741a 100644 --- a/app/voice_service.py +++ b/app/voice_service.py @@ -157,6 +157,16 @@ def __init__(self, content, filename): emotion_file = TempUploadFile(file_obj, filename) result = analyze_voice_emotion(emotion_file) + # 디버그 로그: 전체 결과 요약 + try: + top_em = result.get('top_emotion') or result.get('emotion') + conf = result.get('confidence') + mv = result.get('model_version') + em_scores = result.get('emotion_scores') or {} + print(f"[emotion] result voice_id={voice_id} top={top_em} conf={conf} model={mv} scores={{{k: round(float(v),4) for k,v in em_scores.items()}}}", flush=True) + except Exception: + pass + def to_bps(v: float) -> int: try: return max(0, min(10000, int(round(float(v) * 10000)))) @@ -176,6 +186,8 @@ def to_bps(v: float) -> int: top_conf = result.get("top_confidence") or result.get("confidence", 0) top_conf_bps = to_bps(top_conf) model_version = result.get("model_version") + if isinstance(model_version, str) and len(model_version) > 32: + model_version = model_version[:32] total_raw = happy + sad + neutral + angry + fear + surprise print(f"[voice_analyze] ROUND 이전: happy={happy}, sad={sad}, neutral={neutral}, angry={angry}, fear={fear}, surprise={surprise} → 합계={total_raw}") @@ -210,6 +222,16 @@ def to_bps(v: float) -> int: vals["happy"], vals["sad"], vals["neutral"], vals["angry"], vals["fear"], vals["surprise"] ) + # DB 저장 직전 값 로깅 + try: + print( + f"[voice_analyze] to_db voice_id={voice_id} " + f"vals={{'happy': {happy}, 'sad': {sad}, 'neutral': {neutral}, 'angry': {angry}, 'fear': {fear}, 'surprise': {surprise}}} " + f"top={top_emotion} conf_bps={top_conf_bps} model={model_version}" + ) + except Exception: + pass + self.db_service.create_voice_analyze( voice_id=voice_id, happy_bps=happy, @@ -222,8 +244,9 @@ def to_bps(v: float) -> int: top_confidence_bps=top_conf_bps, model_version=model_version, ) + print(f"[voice_analyze] saved voice_id={voice_id} top={top_emotion} conf_bps={top_conf_bps}", flush=True) except Exception as e: - print(f"Audio emotion background error: {e}") + print(f"Audio emotion background error: {e}", flush=True) def get_user_voice_list(self, username: str) -> Dict[str, Any]: """ diff --git a/restart_dev.sh b/restart_dev.sh new file mode 100755 index 0000000..d136dd1 --- /dev/null +++ b/restart_dev.sh @@ -0,0 +1,51 @@ +#!/bin/bash + +set -e + +echo "🔄 Caring Voice API 서버 재시작(개발 모드, reload 포함)" + +PROJECT_DIR="/home/ubuntu/caring-voice" +cd $PROJECT_DIR + +echo "🛑 기존 uvicorn 프로세스 종료" +pkill -f "uvicorn app.main:app" || true +sleep 1 + +echo "📦 가상환경 활성화" +source venv/bin/activate + +if [ ! -f .env ]; then + echo "❌ .env 파일이 없습니다!" + exit 1 +fi + +echo "🚀 서버 시작 중 (dev, reload)" +nohup uvicorn app.main:app \ + --host 0.0.0.0 --port 8000 \ + --reload \ + --reload-include '*.py' \ + --reload-include '*.yaml' --reload-include '*.yml' \ + --reload-exclude 'venv/*' \ + --reload-exclude 'site-packages/*' \ + --reload-exclude 'botocore/*' \ + > server.log 2>&1 & +SERVER_PID=$! + +echo "⏳ 서버 시작 대기" +sleep 3 + +if ps -p $SERVER_PID > /dev/null; then + echo "✅ 서버 실행 중 (PID: $SERVER_PID)" + if curl -s http://localhost:8000/health > /dev/null; then + echo "✅ 헬스체크 OK" + else + echo "⚠️ 실행 중이나 응답 없음" + fi +else + echo "❌ 서버 시작 실패" + tail -n 100 server.log || true + exit 1 +fi + + +