diff --git a/GLANCES_USAGE.md b/GLANCES_USAGE.md new file mode 100644 index 0000000..73aa27c --- /dev/null +++ b/GLANCES_USAGE.md @@ -0,0 +1,94 @@ +# Glances 사용 가이드 + +## 설치 완료 +glances 3.4.0.3 버전이 설치되었습니다. + +## 기본 사용법 + +### 터미널에서 실시간 모니터링 +```bash +glances +``` + +### 웹 서버 모드로 실행 (원격 접속 가능) +```bash +# 기본 포트(61208)로 실행 +glances -w + +# 특정 포트 지정 +glances -w -p 61208 + +# 특정 IP에서만 접속 허용 (보안 강화) +glances -w -B 0.0.0.0 -p 61208 +``` + +웹 브라우저에서 접속: `http://서버IP:61208` + +### RESTful API 모드 +```bash +glances -s +``` + +### CPU, 메모리, 디스크, 네트워크만 간단히 보기 +```bash +glances --percpu +``` + +## 주요 단축키 + +- `q` 또는 `ESC`: 종료 +- `h`: 도움말 +- `c`: CPU 정보 표시/숨김 +- `m`: 메모리 정보 표시/숨김 +- `d`: 디스크 정보 표시/숨김 +- `n`: 네트워크 정보 표시/숨김 +- `p`: 프로세스 정렬 변경 +- `w`: 경고 삭제 +- `x`: 경고/중요 임계값 삭제 + +## 서비스로 실행 + +glances가 systemd 서비스로 자동 등록되어 있습니다. + +```bash +# 서비스 시작 +sudo systemctl start glances + +# 서비스 중지 +sudo systemctl stop glances + +# 서비스 상태 확인 +sudo systemctl status glances + +# 부팅 시 자동 시작 활성화 +sudo systemctl enable glances +``` + +## 백그라운드 실행 + +```bash +# nohup으로 백그라운드 실행 +nohup glances -w > /dev/null 2>&1 & + +# tmux/screen 사용 +tmux new-session -d -s monitoring 'glances' +``` + +## 유용한 옵션 + +- `--refresh 2`: 2초마다 갱신 (기본값: 3초) +- `--disable-plugin docker`: Docker 플러그인 비활성화 +- `--enable-plugin docker`: Docker 플러그인 활성화 +- `--percpu`: CPU 코어별 사용량 표시 +- `--process-short-name`: 짧은 프로세스 이름 표시 +- `--time`: 시간 표시 + +## 예시: 웹 모드로 백그라운드 실행 + +```bash +# 웹 서버 모드로 백그라운드 실행 +glances -w -B 0.0.0.0 -p 61208 & +``` + +브라우저에서 `http://서버IP:61208` 접속하여 모니터링 가능합니다. + diff --git a/app/dto.py b/app/dto.py index c485197..cea7476 100644 --- a/app/dto.py +++ b/app/dto.py @@ -239,3 +239,28 @@ class ErrorResponse(BaseModel): class SuccessResponse(BaseModel): message: str status: str = "success" + + +# OpenAI 분석 결과 DTO +class AnalysisResultResponse(BaseModel): + """OpenAI 종합분석 결과 응답""" + source: str # weekly | frequency + message: str + + +class WeeklyDayItem(BaseModel): + date: str + weekday: str + top_emotion: Optional[str] = None + + +class WeeklyAnalysisCombinedResponse(BaseModel): + """주간 종합분석: OpenAI 메시지 + 기존 주간 요약""" + message: str + weekly: List[WeeklyDayItem] + + +class FrequencyAnalysisCombinedResponse(BaseModel): + """월간 빈도 종합분석: OpenAI 메시지 + 기존 빈도 결과""" + message: str + frequency: dict diff --git a/app/main.py b/app/main.py index e5a65e1..df9d594 100644 --- a/app/main.py +++ b/app/main.py @@ -27,7 +27,8 @@ UserInfoResponse, CareInfoResponse, FcmTokenRegisterRequest, FcmTokenRegisterResponse, FcmTokenDeactivateResponse, NotificationListResponse, - TopEmotionResponse, CareTopEmotionResponse + TopEmotionResponse, CareTopEmotionResponse, + AnalysisResultResponse, WeeklyAnalysisCombinedResponse, FrequencyAnalysisCombinedResponse ) from .care_service import CareService import random @@ -361,24 +362,35 @@ async def upload_voice_with_question( else: raise HTTPException(status_code=400, detail=result["message"]) -@users_router.get("/voices/analyzing/frequency") +@users_router.get("/voices/analyzing/frequency", response_model=FrequencyAnalysisCombinedResponse) async def get_user_emotion_frequency(username: str, month: str, db: Session = Depends(get_db)): - """사용자 본인의 한달간 감정 빈도수 집계""" - voice_service = get_voice_service(db) - result = voice_service.get_user_emotion_monthly_frequency(username, month) - if not result.get("success"): - raise HTTPException(status_code=400, detail=result.get("message", "조회 실패")) - return result + """사용자 본인의 월간 빈도 종합분석(OpenAI 캐시 + 기존 빈도 결과)""" + from .services.analysis_service import get_frequency_result + try: + message = get_frequency_result(db, username=username, is_care=False) + voice_service = get_voice_service(db) + base = voice_service.get_user_emotion_monthly_frequency(username, month) + frequency = base.get("frequency", {}) if base.get("success") else {} + return FrequencyAnalysisCombinedResponse(message=message, frequency=frequency) + except Exception as e: + raise HTTPException(status_code=400, detail=f"분석 실패: {str(e)}") -@users_router.get("/voices/analyzing/weekly") +@users_router.get("/voices/analyzing/weekly", response_model=WeeklyAnalysisCombinedResponse) async def get_user_emotion_weekly(username: str, month: str, week: int, db: Session = Depends(get_db)): - """사용자 본인의 월/주차별 요일별 top 감정 요약""" - voice_service = get_voice_service(db) - result = voice_service.get_user_emotion_weekly_summary(username, month, week) + """사용자 본인의 주간 종합분석(OpenAI 캐시 사용)""" + from .services.analysis_service import get_weekly_result + try: + message = get_weekly_result(db, username=username, is_care=False) + # 기존 주간 요약도 함께 제공 + voice_service = get_voice_service(db) + weekly_result = voice_service.get_user_emotion_weekly_summary(username, month, week) + weekly = weekly_result.get("weekly", []) if weekly_result.get("success") else [] + return WeeklyAnalysisCombinedResponse(message=message, weekly=weekly) + except Exception as e: + raise HTTPException(status_code=400, detail=f"분석 실패: {str(e)}") - if not result.get("success"): - raise HTTPException(status_code=400, detail=result.get("message", "조회 실패")) - return result + + @users_router.get("/top_emotion", response_model=TopEmotionResponse) @@ -529,26 +541,49 @@ async def get_care_user_voice_list( result = voice_service.get_care_voice_list(care_username, date=date) return CareUserVoiceListResponse(success=result["success"], voices=result.get("voices", [])) -@care_router.get("/users/voices/analyzing/frequency") +@care_router.get("/users/voices/analyzing/frequency", response_model=FrequencyAnalysisCombinedResponse) async def get_emotion_monthly_frequency( care_username: str, month: str, db: Session = Depends(get_db) ): - """ - 보호자 페이지: 연결된 유저의 한달간 감정 빈도수 집계 (CareService 내부 로직 사용) - """ - care_service = CareService(db) - return care_service.get_emotion_monthly_frequency(care_username, month) + """보호자: 연결 유저의 월간 빈도 종합분석(OpenAI 캐시 + 기존 빈도 결과)""" + from .services.analysis_service import get_frequency_result + try: + message = get_frequency_result(db, username=care_username, is_care=True) + from .care_service import CareService + care_service = CareService(db) + base = care_service.get_emotion_monthly_frequency(care_username, month) + frequency = base.get("frequency", {}) if base.get("success") else {} + return FrequencyAnalysisCombinedResponse(message=message, frequency=frequency) + except Exception as e: + raise HTTPException(status_code=400, detail=f"분석 실패: {str(e)}") -@care_router.get("/users/voices/analyzing/weekly") + + + + + + +@care_router.get("/users/voices/analyzing/weekly", response_model=WeeklyAnalysisCombinedResponse) async def get_emotion_weekly_summary( care_username: str, month: str, week: int, db: Session = Depends(get_db) ): - """보호자페이지 - 연결유저 월/주차별 요일 top 감정 통계""" - care_service = CareService(db) - return care_service.get_emotion_weekly_summary(care_username, month, week) + """보호자: 연결 유저의 주간 종합분석(OpenAI 캐시 사용)""" + from .services.analysis_service import get_weekly_result + try: + message = get_weekly_result(db, username=care_username, is_care=True) + # 기존 주간 요약도 함께 제공 + care_service = CareService(db) + weekly_result = care_service.get_emotion_weekly_summary(care_username, month, week) + weekly = weekly_result.get("weekly", []) if weekly_result.get("success") else [] + return WeeklyAnalysisCombinedResponse(message=message, weekly=weekly) + except Exception as e: + raise HTTPException(status_code=400, detail=f"분석 실패: {str(e)}") + + + @care_router.get("/notifications", response_model=NotificationListResponse) async def get_care_notifications(care_username: str, db: Session = Depends(get_db)): @@ -800,6 +835,22 @@ async def test_error(statusCode: int): detail=f"Invalid statusCode: {statusCode}. Only 400 or 500 are allowed." ) + +@test_router.post("/fcm/send") +async def test_fcm_send( + token: Optional[str] = None, + title: str = "Test Title", + body: str = "Test Body", + db: Session = Depends(get_db) +): + """단일 토큰으로 FCM 테스트 전송 (SDK에서 발급받은 토큰 사용)""" + if not token: + raise HTTPException(status_code=400, detail="token is required") + from .services.fcm_service import FcmService + svc = FcmService(db) + result = svc.send_notification_to_tokens([token], title, body) + return {"success": True, "result": result} + # ---------------- router 등록 ---------------- app.include_router(users_router) app.include_router(care_router) diff --git a/app/models.py b/app/models.py index cc2e9dd..1b8c072 100644 --- a/app/models.py +++ b/app/models.py @@ -237,3 +237,39 @@ class Notification(Base): Index('idx_notification_voice', 'voice_id'), Index('idx_notification_created', 'created_at'), ) + + +class WeeklyResult(Base): + """주간 OpenAI 종합분석 캐시""" + __tablename__ = "weekly_result" + + weekly_result_id = Column(BigInteger, primary_key=True, autoincrement=True) + user_id = Column(BigInteger, ForeignKey("user.user_id", ondelete="CASCADE"), nullable=False) + latest_voice_composite_id = Column(BigInteger, ForeignKey("voice_composite.voice_composite_id", ondelete="CASCADE"), nullable=True) + message = Column(Text, nullable=False) + created_at = Column(DateTime, nullable=False, server_default=func.current_timestamp()) + updated_at = Column(DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp()) + + __table_args__ = ( + Index('idx_weekly_user', 'user_id'), + Index('idx_weekly_latest_vc', 'latest_voice_composite_id'), + UniqueConstraint('user_id', name='uq_weekly_user'), + ) + + +class FrequencyResult(Base): + """월간 빈도 OpenAI 종합분석 캐시""" + __tablename__ = "frequency_result" + + frequency_result_id = Column(BigInteger, primary_key=True, autoincrement=True) + user_id = Column(BigInteger, ForeignKey("user.user_id", ondelete="CASCADE"), nullable=False) + latest_voice_composite_id = Column(BigInteger, ForeignKey("voice_composite.voice_composite_id", ondelete="CASCADE"), nullable=True) + message = Column(Text, nullable=False) + created_at = Column(DateTime, nullable=False, server_default=func.current_timestamp()) + updated_at = Column(DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp()) + + __table_args__ = ( + Index('idx_freq_user', 'user_id'), + Index('idx_freq_latest_vc', 'latest_voice_composite_id'), + UniqueConstraint('user_id', name='uq_freq_user'), + ) diff --git a/app/services/analysis_service.py b/app/services/analysis_service.py new file mode 100644 index 0000000..e8e75f1 --- /dev/null +++ b/app/services/analysis_service.py @@ -0,0 +1,228 @@ +import os +from datetime import datetime, timedelta +from collections import Counter, defaultdict +from typing import List, Dict, Optional +from sqlalchemy.orm import Session +from ..models import Voice, VoiceComposite, User, WeeklyResult, FrequencyResult + + +def _get_openai_client(): + """OpenAI 클라이언트 생성 (env에서 키 로드)""" + from openai import OpenAI + api_key = os.getenv("OPENAI_API_KEY") + if not api_key: + raise RuntimeError("OPENAI_API_KEY not configured in environment") + return OpenAI(api_key=api_key) + + +def _call_openai(messages: List[Dict[str, str]], model: Optional[str] = None) -> str: + """Chat Completions 호출 래퍼""" + client = _get_openai_client() + use_model = model or os.getenv("OPENAI_MODEL", "gpt-4o-mini") + resp = client.chat.completions.create( + model=use_model, + messages=messages, + temperature=0.7, + max_tokens=400, + ) + return (resp.choices[0].message.content or "").strip() + + +def _query_weekly_top_emotions(session: Session, user_id: int) -> Dict[str, List[str]]: + """최근 7일간 날짜별 top_emotion 목록 조회 (YYYY-MM-DD -> [emotion,...])""" + end_dt = datetime.now() + start_dt = end_dt - timedelta(days=6) + q = ( + session.query(Voice, VoiceComposite) + .join(VoiceComposite, Voice.voice_id == VoiceComposite.voice_id) + .filter( + Voice.user_id == user_id, + Voice.created_at >= datetime.combine(start_dt.date(), datetime.min.time()), + Voice.created_at <= datetime.combine(end_dt.date(), datetime.max.time()), + ) + .order_by(Voice.created_at.asc()) + ) + by_day: Dict[str, List[str]] = defaultdict(list) + for v, vc in q: + day = v.created_at.date().strftime("%Y-%m-%d") if v.created_at else None + if not day: + continue + em = (vc.top_emotion or "unknown") if vc else "unknown" + by_day[day].append(em) + return dict(by_day) + + +def _query_month_emotion_counts(session: Session, user_id: int) -> Dict[str, int]: + """이번 달의 emotion 빈도수 (voice_composite.top_emotion 기준)""" + now = datetime.now() + start = datetime(now.year, now.month, 1) + # 다음 달 1일 + if now.month == 12: + next_month = datetime(now.year + 1, 1, 1) + else: + next_month = datetime(now.year, now.month + 1, 1) + + q = ( + session.query(Voice, VoiceComposite) + .join(VoiceComposite, Voice.voice_id == VoiceComposite.voice_id) + .filter( + Voice.user_id == user_id, + Voice.created_at >= start, + Voice.created_at < next_month, + ) + ) + cnt = Counter() + for v, vc in q: + em = (vc.top_emotion or "unknown") if vc else "unknown" + cnt[em] += 1 + return dict(cnt) + + +def _build_weekly_prompt(user_name: str, by_day: Dict[str, List[str]]) -> List[Dict[str, str]]: + """주간 분석 프롬프트 구성""" + lines = [f"대상 사용자: {user_name}"] + if not by_day: + lines.append("최근 7일 동안 감정 분석 데이터가 없습니다.") + else: + lines.append("최근 7일 간 날짜별 대표 감정 목록입니다.") + for day in sorted(by_day.keys()): + vals = ", ".join(by_day[day]) if by_day[day] else "(없음)" + lines.append(f"- {day}: {vals}") + system = { + "role": "system", + "content": ( + "너는 노년층 혹은 장애인 케어 서비스의 감정 코치다. 한국어로 공감적이고 간결하게, 1~3문장으로 " + "주간 추세를 요약해라. 데이터가 없거나 매우 적은 경우에는 그 사실을 명확히 언급하고, " + "추측하지 말고 관찰적인 표현만 사용해라. 과장 없이 관찰 중심으로 서술하고, 조언은 최소화한다." + ), + } + user = { + "role": "user", + "content": ( + "다음 날짜별 감정 목록을 바탕으로 주간 감정 추세 한 문단(1~3문장)으로 요약해줘. " + "데이터가 없으면 '최근 7일 동안 감정 분석 데이터가 없었습니다'처럼 명확히 알려줘.\n" + "\n".join(lines) + ), + } + return [system, user] + + +def _build_frequency_prompt(user_name: str, counts: Dict[str, int]) -> List[Dict[str, str]]: + """월간 빈도수 분석 프롬프트 구성""" + items = ", ".join([f"{k}:{v}" for k, v in sorted(counts.items())]) if counts else "(데이터 없음)" + system = { + "role": "system", + "content": ( + "너는 노년층 혹은 장애인 케어 서비스의 감정 코치다. 한국어로 공감적이고 간결하게, 1~3문장으로 " + "월간 감정 빈도 특성을 요약해라. 데이터가 없거나 매우 적은 경우에는 그 사실을 명확히 언급하고, " + "추측하지 말고 관찰적인 표현만 사용해라. 감정이 일부 확인되면 '일부 확인'처럼 신중한 표현을 사용해라." + ), + } + user = { + "role": "user", + "content": ( + f"대상 사용자: {user_name}\n이 달의 대표 감정 빈도수는 다음과 같아: {items}. " + "월간 감정 경향을 1~3문장으로 요약해줘. 데이터가 없으면 '이번 달에는 감정 분석 데이터가 없었습니다'처럼 명확히 알려줘." + ), + } + return [system, user] + + +def get_weekly_result(session: Session, username: str, is_care: bool = False) -> str: + """주간 종합분석 결과 메시지 생성""" + from ..auth_service import get_auth_service + auth = get_auth_service(session) + owner = auth.get_user_by_username(username) + if not owner: + raise ValueError("user not found") + + # care인 경우 연결된 유저로 전환 + target_user = owner + if is_care: + if owner.role != 'CARE' or not owner.connecting_user_code: + raise ValueError("invalid care user or not connected") + target_user = auth.get_user_by_username(owner.connecting_user_code) + if not target_user: + raise ValueError("connected user not found") + + # 최신 voice_composite_id 조회 + latest_vc = ( + session.query(VoiceComposite.voice_composite_id) + .join(Voice, Voice.voice_id == VoiceComposite.voice_id) + .filter(Voice.user_id == target_user.user_id) + .order_by(VoiceComposite.created_at.desc()) + .first() + ) + latest_vc_id = latest_vc[0] if latest_vc else None + + # 캐시 조회 + cache = session.query(WeeklyResult).filter(WeeklyResult.user_id == target_user.user_id).first() + if cache and cache.latest_voice_composite_id == latest_vc_id: + return cache.message + + # 생성 후 캐시 저장/갱신 + by_day = _query_weekly_top_emotions(session, target_user.user_id) + messages = _build_weekly_prompt(target_user.name, by_day) + msg = _call_openai(messages) + + if cache: + cache.message = msg + cache.latest_voice_composite_id = latest_vc_id + else: + session.add(WeeklyResult( + user_id=target_user.user_id, + latest_voice_composite_id=latest_vc_id, + message=msg, + )) + session.commit() + return msg + + +def get_frequency_result(session: Session, username: str, is_care: bool = False) -> str: + """월간 빈도 종합분석 결과 메시지 생성""" + from ..auth_service import get_auth_service + auth = get_auth_service(session) + owner = auth.get_user_by_username(username) + if not owner: + raise ValueError("user not found") + + target_user = owner + if is_care: + if owner.role != 'CARE' or not owner.connecting_user_code: + raise ValueError("invalid care user or not connected") + target_user = auth.get_user_by_username(owner.connecting_user_code) + if not target_user: + raise ValueError("connected user not found") + + # 최신 voice_composite_id 조회 + latest_vc = ( + session.query(VoiceComposite.voice_composite_id) + .join(Voice, Voice.voice_id == VoiceComposite.voice_id) + .filter(Voice.user_id == target_user.user_id) + .order_by(VoiceComposite.created_at.desc()) + .first() + ) + latest_vc_id = latest_vc[0] if latest_vc else None + + # 캐시 조회 + cache = session.query(FrequencyResult).filter(FrequencyResult.user_id == target_user.user_id).first() + if cache and cache.latest_voice_composite_id == latest_vc_id: + return cache.message + + # 생성 후 캐시 저장/갱신 + counts = _query_month_emotion_counts(session, target_user.user_id) + messages = _build_frequency_prompt(target_user.name, counts) + msg = _call_openai(messages) + + if cache: + cache.message = msg + cache.latest_voice_composite_id = latest_vc_id + else: + session.add(FrequencyResult( + user_id=target_user.user_id, + latest_voice_composite_id=latest_vc_id, + message=msg, + )) + session.commit() + return msg + + diff --git a/app/services/fcm_service.py b/app/services/fcm_service.py index 62c7bbc..1d13185 100644 --- a/app/services/fcm_service.py +++ b/app/services/fcm_service.py @@ -161,6 +161,16 @@ def _send_multicast( "total_count": len(fcm_tokens), "error": str(e) } + + def send_notification_to_tokens( + self, + tokens: List[str], + title: str, + body: str, + data: Optional[Dict[str, str]] = None + ) -> Dict[str, int]: + """원시 토큰 배열로 테스트 전송""" + return self._send_multicast(tokens, title, body, data) def _deactivate_invalid_tokens(self, invalid_tokens: List[str]): """만료되거나 유효하지 않은 토큰 비활성화""" diff --git a/app/stt_service.py b/app/stt_service.py index c3aa2fa..89e49c8 100644 --- a/app/stt_service.py +++ b/app/stt_service.py @@ -35,7 +35,7 @@ def _initialize_client(self): print(f"Google STT 클라이언트 초기화 실패: {e}") self.client = None - def transcribe_audio(self, audio_file, language_code: str = "ko-KR") -> Dict[str, Any]: + def transcribe_audio(self, audio_file, language_code: str = "ko-KR", timeout_seconds: Optional[float] = None) -> Dict[str, Any]: """ 음성 파일을 텍스트로 변환합니다. @@ -101,7 +101,8 @@ def robust_load(path: str, target_sr: int = 16000): ) # STT 요청 실행 - response = self.client.recognize(config=config, audio=audio) + # STT 요청 실행 (타임아웃 적용) + response = self.client.recognize(config=config, audio=audio, timeout=timeout_seconds) # 결과 처리 if response.results: @@ -141,6 +142,6 @@ def robust_load(path: str, target_sr: int = 16000): stt_service = GoogleSTTService() -def transcribe_voice(audio_file, language_code: str = "ko-KR") -> Dict[str, Any]: +def transcribe_voice(audio_file, language_code: str = "ko-KR", timeout_seconds: Optional[float] = None) -> Dict[str, Any]: """음성을 텍스트로 변환하는 함수""" - return stt_service.transcribe_audio(audio_file, language_code) + return stt_service.transcribe_audio(audio_file, language_code, timeout_seconds) diff --git a/app/voice_service.py b/app/voice_service.py index eb7a189..84d1015 100644 --- a/app/voice_service.py +++ b/app/voice_service.py @@ -48,50 +48,74 @@ def _convert_to_wav(self, file_content: bytes, original_filename: str) -> Tuple[ print(f"[convert] 입력 파일이 너무 작음: {len(file_content)} bytes, librosa로 폴백") # 바로 librosa로 폴백 (아래 코드로 계속 진행) - # 방법 1: ffmpeg subprocess 직접 사용 (가장 빠름, stdin/stdout 파이프) + # 방법 1: ffmpeg 파일 입력 방식 (컨테이너 분석 강화) if len(file_content) >= 100: # 충분한 크기일 때만 시도 + tmp_in = None + tmp_out = None try: + import shutil, os + ffmpeg_bin = os.getenv('FFMPEG_PATH') or shutil.which('ffmpeg') or '/usr/bin/ffmpeg' + print(f"[convert] using ffmpeg_bin={ffmpeg_bin}") + + # 입력 임시파일 생성 (TMPDIR=/dev/shm 적용됨) + tmp_in = tempfile.NamedTemporaryFile(suffix=f'.{ext}', delete=False) + tmp_in.write(file_content) + tmp_in.flush() + tmp_in_path = tmp_in.name + tmp_in.close() + + tmp_out = tempfile.NamedTemporaryFile(suffix='.wav', delete=False) + tmp_out_path = tmp_out.name + tmp_out.close() + ffmpeg_cmd = [ - 'ffmpeg', - '-i', 'pipe:0', # stdin에서 입력 - '-f', 'wav', # WAV 형식 - '-ar', '16000', # 16kHz 샘플링 레이트 - '-ac', '1', # 모노 (1채널) - '-acodec', 'pcm_s16le', # 16-bit PCM - '-loglevel', 'error', # 에러만 출력 - '-y', # 덮어쓰기 - 'pipe:1' # stdout으로 출력 + ffmpeg_bin, + '-hide_banner', '-loglevel', 'error', + '-probesize', '5M', '-analyzeduration', '10M', ] - + # m4a/mp4 류는 포맷 힌트 제공 + if ext in {'m4a', 'mp4', '3gp', '3g2', 'mov'}: + ffmpeg_cmd += ['-f', 'mp4'] + ffmpeg_cmd += [ + '-i', tmp_in_path, + '-vn', '-sn', + '-acodec', 'pcm_s16le', '-ar', '16000', '-ac', '1', + '-y', tmp_out_path, + ] + process = subprocess.run( ffmpeg_cmd, - input=file_content, stdout=subprocess.PIPE, stderr=subprocess.PIPE, - timeout=30, # 타임아웃 30초 - check=False + timeout=30, + check=False, ) - + # 출력 데이터 유효성 검사 - output_size = len(process.stdout) if process.stdout else 0 - - # WAV 파일은 최소 헤더(44 bytes) + 데이터 필요 - # 16kHz 모노 기준 0.1초도 약 3.2KB 필요 (16kHz * 2 bytes * 0.1초 = 3.2KB) - if process.returncode == 0 and process.stdout and output_size > 3200: - # 추가 검증: WAV 헤더 확인 (RIFF 헤더) - if process.stdout[:4] == b'RIFF' and process.stdout[8:12] == b'WAVE': - print(f"[convert] ffmpeg success: input={len(file_content)} bytes, output={output_size} bytes") - return process.stdout, wav_filename - else: - print(f"[convert] ffmpeg output invalid WAV header, falling back to librosa") + wav_bytes = b'' + try: + with open(tmp_out_path, 'rb') as f: + wav_bytes = f.read() + except Exception: + wav_bytes = b'' + + output_size = len(wav_bytes) + if process.returncode == 0 and output_size > 3200 and wav_bytes[:4] == b'RIFF' and wav_bytes[8:12] == b'WAVE': + print(f"[convert] ffmpeg success: input={len(file_content)} bytes, output={output_size} bytes") + return wav_bytes, wav_filename else: - # ffmpeg 실패 또는 출력이 너무 작음 - stderr_msg = process.stderr.decode('utf-8', errors='ignore')[:500] if process.stderr else "unknown" + stderr_msg = process.stderr.decode('utf-8', errors='ignore')[:500] if process.stderr else 'unknown' print(f"[convert] ffmpeg failed or invalid output (returncode={process.returncode}, input={len(file_content)} bytes, output={output_size} bytes)") print(f"[convert] stderr: {stderr_msg[:200]}") except (subprocess.TimeoutExpired, FileNotFoundError, Exception) as e: - # ffmpeg가 없거나 실패 시 기존 방식으로 폴백 - print(f"[convert] ffmpeg not available or failed ({type(e).__name__}): {str(e)[:200]}, using librosa fallback") + print(f"[convert] ffmpeg not available or failed ({type(e).__name__}): {str(e)[:200]}, using librosa fallback (bin={locals().get('ffmpeg_bin','n/a')})") + finally: + for p in (tmp_in, tmp_out): + if p and hasattr(p, 'name'): + try: + os.unlink(p.name) + except Exception: + pass # 방법 2: 기존 librosa 방식 (폴백) tmp_input = None @@ -250,6 +274,7 @@ async def _process_stt_and_nlp_background(self, file_content: bytes, filename: s db = SessionLocal() try: logger.log_step("(비동기 작업) STT 작업 시작", category="async") + deadline = time.monotonic() + 20.0 # 1. STT 처리 (스레드 풀에서 실행하여 실제 병렬 처리 가능) file_obj_for_stt = BytesIO(file_content) @@ -262,10 +287,24 @@ def __init__(self, content, filename): stt_file = TempUploadFile(file_obj_for_stt, filename) # 동기 함수를 스레드에서 실행하여 블로킹 방지 및 병렬 처리 가능 - stt_result = await asyncio.to_thread(transcribe_voice, stt_file, "ko-KR") + # 남은 시간 계산하여 STT에 타임아웃 적용 (전체 stt->nlp 20초 내) + remaining = max(0.1, deadline - time.monotonic()) + stt_coro = asyncio.to_thread(transcribe_voice, stt_file, "ko-KR", remaining) + try: + stt_result = await asyncio.wait_for(stt_coro, timeout=remaining) + except asyncio.TimeoutError: + print(f"STT 타임아웃: voice_id={voice_id} after 20s") + logger.log_step("stt 타임아웃", category="async") + mark_text_done(db, voice_id) + try_aggregate(db, voice_id) + return if not stt_result.get("transcript"): - print(f"STT 변환 실패: voice_id={voice_id}") + # STT 실패 시에도 집계가 진행되도록 텍스트 작업을 완료 처리 + print(f"STT 변환 실패: voice_id={voice_id} error={stt_result.get('error')}") + logger.log_step("stt 추출 실패", category="async") + mark_text_done(db, voice_id) + try_aggregate(db, voice_id) return transcript = stt_result["transcript"] @@ -273,7 +312,21 @@ def __init__(self, content, filename): logger.log_step("stt 추출 완료", category="async") # 2. NLP 감정 분석 (STT 결과로) - 스레드에서 실행 - nlp_result = await asyncio.to_thread(analyze_text_sentiment, transcript, "ko") + # NLP도 남은 시간 내에서만 수행 + remaining = deadline - time.monotonic() + if remaining <= 0: + logger.log_step("nlp 타임아웃", category="async") + mark_text_done(db, voice_id) + try_aggregate(db, voice_id) + return + nlp_coro = asyncio.to_thread(analyze_text_sentiment, transcript, "ko") + try: + nlp_result = await asyncio.wait_for(nlp_coro, timeout=max(0.1, remaining)) + except asyncio.TimeoutError: + logger.log_step("nlp 타임아웃", category="async") + mark_text_done(db, voice_id) + try_aggregate(db, voice_id) + return logger.log_step("nlp 작업 완료", category="async") # 3. VoiceContent 저장 (STT 결과 + NLP 감정 분석 결과) @@ -307,7 +360,14 @@ def __init__(self, content, filename): except Exception as e: print(f"STT → NLP 처리 중 오류 발생: {e}") + logger.log_step("stt 오류", category="async") db.rollback() + # 오류 시에도 텍스트 작업을 완료 처리하여 집계가 막히지 않도록 함 + try: + mark_text_done(db, voice_id) + try_aggregate(db, voice_id) + except Exception: + pass finally: db.close() diff --git a/database_schema.sql b/database_schema.sql index e18ff19..0682885 100644 --- a/database_schema.sql +++ b/database_schema.sql @@ -157,4 +157,34 @@ CREATE TABLE IF NOT EXISTS `notification` ( INDEX `idx_notification_created` (`created_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +-- WEEKLY_RESULT (OpenAI 주간 종합분석 캐시) +CREATE TABLE IF NOT EXISTS `weekly_result` ( + `weekly_result_id` BIGINT PRIMARY KEY AUTO_INCREMENT, + `user_id` BIGINT NOT NULL, + `latest_voice_composite_id` BIGINT NULL, + `message` TEXT NOT NULL, + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT `fk_weekly_user` FOREIGN KEY (`user_id`) REFERENCES `user`(`user_id`) ON DELETE CASCADE, + CONSTRAINT `fk_weekly_latest_vc` FOREIGN KEY (`latest_voice_composite_id`) REFERENCES `voice_composite`(`voice_composite_id`) ON DELETE CASCADE, + UNIQUE KEY `uq_weekly_user` (`user_id`), + INDEX `idx_weekly_user` (`user_id`), + INDEX `idx_weekly_latest_vc` (`latest_voice_composite_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- FREQUENCY_RESULT (OpenAI 월간 빈도 종합분석 캐시) +CREATE TABLE IF NOT EXISTS `frequency_result` ( + `frequency_result_id` BIGINT PRIMARY KEY AUTO_INCREMENT, + `user_id` BIGINT NOT NULL, + `latest_voice_composite_id` BIGINT NULL, + `message` TEXT NOT NULL, + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT `fk_freq_user` FOREIGN KEY (`user_id`) REFERENCES `user`(`user_id`) ON DELETE CASCADE, + CONSTRAINT `fk_freq_latest_vc` FOREIGN KEY (`latest_voice_composite_id`) REFERENCES `voice_composite`(`voice_composite_id`) ON DELETE CASCADE, + UNIQUE KEY `uq_freq_user` (`user_id`), + INDEX `idx_freq_user` (`user_id`), + INDEX `idx_freq_latest_vc` (`latest_voice_composite_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + SET FOREIGN_KEY_CHECKS = 1; \ No newline at end of file diff --git a/migrations/versions/202511010003_add_weekly_frequency_result.py b/migrations/versions/202511010003_add_weekly_frequency_result.py new file mode 100644 index 0000000..8302312 --- /dev/null +++ b/migrations/versions/202511010003_add_weekly_frequency_result.py @@ -0,0 +1,55 @@ +"""add weekly_result and frequency_result tables + +Revision ID: 202511010003_add_weekly_frequency_result +Revises: 202511010002_add_notification +Create Date: 2025-11-03 20:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = '202511010003_add_weekly_frequency_result' +down_revision = '202511010002_add_notification' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + 'weekly_result', + sa.Column('weekly_result_id', sa.BigInteger(), primary_key=True, autoincrement=True), + sa.Column('user_id', sa.BigInteger(), sa.ForeignKey('user.user_id', ondelete='CASCADE'), nullable=False), + sa.Column('latest_voice_composite_id', sa.BigInteger(), sa.ForeignKey('voice_composite.voice_composite_id', ondelete='CASCADE'), nullable=True), + sa.Column('message', sa.Text(), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), + sa.UniqueConstraint('user_id', name='uq_weekly_user'), + ) + op.create_index('idx_weekly_user', 'weekly_result', ['user_id']) + op.create_index('idx_weekly_latest_vc', 'weekly_result', ['latest_voice_composite_id']) + + op.create_table( + 'frequency_result', + sa.Column('frequency_result_id', sa.BigInteger(), primary_key=True, autoincrement=True), + sa.Column('user_id', sa.BigInteger(), sa.ForeignKey('user.user_id', ondelete='CASCADE'), nullable=False), + sa.Column('latest_voice_composite_id', sa.BigInteger(), sa.ForeignKey('voice_composite.voice_composite_id', ondelete='CASCADE'), nullable=True), + sa.Column('message', sa.Text(), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), + sa.UniqueConstraint('user_id', name='uq_freq_user'), + ) + op.create_index('idx_freq_user', 'frequency_result', ['user_id']) + op.create_index('idx_freq_latest_vc', 'frequency_result', ['latest_voice_composite_id']) + + +def downgrade() -> None: + op.drop_index('idx_freq_latest_vc', table_name='frequency_result') + op.drop_index('idx_freq_user', table_name='frequency_result') + op.drop_table('frequency_result') + + op.drop_index('idx_weekly_latest_vc', table_name='weekly_result') + op.drop_index('idx_weekly_user', table_name='weekly_result') + op.drop_table('weekly_result') + + diff --git a/migrations/versions/202511030001_merge_heads.py b/migrations/versions/202511030001_merge_heads.py new file mode 100644 index 0000000..1f1d6aa --- /dev/null +++ b/migrations/versions/202511030001_merge_heads.py @@ -0,0 +1,25 @@ +"""merge heads + +Revision ID: 202511030001_merge_heads +Revises: add_question_tables, 202511010003_add_weekly_frequency_result +Create Date: 2025-11-03 23:45:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = '202511030001_merge_heads' +down_revision = ('add_question_tables', '202511010003_add_weekly_frequency_result') +branch_labels = None +depends_on = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass + + diff --git a/requirements.txt b/requirements.txt index 5279f08..84604b3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,3 +16,4 @@ bcrypt>=4.0.0 alembic>=1.12.0 psutil>=5.9.0 firebase-admin>=6.0.0 +openai>=1.40.0