Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -155,4 +155,3 @@ restart_systemd.sh
SERVERS.md

*.json

9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. ν™˜κ²½ λ³€μˆ˜ μ„€μ •
Expand Down
22 changes: 22 additions & 0 deletions _schema_fix.py
Original file line number Diff line number Diff line change
@@ -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()



Comment on lines +1 to +22
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Alembic λ§ˆμ΄κ·Έλ ˆμ΄μ…˜ μ‚¬μš©μ„ ꢌμž₯ν•©λ‹ˆλ‹€.

이 μŠ€ν¬λ¦½νŠΈλŠ” μˆ˜λ™μœΌλ‘œ μŠ€ν‚€λ§ˆλ₯Ό λ³€κ²½ν•˜μ§€λ§Œ, README.md에 Alembic λ§ˆμ΄κ·Έλ ˆμ΄μ…˜ κ°€μ΄λ“œκ°€ 이미 μžˆμŠ΅λ‹ˆλ‹€. λͺ‡ κ°€μ§€ 문제점:

  • μ›μ‹œ SQL μ‚¬μš©μœΌλ‘œ λ§ˆμ΄κ·Έλ ˆμ΄μ…˜ νžˆμŠ€ν† λ¦¬ 좔적 λΆˆκ°€
  • MySQL μ „μš© ꡬ문(SHOW COLUMNS)으둜 λ‹€λ₯Έ DB와 ν˜Έν™˜ λΆˆκ°€
  • λ‘€λ°± λ©”μ»€λ‹ˆμ¦˜ μ—†μŒ
  • ν”„λ‘œλ•μ…˜ ν™˜κ²½μ—μ„œ μ‹€μˆ˜λ‘œ 싀행될 μœ„ν—˜

λŒ€μ‹  Alembic을 μ‚¬μš©ν•˜μ„Έμš”:

alembic revision --autogenerate -m "Add surprise_bps to VoiceAnalyze"
alembic upgrade head
πŸ€– Prompt for AI Agents
_schema_fix.py lines 1-22: this ad-hoc script mutates schema with raw,
MySQL-specific SQL, lacks migration history, downgrade support and safety for
prod; replace it with an Alembic migration: create a new revision (alembic
revision --autogenerate -m "Add surprise_bps to voice_analyze"), implement the
upgrade() to add the surprise_bps SMALLINT with a default and nullable/NOT NULL
handling compatible with our DB, implement downgrade() to drop the column, run
alembic upgrade head to apply, remove or disable this standalone script from
repo/CI so it cannot be run against production, and include a brief test or
checklist to verify the migration applies cleanly across environments.

103 changes: 103 additions & 0 deletions app/care_service.py
Original file line number Diff line number Diff line change
@@ -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)}"}
72 changes: 70 additions & 2 deletions app/db_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 ν‚€λ‘œ μŒμ„± 파일 쑰회"""
Expand All @@ -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]:
"""전체 μŒμ„± 파일 λͺ©λ‘ 쑰회"""
Expand Down Expand Up @@ -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:
"""μŒμ„± 감정 뢄석 데이터 생성"""
Expand All @@ -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
Expand All @@ -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]:
"""μŒμ„± 감정 뢄석 κ²°κ³Ό μ—…λ°μ΄νŠΈ"""
Expand All @@ -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:
Expand Down Expand Up @@ -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
Comment on lines +287 to +298
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

λΆ€λΆ„ μ‚­μ œ κ°€λŠ₯성에 λŒ€ν•œ νŠΈλžœμž­μ…˜ 처리λ₯Ό κ²€ν† ν•˜μ„Έμš”.

ν˜„μž¬ κ΅¬ν˜„μ€ μ—¬λŸ¬ ν…Œμ΄λΈ”μ—μ„œ 순차적으둜 μ‚­μ œλ₯Ό μˆ˜ν–‰ν•˜μ§€λ§Œ, 쀑간에 μ‹€νŒ¨ μ‹œ λΆ€λΆ„ μ‚­μ œκ°€ λ°œμƒν•  수 μžˆμŠ΅λ‹ˆλ‹€. λͺ…μ‹œμ μΈ νŠΈλžœμž­μ…˜ μ»¨ν…μŠ€νŠΈλ₯Ό μ‚¬μš©ν•˜κ±°λ‚˜ μ˜ˆμ™Έ λ°œμƒ μ‹œ λ‘€λ°± 처리λ₯Ό μΆ”κ°€ν•˜λŠ” 것을 ꢌμž₯ν•©λ‹ˆλ‹€.

μ˜ˆμ‹œ:

 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
+    try:
+        # 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
+    except Exception as e:
+        self.db.rollback()
+        raise
πŸ“ Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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 delete_voice_with_relations(self, voice_id: int) -> bool:
"""μ—°κ΄€ 데이터(voice_question, voice_content, voice_analyze) μ‚­μ œ ν›„ voice μ‚­μ œ"""
try:
# 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
except Exception as e:
self.db.rollback()
raise
πŸ€– Prompt for AI Agents
In app/db_service.py around lines 287 to 298, the sequential deletes across
multiple tables are not wrapped in an explicit transaction so a failure mid-way
can leave partial deletes; wrap the entire operation in a DB transaction (or
session.begin()) and handle exceptions by rolling back on error, or use the
ORM/session context manager to ensure atomicity and commit only on success, then
return the deletion outcome.



def get_db_service(db: Session) -> DatabaseService:
"""λ°μ΄ν„°λ² μ΄μŠ€ μ„œλΉ„μŠ€ μΈμŠ€ν„΄μŠ€ 생성"""
Expand Down
33 changes: 33 additions & 0 deletions app/dto.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading