diff --git a/.gitignore b/.gitignore index 63aa4b5..217012f 100644 --- a/.gitignore +++ b/.gitignore @@ -155,4 +155,3 @@ restart_systemd.sh SERVERS.md *.json - 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/_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/care_service.py b/app/care_service.py new file mode 100644 index 0000000..a3b542b --- /dev/null +++ b/app/care_service.py @@ -0,0 +1,103 @@ +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): + 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)}"} + + 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/db_service.py b/app/db_service.py index 687d1c6..0d29a5c 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, @@ -58,6 +62,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_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.username == username) + .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 키로 음성 파일 조회""" @@ -69,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]: """전체 음성 파일 목록 조회""" @@ -126,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: """음성 감정 분석 데이터 생성""" @@ -137,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 @@ -151,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]: """음성 감정 분석 결과 업데이트""" @@ -162,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: @@ -229,6 +274,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 a048ab4..fb9781b 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 @@ -74,6 +75,38 @@ 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 + top_emotion: Optional[str] = None + created_at: str + 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..e9928e3 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,33 +49,102 @@ def analyze_emotion(self, audio_file) -> Dict[str, Any]: } try: - # 임시 파일로 저장 - with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp_file: + 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 "" + _, 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 + 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로 리샘플링) - 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 + 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) + 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 - # 감정 라벨 (모델에 따라 조정 필요) - 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,19 +153,57 @@ 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])) } + 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 = { + "중립": "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()} + + # 모델 버전 표기(추적용) + model_version = None + try: + model_version = getattr(self.model.config, "name_or_path", None) or "unknown" + except Exception: + model_version = "unknown" + 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 + "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/main.py b/app/main.py index 961e9b0..1311a7b 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 @@ -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 ( @@ -17,108 +17,85 @@ SigninRequest, SigninResponse, UserVoiceUploadRequest, UserVoiceUploadResponse, VoiceQuestionUploadResponse, - UserVoiceListResponse, + UserVoiceListResponse, UserVoiceDetailResponse, + CareUserVoiceListResponse, EmotionAnalysisResponse, TranscribeResponse, - SentimentResponse, EntitiesResponse, SyntaxResponse, ComprehensiveAnalysisResponse + SentimentResponse, EntitiesResponse, SyntaxResponse, ComprehensiveAnalysisResponse, + VoiceAnalyzePreviewResponse ) +from .care_service import CareService +import random 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"]) +questions_router = APIRouter(prefix="/questions", tags=["questions"]) +# 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, @@ -127,7 +104,6 @@ async def sign_up(request: SignupRequest): role=request.role, connecting_user_code=request.connecting_user_code ) - if result["success"]: return SignupResponse( message="회원가입이 완료되었습니다.", @@ -139,20 +115,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="로그인 성공", @@ -163,58 +134,49 @@ 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", [])) + +@users_router.get("/voices/{voice_id}", response_model=UserVoiceDetailResponse) +async def get_user_voice_detail(voice_id: int, username: str): + db = next(get_db()) + voice_service = get_voice_service(db) + 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( + voice_id=voice_id, + title=result.get("title"), + top_emotion=result.get("top_emotion"), + created_at=result.get("created_at", ""), + voice_content=result.get("voice_content"), ) +@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) + 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) +@users_router.post("/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"]: return VoiceQuestionUploadResponse( success=True, @@ -225,180 +187,83 @@ 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): + 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") +@care_router.get("/users/voices/analyzing/frequency") +async def get_emotion_monthly_frequency( + care_username: str, month: str ): - """음성 파일을 업로드하고 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() - - # 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 - } - - -# 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("/") - - 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)} - - -# 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" + """ + 보호자 페이지: 연결된 유저의 한달간 감정 빈도수 집계 (CareService 내부 로직 사용) + """ + db = next(get_db()) + 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 ): - """음성 파일을 텍스트로 변환합니다.""" - stt_result = transcribe_voice(file, language_code) - return stt_result - + """보호자페이지 - 연결유저 월/주차별 요일 top 감정 통계""" + db = next(get_db()) + care_service = CareService(db) + return care_service.get_emotion_weekly_summary(care_username, month, week) -# POST : analyze text sentiment using Google NLP -@app.post("/nlp/sentiment") -async def analyze_sentiment( - text: str, - language_code: str = "ko" -): - """텍스트의 감정을 분석합니다.""" +# ============== 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 - -# POST : extract entities from text using Google NLP -@app.post("/nlp/entities") -async def extract_entities( - text: str, - language_code: str = "ko" -): - """텍스트에서 엔티티를 추출합니다.""" +@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 - -# POST : analyze text syntax using Google NLP -@app.post("/nlp/syntax") -async def analyze_syntax( - text: str, - language_code: str = "ko" -): - """텍스트의 구문을 분석합니다.""" +@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 - -# POST : comprehensive text analysis using Google NLP -@app.post("/nlp/analyze") -async def analyze_text_comprehensive( - text: str, - language_code: str = "ko" -): - """텍스트의 감정, 엔티티, 구문을 종합 분석합니다.""" +@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, @@ -406,3 +271,74 @@ async def analyze_text_comprehensive( "entity_analysis": entities_result, "syntax_analysis": syntax_result } + +# ============== test 영역 ============= +@test_router.post("/voice/analyze", response_model=VoiceAnalyzePreviewResponse) +async def test_emotion_analyze(file: UploadFile = File(...)): + 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 = 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)}") + +# ---------------- 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) +app.include_router(questions_router) 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/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 7fca548..604741a 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,111 @@ 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) + + # 디버그 로그: 전체 결과 요약 + 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)))) + except Exception: + return 0 + + 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)) + 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))) + + # 모델 응답 키 보정: 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") + 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}") + 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)), + "neutral": int(round(neutral * scale)), + "angry": int(round(angry * scale)), + "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"] + ) + + # 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, + 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=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}", flush=True) def get_user_voice_list(self, username: str) -> Dict[str, Any]: """ @@ -185,6 +292,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, @@ -201,6 +309,68 @@ def get_user_voice_list(self, username: str) -> Dict[str, Any]: "success": False, "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: + 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"} + + 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"} + + 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]: """ @@ -266,8 +436,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) 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 + + +