From d0e1294148a3a51ea9703452c78b4ea9668d699c Mon Sep 17 00:00:00 2001 From: yugyeom <48901587+rladbrua0207@users.noreply.github.com> Date: Fri, 1 Nov 2024 22:03:26 +0900 Subject: [PATCH] =?UTF-8?q?Feat:=20AI=EC=84=9C=EB=B2=84=EC=97=90=EC=84=9C?= =?UTF-8?q?=20STT=20=EA=B2=B0=EA=B3=BC=20=EC=9D=91=EB=8B=B5=20=EC=99=94?= =?UTF-8?q?=EC=9D=84=EB=95=8C=20STT=20=EA=B2=B0=EA=B3=BC=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=ED=95=98=EB=8A=94=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(#27)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Feat: POST 저장 기능, OCR 구현, 폴더 수정, 페이지별 조회 기능 구현 및 배포 (#66) (#67) * Fix: merge 하면서 잘못된 부분 수정 * Feat: Post 저장 기능 구현 (#17) * Feat: Post 저장 기능 구현 * Feat: Post Id로 Post 1개 찾기 기능 구현 * Style: 코드리뷰 반영 - 변수선언과 변수 할당을 한줄에 하도록 변경 * Refactor: 코드리뷰 반영 : Post 조회 수행 위치 변경 - JPA 사용하는 단순 조회 쿼리를 PostService 와 PostRepository 에서 관리하도록 변경 (기존에는 PostQueryService 와 PostQueryRepository에서 관리했음) * Fix: 코드리뷰 반영 : Post 조회시 ResponseBody 삭제 -uri 반환중이라 굳이 불필요해서 삭제함 * Fix: 코드리뷰 반영 : Post 저장 기능에서 Request와 Command 를 분리 * FIx: MemberAPI에 리소스가 생성되는 Response Status 200 -> 201로 변경 (#22) * Release: 0.0.1 배포 (#23) * Fix: 인증토큰 관련 오류 해결 및 오류메시지 추가 * Fix: 서버환경에 맞게 tesseract경로 수정 * Build: 도커파일 작성 * Fix: audio basePath 도커 볼륨에 연결 * Feat: OCR 로직 구현 (#21) * Init: 프로젝트 기본설정 세팅 - 프로젝트 생성 - .gitignore설정 - 프로젝트 의존성 추가 - application.yml 설정파일 구성 * Init: 프로젝트 기본 구조 및 공통 컴포넌트 설정 - 공통 설정 클래스 추가 (JPA, QueryDSL, Swagger, Web) - 공통 도메인 엔티티 (RootEntity) 정의 - 예외 처리 관련 클래스 및 타입 구현 - JSON 변환을 위한 AttributeConverter 추가 - 유틸리티 클래스 (Math) 추가 * Style: formatting통일 (#30) * Chore : Annotation 폴더 구조 설정 * Feat : Annotation Domain Entity 설정 * Feat: Annotaion crud 구현 1. controller : api 명세대로 구현 2. service : R-CUD를 분리하는 service 구현 3. presentation : dto 구현 * Feat: 요약 및 문제 생성 API 구현 (#32) * Rename: AI Task 도메인 이름 변경 - llm 으로 변경 * Refactor: LLM 도메인 애플리케이션 계층과 프레젠테이션 계층 응답 분리 * Feat: LLM 작업 진행 상태 확인 기능 구현 * Feat: 요약 및 문제 결과 조회 기능 구현 * Feat: 요약 및 문제 생성 기능 구현 - AI 서버와 통신하는 부분 제외하고 기능 구현 - 임시 UUID 를 통해 task 저장 * Feat: LLM 서버 콜백 기능 구현 - LLM 서버가 API 콜을 통해 페이지별 요약 및 문제 내용 전달 - task id 를 통해 조회하여 요약 내용과 문제 내용 업데이트 * Refactor: 변수 이름 변경 - 필드명 카멜케이스로 변경 * Refactor: 통일성 없는 부분 수정 - 필드명 변경 - 변수 추출 * Refactor: 예외 종류, 메서드 네이밍 변경 - LLMQueryService 예외 타입 변경 - SummaryAndProblemUpdateResponse 메서드 네이밍 변경 * Refactor: LLMQueryService 응답과 LLMController 응답 분리 * Style: 코드 포맷팅 통일 (#36) * Feat: 요약 및 문제 생성 API 구현 (#4) * Rename: AI Task 도메인 이름 변경 - llm 으로 변경 * Refactor: LLM 도메인 애플리케이션 계층과 프레젠테이션 계층 응답 분리 * Feat: LLM 작업 진행 상태 확인 기능 구현 * Feat: 요약 및 문제 결과 조회 기능 구현 * Feat: 요약 및 문제 생성 기능 구현 - AI 서버와 통신하는 부분 제외하고 기능 구현 - 임시 UUID 를 통해 task 저장 * Feat: LLM 서버 콜백 기능 구현 - LLM 서버가 API 콜을 통해 페이지별 요약 및 문제 내용 전달 - task id 를 통해 조회하여 요약 내용과 문제 내용 업데이트 * Refactor: 변수 이름 변경 - 필드명 카멜케이스로 변경 * Refactor: 통일성 없는 부분 수정 - 필드명 변경 - 변수 추출 * Refactor: 예외 종류, 메서드 네이밍 변경 - LLMQueryService 예외 타입 변경 - SummaryAndProblemUpdateResponse 메서드 네이밍 변경 * Refactor: LLMQueryService 응답과 LLMController 응답 분리 * Feat: 폴더 관련 기능 구현 (#6) * Init: 프로젝트 기본설정 세팅 - 프로젝트 생성 - .gitignore설정 - 프로젝트 의존성 추가 - application.yml 설정파일 구성 * Init: 프로젝트 기본 구조 및 공통 컴포넌트 설정 - 공통 설정 클래스 추가 (JPA, QueryDSL, Swagger, Web) - 공통 도메인 엔티티 (RootEntity) 정의 - 예외 처리 관련 클래스 및 타입 구현 - JSON 변환을 위한 AttributeConverter 추가 - 유틸리티 클래스 (Math) 추가 * Chore: Folder 도메인 폴더 구조 셋업 폴더 구조 셋업 작업 * Feat: Folder 도메인의 엔티티 생성 엔티티 생성자, 부모-자식간 연결로직 생성 * refactor: 자기 참조 관계 설정 수정 기존, 다대일 양방향 관계에서 다대일 단방향 관계로 설정하고, 삭제 등의 이슈 발생시 Service 계층에서 함수의 재귀사용을 통해 삭제할 예정 * Chore: Document 도메인 폴더 구조 셋업 폴더 구조 셋업 및 엔티티 생성 * Feat: Lombok 라이브러리 활용하여 기본 생성자 생성 기본 생성자 생성 lombok 라이브러리 활용하여 대체 * chore: name 필드의 length 50으로 설정 name 필드 (Document, Folder) 의 length = 50 으로 설정 * chore: Domain 계층의 Repository가 QueryRepository 상속받도록 함 상속 작업 수행 * chore: Member 도메인 매핑 작업 수행 Member 도메인 매핑 작업 수행 * chore: Member 도메인과 Folder 도메인 연결 작업 수행 Member 도메인과 Folder 도메인 연결 작업 수행 * feat: 루트 폴더 생성하는 기능 구현 루트 폴더 생성하는 기능 구현 * feat: 서브폴더 생성하는 기능 구현 서브 폴더 생성하는 기능 구현 * feat: 폴더를 루트로 이동시키는 기능 구현 폴더를 루트로 이동시키는 기능 구현 * feat: 새로운 폴더 내부로 이동시키는 기능 구현 새로운 폴더 내부로 이동시키는 기능 구현 * feat: 계층형 구조의 폴더 탐색 기능 구현 계층형 구조의 폴더 탐색 기능 구현 * test: 재귀적으로 폴더를 조회하는 테스트 코드 작성 재귀적으로 폴더 조회하는 테스트코드 작성 * remove: 사용하지 않는 QueryDSL 관련 파일 삭제 사용하지 않는 QueryDSL 관련 파일 삭제 * refactor: formatting 적용 formatting 적용 * feat: 폴더 재귀적으로 삭제하는 기능 구현 폴더 재귀적으로 삭제하는 기능 구현 * feat: @OnDelete 어노테이션을 사용하여 삭제 기능 구현 삭제 기능 구현 * feat: 폴더 구조의 조회를 간편하게 개선 폴더 구조의 조회 간편하게 개선 * feat: 루트에 폴더를 생성하는 API 구현 루트에 폴더를 생성하는 API 구현 * feat: 서브 폴더를 생성하는 API 구현 서브 폴더 생성하는 API 구현 * feat: 폴더 이동하는 API 구현 폴더 이동하는 API 구현 * refactor: 중복된 함수 기능 병합 작업 수행 중복된 함수 기능 병합 작업 수행 * feat: 폴더 조회 API 구현 폴더 조회 API 구현 * feat: 폴더 삭제 API 구현 폴더 삭제 API 구현 * rename: 함수명 변경 함수 명 변경 * refactor: 메서드 분리 작업 수행 메서드 분리 작업 수행 * refactor: Delete API 204 로 반환 204로 반환 * feat: 요청마다 DTO를 다르게 설정 요청마다 DTO 다르게 설정 * refactor: 타입추론방식에서 타입명시방식으로 변경 타입명시방식으로 코드 스타일 변경 * refactor: 도메인 값에 대한 검증은 도메인계층으로 옮김 도메인 계층으로 값에 대한 검증 이동 * refactor: Owner가 아닌 폴더에 접근하려고 하는 경우 NotFoundException 예외 발생 예외 발생 --------- * Style: 코드 포맷팅 통일 * Refactor: 예외 종류 변경 --------- * Revert "Style: 코드 포맷팅 통일 (#36)" (#38) This reverts commit ad9062e737992caa2bbb3a07dda0072ec086c7ef. * Feat: 폴더 관련 기능 구현 (#39) * Feat: 요약 및 문제 생성 API 구현 (#4) * Rename: AI Task 도메인 이름 변경 - llm 으로 변경 * Refactor: LLM 도메인 애플리케이션 계층과 프레젠테이션 계층 응답 분리 * Feat: LLM 작업 진행 상태 확인 기능 구현 * Feat: 요약 및 문제 결과 조회 기능 구현 * Feat: 요약 및 문제 생성 기능 구현 - AI 서버와 통신하는 부분 제외하고 기능 구현 - 임시 UUID 를 통해 task 저장 * Feat: LLM 서버 콜백 기능 구현 - LLM 서버가 API 콜을 통해 페이지별 요약 및 문제 내용 전달 - task id 를 통해 조회하여 요약 내용과 문제 내용 업데이트 * Refactor: 변수 이름 변경 - 필드명 카멜케이스로 변경 * Refactor: 통일성 없는 부분 수정 - 필드명 변경 - 변수 추출 * Refactor: 예외 종류, 메서드 네이밍 변경 - LLMQueryService 예외 타입 변경 - SummaryAndProblemUpdateResponse 메서드 네이밍 변경 * Refactor: LLMQueryService 응답과 LLMController 응답 분리 * Feat: 폴더 관련 기능 구현 (#6) * Init: 프로젝트 기본설정 세팅 - 프로젝트 생성 - .gitignore설정 - 프로젝트 의존성 추가 - application.yml 설정파일 구성 * Init: 프로젝트 기본 구조 및 공통 컴포넌트 설정 - 공통 설정 클래스 추가 (JPA, QueryDSL, Swagger, Web) - 공통 도메인 엔티티 (RootEntity) 정의 - 예외 처리 관련 클래스 및 타입 구현 - JSON 변환을 위한 AttributeConverter 추가 - 유틸리티 클래스 (Math) 추가 * Chore: Folder 도메인 폴더 구조 셋업 폴더 구조 셋업 작업 * Feat: Folder 도메인의 엔티티 생성 엔티티 생성자, 부모-자식간 연결로직 생성 * refactor: 자기 참조 관계 설정 수정 기존, 다대일 양방향 관계에서 다대일 단방향 관계로 설정하고, 삭제 등의 이슈 발생시 Service 계층에서 함수의 재귀사용을 통해 삭제할 예정 * Chore: Document 도메인 폴더 구조 셋업 폴더 구조 셋업 및 엔티티 생성 * Feat: Lombok 라이브러리 활용하여 기본 생성자 생성 기본 생성자 생성 lombok 라이브러리 활용하여 대체 * chore: name 필드의 length 50으로 설정 name 필드 (Document, Folder) 의 length = 50 으로 설정 * chore: Domain 계층의 Repository가 QueryRepository 상속받도록 함 상속 작업 수행 * chore: Member 도메인 매핑 작업 수행 Member 도메인 매핑 작업 수행 * chore: Member 도메인과 Folder 도메인 연결 작업 수행 Member 도메인과 Folder 도메인 연결 작업 수행 * feat: 루트 폴더 생성하는 기능 구현 루트 폴더 생성하는 기능 구현 * feat: 서브폴더 생성하는 기능 구현 서브 폴더 생성하는 기능 구현 * feat: 폴더를 루트로 이동시키는 기능 구현 폴더를 루트로 이동시키는 기능 구현 * feat: 새로운 폴더 내부로 이동시키는 기능 구현 새로운 폴더 내부로 이동시키는 기능 구현 * feat: 계층형 구조의 폴더 탐색 기능 구현 계층형 구조의 폴더 탐색 기능 구현 * test: 재귀적으로 폴더를 조회하는 테스트 코드 작성 재귀적으로 폴더 조회하는 테스트코드 작성 * remove: 사용하지 않는 QueryDSL 관련 파일 삭제 사용하지 않는 QueryDSL 관련 파일 삭제 * refactor: formatting 적용 formatting 적용 * feat: 폴더 재귀적으로 삭제하는 기능 구현 폴더 재귀적으로 삭제하는 기능 구현 * feat: @OnDelete 어노테이션을 사용하여 삭제 기능 구현 삭제 기능 구현 * feat: 폴더 구조의 조회를 간편하게 개선 폴더 구조의 조회 간편하게 개선 * feat: 루트에 폴더를 생성하는 API 구현 루트에 폴더를 생성하는 API 구현 * feat: 서브 폴더를 생성하는 API 구현 서브 폴더 생성하는 API 구현 * feat: 폴더 이동하는 API 구현 폴더 이동하는 API 구현 * refactor: 중복된 함수 기능 병합 작업 수행 중복된 함수 기능 병합 작업 수행 * feat: 폴더 조회 API 구현 폴더 조회 API 구현 * feat: 폴더 삭제 API 구현 폴더 삭제 API 구현 * rename: 함수명 변경 함수 명 변경 * refactor: 메서드 분리 작업 수행 메서드 분리 작업 수행 * refactor: Delete API 204 로 반환 204로 반환 * feat: 요청마다 DTO를 다르게 설정 요청마다 DTO 다르게 설정 * refactor: 타입추론방식에서 타입명시방식으로 변경 타입명시방식으로 코드 스타일 변경 * refactor: 도메인 값에 대한 검증은 도메인계층으로 옮김 도메인 계층으로 값에 대한 검증 이동 * refactor: Owner가 아닌 폴더에 접근하려고 하는 경우 NotFoundException 예외 발생 예외 발생 --------- --------- * Style: 코드 포맷팅 통일 (#40) * Style: 코드 포맷팅 통일 * Refactor: 예외 종류 변경 --------- * Remove: .idea 폴더 삭제 * Style: 코드 포맷팅 통일 (#41) * refactor: 폴더의 삭제 방법 재귀형태로 찾아 삭제하도록 개선 개선 * chore: API 매칭 URL 수정 작업 진행 API 매칭 URL 수정 작업 진행 * remove: 불필요한 문서 상태 삭제 불필요한 문서 상태 삭제 * feat: PDF 저장하는 기능 구현 * remove: 불필요한 라이브러리 삭제 불필요한 라이브러리 삭제 * feat: Document 저장하는 기능 구현 * test: PDF Service에서 PDF 저장 로직 테스트코드 작성 PDF 저장 로직 테스트코드 작성 * feat: Document 저장 하는 기능 구현 Document 저장하는 기능 구현 * chore: PDF 처리 및 OCR 라이브러리 라이브러리 import * feat: Document 생성 API 구현 Document 생성 API 구현 * feat: 자료 이름 수정 기능 구현 자료 이름 수정 기능 구현 * feat: 자료 조회 기능 구현 자료 조회 기능 구현 * feat: 자료 삭제 기능 구현 자료 삭제 기능 구현 * feat: 폴더 삭제시 자료도 함께 삭제되도록 기능 구현 삭제 기능 구현 * chore: PDF Setup pdf 세팅 * Feat: 녹음 파일 업로드 기능 구현 (#8) * Feat: 녹음 파일 업로드 기능 구현 - Recording 엔티티, 레포지토리, 컨트롤러 코드 작성 - 오디오 디코딩, 파일 저장 코드 작성 * Chore: Weekly5 로 rebase * Refactor: file base path @value 를 사용하도록 변경 --------- * feat: 루트(메인)에 생성되는 Document 설정 루트 자료 추가 기능 구현 * Feat: 녹음-페이지 저장 기능 구현 (#10) * Refactor: 메서드, 파라미터 이름 변경 * Feat: 녹음-페이지 저장 기능 구현 - 페이지 넘김 이벤트에 따라 녹음-페이지 테이블에 타임스탬프 저장 * Refactor: 예외 메시지 수정 * Feature/annotation 구현 완료 (#12) * Feat : Annotation CRUD 구현 1. Controller : API 명세서 구현 2. Service : R-CUD를 QueryService, Serivce를 이용하여 구현 3. presentation : DTO 구현 * Refactor: @Positive를 이용한 양수 검증 * Refactor: @NoArgsConstructor의 접근 수준을 PROTECTED로 변경 * Refactor: CreateAnnotationRequest에서 좌표 및 크기 검증 추가 * Refactor: DTO를 record로 통일 * Refactor: getById로 변경 * Refactor: record로 인한 형식 변경 * Refactor: 정적 팩토리 from으로 변경 * Refactor: ManyToOne의 fetch 형식 LAZY로 설정 * Refactor : 정적팩토리 from으로 인한 코드 변경 * Refactor : createAnnotation에서 누락된 savedAnnotatio 추가 * Refactor : pageNumbers 누락 -> 해당 내용을 반영한 Read 구현 * Refactor: CRUD test code 작성 * Refactor : getById로 변경 * Revert "Feature/annotation 구현 완료 (#12)" (#14) This reverts commit 0dcf1d29f5897fd5db772dbf8035d877f84b19e9. * Feat: Annotation API 구현 (#15) * Feat : Annotation CRUD 구현 1. Controller : API 명세서 구현 2. Service : R-CUD를 QueryService, Serivce를 이용하여 구현 3. presentation : DTO 구현 * Refactor: @Positive를 이용한 양수 검증 * Refactor: @NoArgsConstructor의 접근 수준을 PROTECTED로 변경 * Refactor: CreateAnnotationRequest에서 좌표 및 크기 검증 추가 * Refactor: DTO를 record로 통일 * Refactor: getById로 변경 * Refactor: record로 인한 형식 변경 * Refactor: 정적 팩토리 from으로 변경 * Refactor: ManyToOne의 fetch 형식 LAZY로 설정 * Refactor : 정적팩토리 from으로 인한 코드 변경 * Refactor : createAnnotation에서 누락된 savedAnnotatio 추가 * Refactor : pageNumbers 누락 -> 해당 내용을 반영한 Read 구현 * Refactor: CRUD test code 작성 * Refactor : getById로 변경 --------- * Feat: 문서 도메인 관련 기능 구현 (#13) * refactor: 폴더의 삭제 방법 재귀형태로 찾아 삭제하도록 개선 개선 * chore: API 매칭 URL 수정 작업 진행 API 매칭 URL 수정 작업 진행 * remove: 불필요한 문서 상태 삭제 불필요한 문서 상태 삭제 * feat: PDF 저장하는 기능 구현 * remove: 불필요한 라이브러리 삭제 불필요한 라이브러리 삭제 * feat: Document 저장하는 기능 구현 * test: PDF Service에서 PDF 저장 로직 테스트코드 작성 PDF 저장 로직 테스트코드 작성 * feat: Document 저장 하는 기능 구현 Document 저장하는 기능 구현 * chore: PDF 처리 및 OCR 라이브러리 라이브러리 import * feat: Document 생성 API 구현 Document 생성 API 구현 * feat: 자료 이름 수정 기능 구현 자료 이름 수정 기능 구현 * feat: 자료 조회 기능 구현 자료 조회 기능 구현 * feat: 자료 삭제 기능 구현 자료 삭제 기능 구현 * feat: 폴더 삭제시 자료도 함께 삭제되도록 기능 구현 삭제 기능 구현 * chore: PDF Setup pdf 세팅 * feat: 루트(메인)에 생성되는 Document 설정 루트 자료 추가 기능 구현 --------- * chore: OCR 도메인 생성 도메인 생성 * feat: ID를 사용하여 엔티티 꺼내오는 작업 수행 엔티티 꺼내오는 작업 수행 * rename: PDF 관련 파일 이동 파일 이동 * feat: 문서 저장시 OCR 작업수행하는 기능 OCR 자동 작업 기능 구현 * refactor: PDF 저장 로직 개선 저장 로직 개선 * test: OCR 기능 작동 여부 테스트코드 작성 테스트 코드 작성 * refactor: 상수값 static 으로 따로 관리 상수값 관리하도록 피드백 반영 * feat: AOP 를 활용하여 Get요청시 ok를 바로 보내줄 수 있도록 기능 구현 AOP 활용 래퍼클래스 개발 * remove: 상의 후 도입할지 정해야하기때문에 우선 삭제 * Feat: AI Client기능 구현 (#18) * Feat: AI Client기능 구현 * Test: AI Client 통합테스트 작성 * build: 빌드시 테스트에서 제외해야 할 테스트 태그 추가 * Test: AI Client 단위테스트 작성 * Feat: LLMService에 AI 서버 요청 기능 추가 * Test: LLMService AI_기능_요청 메서드에 클라이언트 단위테스트 코드 추가 * Chore: 불필요한 주석 제거 * Chore: 더미 AI서버 주소 추가 * Chore: 불필요한 impl클래스 제거 및 테스트코드 수정 * Feat: PDF 처리 기능 및 OCR 기능 (#19) * chore: OCR 도메인 생성 도메인 생성 * feat: ID를 사용하여 엔티티 꺼내오는 작업 수행 엔티티 꺼내오는 작업 수행 * rename: PDF 관련 파일 이동 파일 이동 * feat: 문서 저장시 OCR 작업수행하는 기능 OCR 자동 작업 기능 구현 * refactor: PDF 저장 로직 개선 저장 로직 개선 * test: OCR 기능 작동 여부 테스트코드 작성 테스트 코드 작성 * refactor: 상수값 static 으로 따로 관리 상수값 관리하도록 피드백 반영 * feat: AOP 를 활용하여 Get요청시 ok를 바로 보내줄 수 있도록 기능 구현 AOP 활용 래퍼클래스 개발 * remove: 상의 후 도입할지 정해야하기때문에 우선 삭제 * Refactor: 에러 메시지 상수화 (#20) * Refactor: 에러 메시지 상수화 * Refactor: 코드 통합 후 에러 메시지 상수화 * test: 테스트코드 재작성 테스트코드 재작성 * refactor: Controller -> RestController 로 변경 변경작업 수행 * feat: PDF의 총 페이지 수 가져오는 기능 구현 총 페이지 수 가져오는 기능 구현 * feat: Document 도메인에서 페이지 정보 유효성 검사 페이지 정보 유효성 검사 * refactor: pdf주소만 받아 생성하던 Document 생성 로직 수정 수정 * remove: consumes 를 다 허용해줌 다 허용해주도럭 제약 조건 삭제 * feat: 폴더 이름 변경 기능 추가 폴더 이름 변경 기능 추가 * feat: OCR 결과 조회 기능 구현 OCR 결과 조회 기능 구현 * refactor: 폴더 삭제시 연결된 문서 삭제 기능 수정 삭제 기능을 수정 * chore: 에러 메시지 INVALID_DOCUMENT_PAGE 추가 * refactor: 하나의 페이지 당 최대 하나의 OCR 정보만 조회되도록 수정 기존 List -> Optional 로 리턴 타입 수정 --------- * Refactor: 녹음 API 상태 코드 변경 (#25) * Style: formatting통일 (#30) * Feat: 요약 및 문제 생성 API 구현 (#32) * Rename: AI Task 도메인 이름 변경 - llm 으로 변경 * Refactor: LLM 도메인 애플리케이션 계층과 프레젠테이션 계층 응답 분리 * Feat: LLM 작업 진행 상태 확인 기능 구현 * Feat: 요약 및 문제 결과 조회 기능 구현 * Feat: 요약 및 문제 생성 기능 구현 - AI 서버와 통신하는 부분 제외하고 기능 구현 - 임시 UUID 를 통해 task 저장 * Feat: LLM 서버 콜백 기능 구현 - LLM 서버가 API 콜을 통해 페이지별 요약 및 문제 내용 전달 - task id 를 통해 조회하여 요약 내용과 문제 내용 업데이트 * Refactor: 변수 이름 변경 - 필드명 카멜케이스로 변경 * Refactor: 통일성 없는 부분 수정 - 필드명 변경 - 변수 추출 * Refactor: 예외 종류, 메서드 네이밍 변경 - LLMQueryService 예외 타입 변경 - SummaryAndProblemUpdateResponse 메서드 네이밍 변경 * Refactor: LLMQueryService 응답과 LLMController 응답 분리 * Style: 코드 포맷팅 통일 (#36) * Feat: 요약 및 문제 생성 API 구현 (#4) * Rename: AI Task 도메인 이름 변경 - llm 으로 변경 * Refactor: LLM 도메인 애플리케이션 계층과 프레젠테이션 계층 응답 분리 * Feat: LLM 작업 진행 상태 확인 기능 구현 * Feat: 요약 및 문제 결과 조회 기능 구현 * Feat: 요약 및 문제 생성 기능 구현 - AI 서버와 통신하는 부분 제외하고 기능 구현 - 임시 UUID 를 통해 task 저장 * Feat: LLM 서버 콜백 기능 구현 - LLM 서버가 API 콜을 통해 페이지별 요약 및 문제 내용 전달 - task id 를 통해 조회하여 요약 내용과 문제 내용 업데이트 * Refactor: 변수 이름 변경 - 필드명 카멜케이스로 변경 * Refactor: 통일성 없는 부분 수정 - 필드명 변경 - 변수 추출 * Refactor: 예외 종류, 메서드 네이밍 변경 - LLMQueryService 예외 타입 변경 - SummaryAndProblemUpdateResponse 메서드 네이밍 변경 * Refactor: LLMQueryService 응답과 LLMController 응답 분리 * Feat: 폴더 관련 기능 구현 (#6) * Init: 프로젝트 기본설정 세팅 - 프로젝트 생성 - .gitignore설정 - 프로젝트 의존성 추가 - application.yml 설정파일 구성 * Init: 프로젝트 기본 구조 및 공통 컴포넌트 설정 - 공통 설정 클래스 추가 (JPA, QueryDSL, Swagger, Web) - 공통 도메인 엔티티 (RootEntity) 정의 - 예외 처리 관련 클래스 및 타입 구현 - JSON 변환을 위한 AttributeConverter 추가 - 유틸리티 클래스 (Math) 추가 * Chore: Folder 도메인 폴더 구조 셋업 폴더 구조 셋업 작업 * Feat: Folder 도메인의 엔티티 생성 엔티티 생성자, 부모-자식간 연결로직 생성 * refactor: 자기 참조 관계 설정 수정 기존, 다대일 양방향 관계에서 다대일 단방향 관계로 설정하고, 삭제 등의 이슈 발생시 Service 계층에서 함수의 재귀사용을 통해 삭제할 예정 * Chore: Document 도메인 폴더 구조 셋업 폴더 구조 셋업 및 엔티티 생성 * Feat: Lombok 라이브러리 활용하여 기본 생성자 생성 기본 생성자 생성 lombok 라이브러리 활용하여 대체 * chore: name 필드의 length 50으로 설정 name 필드 (Document, Folder) 의 length = 50 으로 설정 * chore: Domain 계층의 Repository가 QueryRepository 상속받도록 함 상속 작업 수행 * chore: Member 도메인 매핑 작업 수행 Member 도메인 매핑 작업 수행 * chore: Member 도메인과 Folder 도메인 연결 작업 수행 Member 도메인과 Folder 도메인 연결 작업 수행 * feat: 루트 폴더 생성하는 기능 구현 루트 폴더 생성하는 기능 구현 * feat: 서브폴더 생성하는 기능 구현 서브 폴더 생성하는 기능 구현 * feat: 폴더를 루트로 이동시키는 기능 구현 폴더를 루트로 이동시키는 기능 구현 * feat: 새로운 폴더 내부로 이동시키는 기능 구현 새로운 폴더 내부로 이동시키는 기능 구현 * feat: 계층형 구조의 폴더 탐색 기능 구현 계층형 구조의 폴더 탐색 기능 구현 * test: 재귀적으로 폴더를 조회하는 테스트 코드 작성 재귀적으로 폴더 조회하는 테스트코드 작성 * remove: 사용하지 않는 QueryDSL 관련 파일 삭제 사용하지 않는 QueryDSL 관련 파일 삭제 * refactor: formatting 적용 formatting 적용 * feat: 폴더 재귀적으로 삭제하는 기능 구현 폴더 재귀적으로 삭제하는 기능 구현 * feat: @OnDelete 어노테이션을 사용하여 삭제 기능 구현 삭제 기능 구현 * feat: 폴더 구조의 조회를 간편하게 개선 폴더 구조의 조회 간편하게 개선 * feat: 루트에 폴더를 생성하는 API 구현 루트에 폴더를 생성하는 API 구현 * feat: 서브 폴더를 생성하는 API 구현 서브 폴더 생성하는 API 구현 * feat: 폴더 이동하는 API 구현 폴더 이동하는 API 구현 * refactor: 중복된 함수 기능 병합 작업 수행 중복된 함수 기능 병합 작업 수행 * feat: 폴더 조회 API 구현 폴더 조회 API 구현 * feat: 폴더 삭제 API 구현 폴더 삭제 API 구현 * rename: 함수명 변경 함수 명 변경 * refactor: 메서드 분리 작업 수행 메서드 분리 작업 수행 * refactor: Delete API 204 로 반환 204로 반환 * feat: 요청마다 DTO를 다르게 설정 요청마다 DTO 다르게 설정 * refactor: 타입추론방식에서 타입명시방식으로 변경 타입명시방식으로 코드 스타일 변경 * refactor: 도메인 값에 대한 검증은 도메인계층으로 옮김 도메인 계층으로 값에 대한 검증 이동 * refactor: Owner가 아닌 폴더에 접근하려고 하는 경우 NotFoundException 예외 발생 예외 발생 --------- * Style: 코드 포맷팅 통일 * Refactor: 예외 종류 변경 --------- * Revert "Style: 코드 포맷팅 통일 (#36)" (#38) This reverts commit ad9062e737992caa2bbb3a07dda0072ec086c7ef. * Feat: 폴더 관련 기능 구현 (#39) * Feat: 요약 및 문제 생성 API 구현 (#4) * Rename: AI Task 도메인 이름 변경 - llm 으로 변경 * Refactor: LLM 도메인 애플리케이션 계층과 프레젠테이션 계층 응답 분리 * Feat: LLM 작업 진행 상태 확인 기능 구현 * Feat: 요약 및 문제 결과 조회 기능 구현 * Feat: 요약 및 문제 생성 기능 구현 - AI 서버와 통신하는 부분 제외하고 기능 구현 - 임시 UUID 를 통해 task 저장 * Feat: LLM 서버 콜백 기능 구현 - LLM 서버가 API 콜을 통해 페이지별 요약 및 문제 내용 전달 - task id 를 통해 조회하여 요약 내용과 문제 내용 업데이트 * Refactor: 변수 이름 변경 - 필드명 카멜케이스로 변경 * Refactor: 통일성 없는 부분 수정 - 필드명 변경 - 변수 추출 * Refactor: 예외 종류, 메서드 네이밍 변경 - LLMQueryService 예외 타입 변경 - SummaryAndProblemUpdateResponse 메서드 네이밍 변경 * Refactor: LLMQueryService 응답과 LLMController 응답 분리 * Feat: 폴더 관련 기능 구현 (#6) * Init: 프로젝트 기본설정 세팅 - 프로젝트 생성 - .gitignore설정 - 프로젝트 의존성 추가 - application.yml 설정파일 구성 * Init: 프로젝트 기본 구조 및 공통 컴포넌트 설정 - 공통 설정 클래스 추가 (JPA, QueryDSL, Swagger, Web) - 공통 도메인 엔티티 (RootEntity) 정의 - 예외 처리 관련 클래스 및 타입 구현 - JSON 변환을 위한 AttributeConverter 추가 - 유틸리티 클래스 (Math) 추가 * Chore: Folder 도메인 폴더 구조 셋업 폴더 구조 셋업 작업 * Feat: Folder 도메인의 엔티티 생성 엔티티 생성자, 부모-자식간 연결로직 생성 * refactor: 자기 참조 관계 설정 수정 기존, 다대일 양방향 관계에서 다대일 단방향 관계로 설정하고, 삭제 등의 이슈 발생시 Service 계층에서 함수의 재귀사용을 통해 삭제할 예정 * Chore: Document 도메인 폴더 구조 셋업 폴더 구조 셋업 및 엔티티 생성 * Feat: Lombok 라이브러리 활용하여 기본 생성자 생성 기본 생성자 생성 lombok 라이브러리 활용하여 대체 * chore: name 필드의 length 50으로 설정 name 필드 (Document, Folder) 의 length = 50 으로 설정 * chore: Domain 계층의 Repository가 QueryRepository 상속받도록 함 상속 작업 수행 * chore: Member 도메인 매핑 작업 수행 Member 도메인 매핑 작업 수행 * chore: Member 도메인과 Folder 도메인 연결 작업 수행 Member 도메인과 Folder 도메인 연결 작업 수행 * feat: 루트 폴더 생성하는 기능 구현 루트 폴더 생성하는 기능 구현 * feat: 서브폴더 생성하는 기능 구현 서브 폴더 생성하는 기능 구현 * feat: 폴더를 루트로 이동시키는 기능 구현 폴더를 루트로 이동시키는 기능 구현 * feat: 새로운 폴더 내부로 이동시키는 기능 구현 새로운 폴더 내부로 이동시키는 기능 구현 * feat: 계층형 구조의 폴더 탐색 기능 구현 계층형 구조의 폴더 탐색 기능 구현 * test: 재귀적으로 폴더를 조회하는 테스트 코드 작성 재귀적으로 폴더 조회하는 테스트코드 작성 * remove: 사용하지 않는 QueryDSL 관련 파일 삭제 사용하지 않는 QueryDSL 관련 파일 삭제 * refactor: formatting 적용 formatting 적용 * feat: 폴더 재귀적으로 삭제하는 기능 구현 폴더 재귀적으로 삭제하는 기능 구현 * feat: @OnDelete 어노테이션을 사용하여 삭제 기능 구현 삭제 기능 구현 * feat: 폴더 구조의 조회를 간편하게 개선 폴더 구조의 조회 간편하게 개선 * feat: 루트에 폴더를 생성하는 API 구현 루트에 폴더를 생성하는 API 구현 * feat: 서브 폴더를 생성하는 API 구현 서브 폴더 생성하는 API 구현 * feat: 폴더 이동하는 API 구현 폴더 이동하는 API 구현 * refactor: 중복된 함수 기능 병합 작업 수행 중복된 함수 기능 병합 작업 수행 * feat: 폴더 조회 API 구현 폴더 조회 API 구현 * feat: 폴더 삭제 API 구현 폴더 삭제 API 구현 * rename: 함수명 변경 함수 명 변경 * refactor: 메서드 분리 작업 수행 메서드 분리 작업 수행 * refactor: Delete API 204 로 반환 204로 반환 * feat: 요청마다 DTO를 다르게 설정 요청마다 DTO 다르게 설정 * refactor: 타입추론방식에서 타입명시방식으로 변경 타입명시방식으로 코드 스타일 변경 * refactor: 도메인 값에 대한 검증은 도메인계층으로 옮김 도메인 계층으로 값에 대한 검증 이동 * refactor: Owner가 아닌 폴더에 접근하려고 하는 경우 NotFoundException 예외 발생 예외 발생 --------- --------- * Style: 코드 포맷팅 통일 (#40) * Style: 코드 포맷팅 통일 * Refactor: 예외 종류 변경 --------- * Remove: .idea 폴더 삭제 * Style: 코드 포맷팅 통일 (#41) * Feat: 녹음 파일 업로드 기능 구현 (#8) * Feat: 녹음 파일 업로드 기능 구현 - Recording 엔티티, 레포지토리, 컨트롤러 코드 작성 - 오디오 디코딩, 파일 저장 코드 작성 * Chore: Weekly5 로 rebase * Refactor: file base path @value 를 사용하도록 변경 --------- * Refactor: 메서드, 파라미터 이름 변경 * Feat: 녹음-페이지 저장 기능 구현 - 페이지 넘김 이벤트에 따라 녹음-페이지 테이블에 타임스탬프 저장 * Refactor: 예외 메시지 수정 * refactor: 녹음 파일 업로드, 페이지 넘김 이벤트 처리 api 상태코드 변경 - 200 -> 201 --------- * Feat: 페이지별 api 구현 및 리팩토링 (#24) * Refactor: 예외 로깅 추가 - LLMQueryService 예외 로깅 * Refactor: LLM 테이블 이름 변경 * Refactor: 명세 변경으로 인한 AI 서버 결과 Request DTO 변경 * Refactor: 요약, 문제와 LLM 연관관계 변경 - 다대일에서 일대일로 변경 - LLM 이 연관관계 주인 * Refactor: LLMService 메서드 네이밍 변경 - task 를 페이지별로 생성하므로 의미를 더 잘 나타낼 수 있도록 복수형으로 변경 * Refactor: Problem, Summary QueryRepository 인터페이스 사용 * Refactor: LLM 테이블 이름 변경 * Refactor: 명세 변경으로 인한 AI 서버 결과 Request DTO 변경 * Refactor: 요약, 문제와 LLM 연관관계 변경 - 다대일에서 일대일로 변경 - LLM 이 연관관계 주인 * Refactor: LLMService 메서드 네이밍 변경 - task 를 페이지별로 생성하므로 의미를 더 잘 나타낼 수 있도록 복수형으로 변경 * Rebase: Weekly8 로 리베이스 * Refactor: Problem, Summary QueryRepository 인터페이스화 * Feat: 페이지별 AI 기능 상태 조회 api 구현 * Refactor: Problem, Summary 도메인 unique 제약 조건 추가 * Feat: 페이지별 AI 기능 결과 확인 API 구현 * Refactor: 상태, 결과 조회시 요청 기록이 없는 경우 처리 수정 - 예외를 발생시키지 않고, 요청없음 enum 값이나 empty list 등을 반환하도록 수정 * feat: AI 기능 요청시 기존 내역이 존재하는 경우 처리 - Summary 와 Problem 레코드 재활용 - LLM task 삭제 후 재생성(taskId 변경으로 인한) * Style: LLM으로 시작하는 클래스명 Llm으로 통일 (#26) --------- Co-authored-by: Hyun-Seo Jeong <90139789+hynseoj@users.noreply.github.com> Co-authored-by: hynseoj Co-authored-by: Cindy <93774025+Shsin9797@users.noreply.github.com> Co-authored-by: 윤정훈 <76200940+yunjunghun0116@users.noreply.github.com> Co-authored-by: mingjuu Co-authored-by: Minju Song <101880766+mingjuu@users.noreply.github.com> * Feat: AI서버에서 STT 결과 응답 왔을때 STT 결과 처리하는 기능 구현 --------- Co-authored-by: Hyun-Seo Jeong <90139789+hynseoj@users.noreply.github.com> Co-authored-by: hynseoj Co-authored-by: Cindy <93774025+Shsin9797@users.noreply.github.com> Co-authored-by: 윤정훈 <76200940+yunjunghun0116@users.noreply.github.com> Co-authored-by: mingjuu Co-authored-by: Minju Song <101880766+mingjuu@users.noreply.github.com> --- src/main/java/notai/client/ai/AiClient.java | 10 +- .../notai/common/exception/ErrorMessages.java | 31 +- .../domain/PageRecordingRepository.java | 8 +- .../query/PageRecordingQueryRepository.java | 12 +- .../PageRecordingQueryRepositoryImpl.java | 23 ++ .../application/RecordingService.java | 11 +- .../notai/stt/application/SttService.java | 45 +++ .../notai/stt/application/SttTaskService.java | 61 ++++ .../command/SttRequestCommand.java | 7 + .../command/UpdateSttResultCommand.java | 16 + .../application/dto/SttPageMatchedDto.java | 19 ++ src/main/java/notai/stt/domain/Stt.java | 150 +++++++++ .../java/notai/stt/domain/SttRepository.java | 6 + .../presentation/SttCallbackController.java | 22 ++ .../request/SttCallbackRequest.java | 38 +++ .../java/notai/sttTask/domain/SttTask.java | 43 +++ .../sttTask/domain/SttTaskRepository.java | 14 + .../client/ai/AiClientIntegrationTest.java | 12 +- .../java/notai/client/ai/AiClientTest.java | 10 +- .../application/PageRecordingServiceTest.java | 10 +- .../notai/stt/application/SttServiceTest.java | 124 ++++++++ src/test/java/notai/stt/domain/SttTest.java | 287 ++++++++++++++++++ 22 files changed, 917 insertions(+), 42 deletions(-) create mode 100644 src/main/java/notai/pageRecording/query/PageRecordingQueryRepositoryImpl.java create mode 100644 src/main/java/notai/stt/application/SttService.java create mode 100644 src/main/java/notai/stt/application/SttTaskService.java create mode 100644 src/main/java/notai/stt/application/command/SttRequestCommand.java create mode 100644 src/main/java/notai/stt/application/command/UpdateSttResultCommand.java create mode 100644 src/main/java/notai/stt/application/dto/SttPageMatchedDto.java create mode 100644 src/main/java/notai/stt/domain/Stt.java create mode 100644 src/main/java/notai/stt/domain/SttRepository.java create mode 100644 src/main/java/notai/stt/presentation/SttCallbackController.java create mode 100644 src/main/java/notai/stt/presentation/request/SttCallbackRequest.java create mode 100644 src/main/java/notai/sttTask/domain/SttTask.java create mode 100644 src/main/java/notai/sttTask/domain/SttTaskRepository.java create mode 100644 src/test/java/notai/stt/application/SttServiceTest.java create mode 100644 src/test/java/notai/stt/domain/SttTest.java diff --git a/src/main/java/notai/client/ai/AiClient.java b/src/main/java/notai/client/ai/AiClient.java index 296787a..c03507b 100644 --- a/src/main/java/notai/client/ai/AiClient.java +++ b/src/main/java/notai/client/ai/AiClient.java @@ -2,17 +2,17 @@ import notai.client.ai.request.LlmTaskRequest; import notai.client.ai.response.TaskResponse; +import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestPart; -import org.springframework.web.multipart.MultipartFile; import org.springframework.web.service.annotation.PostExchange; +import java.io.InputStream; + public interface AiClient { @PostExchange(url = "/api/ai/llm") TaskResponse submitLlmTask(@RequestBody LlmTaskRequest request); - @PostExchange(url = "/api/ai/stt") - TaskResponse submitSttTask(@RequestPart("audio") MultipartFile audioFile); + @PostExchange(url = "/api/ai/stt", contentType = MediaType.MULTIPART_FORM_DATA_VALUE) + TaskResponse submitSttTask(@RequestBody InputStream audioFileStream); } - diff --git a/src/main/java/notai/common/exception/ErrorMessages.java b/src/main/java/notai/common/exception/ErrorMessages.java index ba0884a..bc7a178 100644 --- a/src/main/java/notai/common/exception/ErrorMessages.java +++ b/src/main/java/notai/common/exception/ErrorMessages.java @@ -9,16 +9,19 @@ public enum ErrorMessages { ANNOTATION_NOT_FOUND("주석을 찾을 수 없습니다."), // document - DOCUMENT_NOT_FOUND("자료를 찾을 수 없습니다."), INVALID_DOCUMENT_PAGE("존재하지 않는 페이지 입니다."), + DOCUMENT_NOT_FOUND("자료를 찾을 수 없습니다."), + INVALID_DOCUMENT_PAGE("존재하지 않는 페이지 입니다."), // ocr - OCR_RESULT_NOT_FOUND("OCR 데이터를 찾을 수 없습니다."), OCR_TASK_ERROR("PDF 파일을 통해 OCR 작업을 수행하는데 실패했습니다."), + OCR_RESULT_NOT_FOUND("OCR 데이터를 찾을 수 없습니다."), + OCR_TASK_ERROR("PDF 파일을 통해 OCR 작업을 수행하는데 실패했습니다."), // folder FOLDER_NOT_FOUND("폴더를 찾을 수 없습니다."), // llm task - LLM_TASK_LOG_NOT_FOUND("AI 작업 기록을 찾을 수 없습니다."), LLM_TASK_RESULT_ERROR("AI 요약 및 문제 생성 중에 문제가 발생했습니다."), + LLM_TASK_LOG_NOT_FOUND("AI 작업 기록을 찾을 수 없습니다."), + LLM_TASK_RESULT_ERROR("AI 요약 및 문제 생성 중에 문제가 발생했습니다."), // problem PROBLEM_NOT_FOUND("문제 정보를 찾을 수 없습니다."), @@ -33,19 +36,29 @@ public enum ErrorMessages { RECORDING_NOT_FOUND("녹음 파일을 찾을 수 없습니다."), // external api call - KAKAO_API_ERROR("카카오 API 호출에 예외가 발생했습니다."), AI_SERVER_ERROR("AI 서버 API 호출에 예외가 발생했습니다."), + KAKAO_API_ERROR("카카오 API 호출에 예외가 발생했습니다."), + AI_SERVER_ERROR("AI 서버 API 호출에 예외가 발생했습니다."), // auth - INVALID_ACCESS_TOKEN("유효하지 않은 토큰입니다."), INVALID_REFRESH_TOKEN("유요하지 않은 Refresh Token입니다."), EXPIRED_REFRESH_TOKEN( - "만료된 Refresh Token입니다."), INVALID_LOGIN_TYPE("지원하지 않는 소셜 로그인 타입입니다."), NOTFOUND_ACCESS_TOKEN( - "토큰 정보가 존재하지 않습니다."), + INVALID_ACCESS_TOKEN("유효하지 않은 토큰입니다."), + INVALID_REFRESH_TOKEN("유요하지 않은 Refresh Token입니다."), + EXPIRED_REFRESH_TOKEN("만료된 Refresh Token입니다."), + INVALID_LOGIN_TYPE("지원하지 않는 소셜 로그인 타입입니다."), + NOTFOUND_ACCESS_TOKEN("토큰 정보가 존재하지 않습니다."), + + // stt + STT_TASK_NOT_FOUND("음성 인식 작업을 찾을 수 없습니다."), + STT_TASK_ERROR("음성 인식 작업 중에 오류가 발생했습니다."), // json conversion JSON_CONVERSION_ERROR("JSON-객체 변환 중에 오류가 발생했습니다."), // etc - INVALID_FILE_TYPE("지원하지 않는 파일 형식입니다."), FILE_NOT_FOUND("존재하지 않는 파일입니다."), FILE_SAVE_ERROR( - "파일을 저장하는 과정에서 오류가 발생했습니다."), INVALID_AUDIO_ENCODING("오디오 파일이 잘못되었습니다."); + INVALID_FILE_TYPE("지원하지 않는 파일 형식입니다."), + FILE_NOT_FOUND("존재하지 않는 파일입니다."), + FILE_SAVE_ERROR("파일을 저장하는 과정에서 오류가 발생했습니다."), + INVALID_AUDIO_ENCODING("오디오 파일이 잘못되었습니다."), + FILE_READ_ERROR("파일을 읽는 과정에서 오류가 발생했습니다."); private final String message; diff --git a/src/main/java/notai/pageRecording/domain/PageRecordingRepository.java b/src/main/java/notai/pageRecording/domain/PageRecordingRepository.java index 01e84fe..2a7fe96 100644 --- a/src/main/java/notai/pageRecording/domain/PageRecordingRepository.java +++ b/src/main/java/notai/pageRecording/domain/PageRecordingRepository.java @@ -1,7 +1,13 @@ package notai.pageRecording.domain; +import notai.pageRecording.query.PageRecordingQueryRepository; +import notai.recording.domain.Recording; import org.springframework.data.jpa.repository.JpaRepository; -public interface PageRecordingRepository extends JpaRepository { +import java.util.List; +public interface PageRecordingRepository extends + JpaRepository, PageRecordingQueryRepository { + + List findAllByRecording(Recording recording); } diff --git a/src/main/java/notai/pageRecording/query/PageRecordingQueryRepository.java b/src/main/java/notai/pageRecording/query/PageRecordingQueryRepository.java index fa6e7c1..b49195b 100644 --- a/src/main/java/notai/pageRecording/query/PageRecordingQueryRepository.java +++ b/src/main/java/notai/pageRecording/query/PageRecordingQueryRepository.java @@ -1,12 +1,10 @@ package notai.pageRecording.query; -import com.querydsl.jpa.impl.JPAQueryFactory; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Repository; +import notai.pageRecording.domain.PageRecording; -@Repository -@RequiredArgsConstructor -public class PageRecordingQueryRepository { +import java.util.List; - private final JPAQueryFactory queryFactory; +public interface PageRecordingQueryRepository { + + List findAllByRecordingIdOrderByStartTime(Long recordingId); } diff --git a/src/main/java/notai/pageRecording/query/PageRecordingQueryRepositoryImpl.java b/src/main/java/notai/pageRecording/query/PageRecordingQueryRepositoryImpl.java new file mode 100644 index 0000000..c2a297b --- /dev/null +++ b/src/main/java/notai/pageRecording/query/PageRecordingQueryRepositoryImpl.java @@ -0,0 +1,23 @@ +package notai.pageRecording.query; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import notai.pageRecording.domain.PageRecording; +import static notai.pageRecording.domain.QPageRecording.pageRecording; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class PageRecordingQueryRepositoryImpl implements PageRecordingQueryRepository{ + + private final JPAQueryFactory queryFactory; + + public List findAllByRecordingIdOrderByStartTime(Long recordingId) { + return queryFactory.selectFrom(pageRecording) + .where(pageRecording.recording.id.eq(recordingId)) + .orderBy(pageRecording.startTime.asc()) + .fetch(); + } +} diff --git a/src/main/java/notai/recording/application/RecordingService.java b/src/main/java/notai/recording/application/RecordingService.java index 7f65bfb..d09e819 100644 --- a/src/main/java/notai/recording/application/RecordingService.java +++ b/src/main/java/notai/recording/application/RecordingService.java @@ -2,6 +2,8 @@ import lombok.RequiredArgsConstructor; import notai.common.domain.vo.FilePath; +import static notai.common.exception.ErrorMessages.FILE_SAVE_ERROR; +import static notai.common.exception.ErrorMessages.INVALID_AUDIO_ENCODING; import notai.common.exception.type.BadRequestException; import notai.common.exception.type.InternalServerErrorException; import notai.common.utils.AudioDecoder; @@ -12,6 +14,8 @@ import notai.recording.application.result.RecordingSaveResult; import notai.recording.domain.Recording; import notai.recording.domain.RecordingRepository; +import notai.stt.application.SttTaskService; +import notai.stt.application.command.SttRequestCommand; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -20,9 +24,6 @@ import java.nio.file.Path; import java.nio.file.Paths; -import static notai.common.exception.ErrorMessages.FILE_SAVE_ERROR; -import static notai.common.exception.ErrorMessages.INVALID_AUDIO_ENCODING; - @Service @Transactional @RequiredArgsConstructor @@ -32,6 +33,7 @@ public class RecordingService { private final DocumentRepository documentRepository; private final AudioDecoder audioDecoder; private final FileManager fileManager; + private final SttTaskService sttTaskService; @Value("${file.audio.basePath}") private String audioBasePath; @@ -52,6 +54,9 @@ public RecordingSaveResult saveRecording(RecordingSaveCommand command) { fileManager.save(binaryAudioData, outputPath); savedRecording.updateFilePath(filePath); + SttRequestCommand sttCommand = new SttRequestCommand(savedRecording.getId(), filePath.getFilePath()); + sttTaskService.submitSttTask(sttCommand); + return RecordingSaveResult.of(savedRecording.getId(), foundDocument.getId(), savedRecording.getCreatedAt()); } catch (IllegalArgumentException e) { diff --git a/src/main/java/notai/stt/application/SttService.java b/src/main/java/notai/stt/application/SttService.java new file mode 100644 index 0000000..f48545d --- /dev/null +++ b/src/main/java/notai/stt/application/SttService.java @@ -0,0 +1,45 @@ +package notai.stt.application; + +import lombok.RequiredArgsConstructor; +import notai.pageRecording.domain.PageRecording; +import notai.pageRecording.domain.PageRecordingRepository; +import notai.recording.domain.Recording; +import notai.stt.application.command.UpdateSttResultCommand; +import notai.stt.application.dto.SttPageMatchedDto; +import notai.stt.domain.Stt; +import notai.stt.domain.SttRepository; +import notai.sttTask.domain.SttTask; +import notai.sttTask.domain.SttTaskRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@Transactional +@RequiredArgsConstructor +public class SttService { + private final SttRepository sttRepository; + private final SttTaskRepository sttTaskRepository; + private final PageRecordingRepository pageRecordingRepository; + + /** + * AI 서버로부터 받은 STT 결과를 처리하여 페이지별 STT 데이터를 생성하고 저장합니다. + * 1. STT 테스크와 관련 엔티티들을 조회 + * 2. 음성 인식된 단어들을 페이지와 매칭 + * 3. 매칭 결과를 저장하고 테스크를 완료 처리 + */ + public void updateSttResult(UpdateSttResultCommand command) { + SttTask sttTask = sttTaskRepository.getById(command.taskId()); + Stt stt = sttTask.getStt(); + Recording recording = stt.getRecording(); + List pageRecordings = pageRecordingRepository.findAllByRecordingIdOrderByStartTime(recording.getId()); + + SttPageMatchedDto matchedResult = stt.matchWordsWithPages(command.words(), pageRecordings); + List pageMatchedSttResults = Stt.createFromMatchedResult(recording, matchedResult); + sttRepository.saveAll(pageMatchedSttResults); + + sttTask.complete(); + sttTaskRepository.save(sttTask); + } +} diff --git a/src/main/java/notai/stt/application/SttTaskService.java b/src/main/java/notai/stt/application/SttTaskService.java new file mode 100644 index 0000000..af2cfe9 --- /dev/null +++ b/src/main/java/notai/stt/application/SttTaskService.java @@ -0,0 +1,61 @@ +package notai.stt.application; + +import lombok.RequiredArgsConstructor; +import notai.client.ai.AiClient; +import notai.client.ai.response.TaskResponse; +import static notai.common.exception.ErrorMessages.FILE_NOT_FOUND; +import static notai.common.exception.ErrorMessages.FILE_READ_ERROR; +import notai.common.exception.type.FileProcessException; +import notai.common.exception.type.NotFoundException; +import notai.llm.domain.TaskStatus; +import notai.recording.domain.Recording; +import notai.recording.domain.RecordingRepository; +import notai.stt.application.command.SttRequestCommand; +import notai.stt.domain.Stt; +import notai.stt.domain.SttRepository; +import notai.sttTask.domain.SttTask; +import notai.sttTask.domain.SttTaskRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; + +@Service +@Transactional +@RequiredArgsConstructor +public class SttTaskService { + private final AiClient aiClient; + private final SttRepository sttRepository; + private final SttTaskRepository sttTaskRepository; + private final RecordingRepository recordingRepository; + + public void submitSttTask(SttRequestCommand command) { + Recording recording = recordingRepository.getById(command.recordingId()); + File audioFile = validateAudioFile(command.audioFilePath()); + + try (FileInputStream fileInputStream = new FileInputStream(audioFile)) { + TaskResponse response = aiClient.submitSttTask(fileInputStream); + createAndSaveSttTask(recording, response); + } catch (IOException e) { + throw new FileProcessException(FILE_READ_ERROR); + } + } + + private File validateAudioFile(String audioFilePath) { + File audioFile = new File(audioFilePath); + if (!audioFile.exists()) { + throw new NotFoundException(FILE_NOT_FOUND); + } + return audioFile; + } + + private void createAndSaveSttTask(Recording recording, TaskResponse response) { + Stt stt = new Stt(recording); + sttRepository.save(stt); + + SttTask sttTask = new SttTask(response.taskId(), stt, TaskStatus.PENDING); + sttTaskRepository.save(sttTask); + } +} diff --git a/src/main/java/notai/stt/application/command/SttRequestCommand.java b/src/main/java/notai/stt/application/command/SttRequestCommand.java new file mode 100644 index 0000000..540529b --- /dev/null +++ b/src/main/java/notai/stt/application/command/SttRequestCommand.java @@ -0,0 +1,7 @@ +package notai.stt.application.command; + +public record SttRequestCommand( + Long recordingId, + String audioFilePath +) { +} diff --git a/src/main/java/notai/stt/application/command/UpdateSttResultCommand.java b/src/main/java/notai/stt/application/command/UpdateSttResultCommand.java new file mode 100644 index 0000000..c476790 --- /dev/null +++ b/src/main/java/notai/stt/application/command/UpdateSttResultCommand.java @@ -0,0 +1,16 @@ +package notai.stt.application.command; + +import java.util.List; +import java.util.UUID; + +public record UpdateSttResultCommand( + UUID taskId, + List words +) { + public record Word( + String word, + double start, + double end + ) { + } +} diff --git a/src/main/java/notai/stt/application/dto/SttPageMatchedDto.java b/src/main/java/notai/stt/application/dto/SttPageMatchedDto.java new file mode 100644 index 0000000..7be4a98 --- /dev/null +++ b/src/main/java/notai/stt/application/dto/SttPageMatchedDto.java @@ -0,0 +1,19 @@ +package notai.stt.application.dto; + +import java.util.List; + +public record SttPageMatchedDto( + List pageContents +) { + public record PageMatchedContent( + Integer pageNumber, + String content, + List words + ) {} + + public record PageMatchedWord( + String word, + Integer startTime, + Integer endTime + ) {} +} diff --git a/src/main/java/notai/stt/domain/Stt.java b/src/main/java/notai/stt/domain/Stt.java new file mode 100644 index 0000000..46794d6 --- /dev/null +++ b/src/main/java/notai/stt/domain/Stt.java @@ -0,0 +1,150 @@ +package notai.stt.domain; + +import jakarta.persistence.*; +import static jakarta.persistence.FetchType.LAZY; +import jakarta.validation.constraints.NotNull; +import static lombok.AccessLevel.PROTECTED; +import lombok.Getter; +import lombok.NoArgsConstructor; +import notai.common.domain.RootEntity; +import notai.pageRecording.domain.PageRecording; +import notai.recording.domain.Recording; +import notai.stt.application.command.UpdateSttResultCommand; +import notai.stt.application.dto.SttPageMatchedDto; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.stream.Collectors; + +@Getter +@NoArgsConstructor(access = PROTECTED) +@Entity +@Table(name = "stt") +public class Stt extends RootEntity { + + // Todo: 실제 테스트해보며 오차 시간 조정 + // 페이지 매칭 시 허용되는 시간 오차 (초) + private static final double TIME_THRESHOLD = 0.0; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @NotNull + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "recording_id") + private Recording recording; + + private Integer pageNumber; + + @Column(columnDefinition = "TEXT") + private String content; + + private Integer startTime; + + private Integer endTime; + + public Stt(Recording recording) { + this.recording = recording; + } + + public Stt(Recording recording, Integer pageNumber, String content, Integer startTime, Integer endTime) { + this.recording = recording; + this.pageNumber = pageNumber; + this.content = content; + this.startTime = startTime; + this.endTime = endTime; + } + + /** + * 페이지별 STT 결과로부터 새로운 STT 엔티티를 생성합니다. + * 시작/종료 시간은 페이지 내 첫/마지막 단어의 시간으로 설정합니다. + */ + public static Stt createFromPageContent(Recording recording, SttPageMatchedDto.PageMatchedContent content) { + return new Stt( + recording, + content.pageNumber(), + content.content(), + content.words().get(0).startTime(), + content.words().get(content.words().size() - 1).endTime() + ); + } + + /** + * 음성 인식된 단어들을 페이지 기록과 매칭하여 페이지별 STT 결과를 생성합니다. + */ + public SttPageMatchedDto matchWordsWithPages( + List words, + List pageRecordings + ) { + if (pageRecordings.isEmpty()) { + return new SttPageMatchedDto(List.of()); + } + + // 페이지 번호 순으로 자동 정렬됨 + Map> pageWordMap = new TreeMap<>(); + int wordIndex = 0; + PageRecording lastPage = pageRecordings.get(pageRecordings.size() - 1); + + // 각 페이지별로 매칭되는 단어들을 찾아 처리 + for (PageRecording page : pageRecordings) { + List pageWords = new ArrayList<>(); + double pageStart = page.getStartTime(); + double pageEnd = page.getEndTime(); + + // 현재 페이지의 시간 범위에 속하는 단어들을 찾아 매칭 + while (wordIndex < words.size()) { + UpdateSttResultCommand.Word word = words.get(wordIndex); + + // 페이지 시작 시간보다 이른 단어는 건너뛰기 + if (word.start() + TIME_THRESHOLD < pageStart) { + wordIndex++; + continue; + } + + // 마지막 페이지가 아닐 경우, 페이지 종료 시간을 벗어난 단어가 나오면 다음 페이지로 + if (page != lastPage && word.start() - TIME_THRESHOLD >= pageEnd) { + break; + } + + // 현재 페이지에 단어 매칭하여 추가 + pageWords.add(new SttPageMatchedDto.PageMatchedWord( + word.word(), + (int) word.start(), + (int) word.end() + )); + wordIndex++; + } + + // 매칭된 단어가 있는 경우만 맵에 추가 + if (!pageWords.isEmpty()) { + pageWordMap.put(page.getPageNumber(), pageWords); + } + } + + // 페이지별로 단어들을 하나의 텍스트로 합치는 과정 + List pageContents = pageWordMap.entrySet().stream() + .map(entry -> { + Integer pageNumber = entry.getKey(); + List pageWords = entry.getValue(); + String combinedContent = pageWords.stream() + .map(SttPageMatchedDto.PageMatchedWord::word) + .collect(Collectors.joining(" ")); + return new SttPageMatchedDto.PageMatchedContent(pageNumber, combinedContent, pageWords); + }) + .toList(); + + return new SttPageMatchedDto(pageContents); + } + + /** + * 페이지 매칭 결과로부터 STT 엔티티들을 생성하고 저장합니다. + */ + public static List createFromMatchedResult(Recording recording, SttPageMatchedDto matchedResult) { + return matchedResult.pageContents().stream() + .map(content -> createFromPageContent(recording, content)) + .toList(); + } +} diff --git a/src/main/java/notai/stt/domain/SttRepository.java b/src/main/java/notai/stt/domain/SttRepository.java new file mode 100644 index 0000000..88f2064 --- /dev/null +++ b/src/main/java/notai/stt/domain/SttRepository.java @@ -0,0 +1,6 @@ +package notai.stt.domain; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface SttRepository extends JpaRepository { +} diff --git a/src/main/java/notai/stt/presentation/SttCallbackController.java b/src/main/java/notai/stt/presentation/SttCallbackController.java new file mode 100644 index 0000000..d048dae --- /dev/null +++ b/src/main/java/notai/stt/presentation/SttCallbackController.java @@ -0,0 +1,22 @@ +package notai.stt.presentation; + +import lombok.RequiredArgsConstructor; +import notai.stt.application.SttService; +import notai.stt.presentation.request.SttCallbackRequest; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +public class SttCallbackController { + + private final SttService sttService; + + @PostMapping("/api/ai/stt/callback") + public ResponseEntity sttCallback(@RequestBody SttCallbackRequest request) { + sttService.updateSttResult(request.toCommand()); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/notai/stt/presentation/request/SttCallbackRequest.java b/src/main/java/notai/stt/presentation/request/SttCallbackRequest.java new file mode 100644 index 0000000..1e58bc7 --- /dev/null +++ b/src/main/java/notai/stt/presentation/request/SttCallbackRequest.java @@ -0,0 +1,38 @@ +package notai.stt.presentation.request; + +import notai.stt.application.command.UpdateSttResultCommand; +import notai.stt.application.command.UpdateSttResultCommand.Word; + +import java.util.List; +import java.util.UUID; + +public record SttCallbackRequest( + String taskId, + String state, + SttResult result +) { + public UpdateSttResultCommand toCommand() { + List words = result.words().stream() + .map(word -> new Word( + word.word(), + word.start(), + word.end() + )) + .toList(); + return new UpdateSttResultCommand(UUID.fromString(taskId), words); + } + + public record SttResult( + double audioLength, + String language, + double languageProbability, + String text, + List words + ) { + public record Word( + double start, + double end, + String word + ) {} + } +} diff --git a/src/main/java/notai/sttTask/domain/SttTask.java b/src/main/java/notai/sttTask/domain/SttTask.java new file mode 100644 index 0000000..4897c13 --- /dev/null +++ b/src/main/java/notai/sttTask/domain/SttTask.java @@ -0,0 +1,43 @@ +package notai.sttTask.domain; + +import static jakarta.persistence.CascadeType.PERSIST; +import jakarta.persistence.*; +import static jakarta.persistence.FetchType.LAZY; +import jakarta.validation.constraints.NotNull; +import static lombok.AccessLevel.PROTECTED; +import lombok.Getter; +import lombok.NoArgsConstructor; +import notai.common.domain.RootEntity; +import notai.llm.domain.TaskStatus; +import notai.stt.domain.Stt; + +import java.util.UUID; + +@Getter +@NoArgsConstructor(access = PROTECTED) +@Entity +@Table(name = "stt_task") +public class SttTask extends RootEntity { + + @Id + private UUID id; + + @OneToOne(fetch = LAZY, cascade = PERSIST) + @JoinColumn(name = "stt_id") + private Stt stt; + + @NotNull + @Enumerated(EnumType.STRING) + @Column(length = 20) + private TaskStatus status; + + public SttTask(UUID id, Stt stt, TaskStatus status) { + this.id = id; + this.stt = stt; + this.status = status; + } + + public void complete() { + this.status = TaskStatus.COMPLETED; + } +} diff --git a/src/main/java/notai/sttTask/domain/SttTaskRepository.java b/src/main/java/notai/sttTask/domain/SttTaskRepository.java new file mode 100644 index 0000000..08956f1 --- /dev/null +++ b/src/main/java/notai/sttTask/domain/SttTaskRepository.java @@ -0,0 +1,14 @@ +package notai.sttTask.domain; + +import static notai.common.exception.ErrorMessages.AI_SERVER_ERROR; +import notai.common.exception.type.NotFoundException; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.UUID; + +public interface SttTaskRepository extends JpaRepository { + + default SttTask getById(UUID id) { + return findById(id).orElseThrow(() -> new NotFoundException(AI_SERVER_ERROR)); + } +} diff --git a/src/test/java/notai/client/ai/AiClientIntegrationTest.java b/src/test/java/notai/client/ai/AiClientIntegrationTest.java index ebd27d3..782f4b8 100644 --- a/src/test/java/notai/client/ai/AiClientIntegrationTest.java +++ b/src/test/java/notai/client/ai/AiClientIntegrationTest.java @@ -8,7 +8,8 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.mock.web.MockMultipartFile; +import java.io.ByteArrayInputStream; +import java.io.InputStream; @SpringBootTest @Tag("exclude-test") // 테스트 필요할때 주석 @@ -34,16 +35,15 @@ class AiClientIntegrationTest { @Test void STT_태스크_제출_통합_테스트() { // Given - MockMultipartFile audioFile = new MockMultipartFile( - "audio", "test.mp3", "audio/mpeg", "test audio content".getBytes() - ); + byte[] audioBytes = "test audio content".getBytes(); + InputStream audioInputStream = new ByteArrayInputStream(audioBytes); // When - TaskResponse response = aiClient.submitSttTask(audioFile); + TaskResponse response = aiClient.submitSttTask(audioInputStream); // Then assertNotNull(response); assertNotNull(response.taskId()); - assertEquals("llm", response.taskType()); + assertEquals("stt", response.taskType()); } } diff --git a/src/test/java/notai/client/ai/AiClientTest.java b/src/test/java/notai/client/ai/AiClientTest.java index 2fab457..b447e0a 100644 --- a/src/test/java/notai/client/ai/AiClientTest.java +++ b/src/test/java/notai/client/ai/AiClientTest.java @@ -8,8 +8,8 @@ import org.mockito.Mock; import static org.mockito.Mockito.*; import org.mockito.MockitoAnnotations; -import org.springframework.web.multipart.MultipartFile; +import java.io.InputStream; import java.util.UUID; class AiClientTest { @@ -41,16 +41,16 @@ void setUp() { @Test void STT_테스크_전달_테스트() { // Given - MultipartFile mockAudioFile = mock(MultipartFile.class); + InputStream inputStream = mock(InputStream.class); UUID expectedTaskId = UUID.randomUUID(); TaskResponse expectedResponse = new TaskResponse(expectedTaskId, "stt"); - when(aiClient.submitSttTask(mockAudioFile)).thenReturn(expectedResponse); + when(aiClient.submitSttTask(inputStream)).thenReturn(expectedResponse); // When - TaskResponse response = aiClient.submitSttTask(mockAudioFile); + TaskResponse response = aiClient.submitSttTask(inputStream); // Then assertEquals(expectedResponse, response); - verify(aiClient, times(1)).submitSttTask(mockAudioFile); + verify(aiClient, times(1)).submitSttTask(inputStream); } } diff --git a/src/test/java/notai/pageRecording/application/PageRecordingServiceTest.java b/src/test/java/notai/pageRecording/application/PageRecordingServiceTest.java index 3d89a01..085a4ca 100644 --- a/src/test/java/notai/pageRecording/application/PageRecordingServiceTest.java +++ b/src/test/java/notai/pageRecording/application/PageRecordingServiceTest.java @@ -8,15 +8,14 @@ import notai.recording.domain.RecordingRepository; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import static org.mockito.BDDMockito.given; import org.mockito.InjectMocks; import org.mockito.Mock; +import static org.mockito.Mockito.*; import org.mockito.junit.jupiter.MockitoExtension; import java.util.List; -import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.*; - @ExtendWith(MockitoExtension.class) class PageRecordingServiceTest { @@ -35,8 +34,7 @@ class PageRecordingServiceTest { Long recordingId = 1L; Long documentId = 1L; - PageRecordingSaveCommand command = new PageRecordingSaveCommand( - recordingId, + PageRecordingSaveCommand command = new PageRecordingSaveCommand(recordingId, documentId, List.of(new PageRecordingSession(1, 100.0, 185.5), new PageRecordingSession(5, 185.5, 290.3)) ); @@ -51,4 +49,4 @@ class PageRecordingServiceTest { // then verify(pageRecordingRepository, times(2)).save(any(PageRecording.class)); } -} \ No newline at end of file +} diff --git a/src/test/java/notai/stt/application/SttServiceTest.java b/src/test/java/notai/stt/application/SttServiceTest.java new file mode 100644 index 0000000..f80d455 --- /dev/null +++ b/src/test/java/notai/stt/application/SttServiceTest.java @@ -0,0 +1,124 @@ +package notai.stt.application; + +import static notai.common.exception.ErrorMessages.AI_SERVER_ERROR; +import notai.common.exception.type.NotFoundException; +import notai.pageRecording.domain.PageRecording; +import notai.pageRecording.domain.PageRecordingRepository; +import notai.recording.domain.Recording; +import notai.stt.application.command.UpdateSttResultCommand; +import notai.stt.application.dto.SttPageMatchedDto; +import notai.stt.domain.Stt; +import notai.stt.domain.SttRepository; +import notai.sttTask.domain.SttTask; +import notai.sttTask.domain.SttTaskRepository; +import static org.junit.jupiter.api.Assertions.assertThrows; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import static org.mockito.Mockito.*; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.UUID; + +@ExtendWith(MockitoExtension.class) +class SttServiceTest { + + @InjectMocks + private SttService sttService; + + @Mock + private SttRepository sttRepository; + + @Mock + private SttTaskRepository sttTaskRepository; + + @Mock + private PageRecordingRepository pageRecordingRepository; + + @Test + void STT_결과_업데이트_성공() { + // given + UUID taskId = UUID.randomUUID(); + List words = List.of( + new UpdateSttResultCommand.Word("테스트", 1.0, 2.0), + new UpdateSttResultCommand.Word("음성인식", 3.1, 4) + ); + UpdateSttResultCommand command = new UpdateSttResultCommand(taskId, words); + + SttTask sttTask = mock(SttTask.class); + Stt stt = mock(Stt.class); + Recording recording = mock(Recording.class); + List pageRecordings = List.of(mock(PageRecording.class)); + + when(sttTaskRepository.getById(taskId)).thenReturn(sttTask); + when(sttTask.getStt()).thenReturn(stt); + when(stt.getRecording()).thenReturn(recording); + when(recording.getId()).thenReturn(1L); + when(pageRecordingRepository.findAllByRecordingIdOrderByStartTime(1L)).thenReturn(pageRecordings); + + List matchedWords = List.of( + new SttPageMatchedDto.PageMatchedWord("테스트", 1, 2), + new SttPageMatchedDto.PageMatchedWord("음성인식", 3, 4) + ); + SttPageMatchedDto matchedResult = new SttPageMatchedDto(List.of( + new SttPageMatchedDto.PageMatchedContent(1, "테스트 음성인식", matchedWords))); + when(stt.matchWordsWithPages(words, pageRecordings)).thenReturn(matchedResult); + + // when + sttService.updateSttResult(command); + + // then + verify(sttTask).complete(); + verify(sttTaskRepository).save(sttTask); + verify(sttRepository).saveAll(argThat(sttList -> { + List results = (List) sttList; + return results.size() == 1; // 예상되는 STT 엔티티 개수 확인 + })); + } + + @Test + void STT_결과_업데이트_실패_태스크_없음() { + // given + UUID taskId = UUID.randomUUID(); + List words = List.of(new UpdateSttResultCommand.Word("테스트", 1.0, 2.0)); + UpdateSttResultCommand command = new UpdateSttResultCommand(taskId, words); + + when(sttTaskRepository.getById(taskId)).thenThrow(new NotFoundException(AI_SERVER_ERROR)); + + // when & then + assertThrows(NotFoundException.class, () -> sttService.updateSttResult(command)); + verify(sttRepository, never()).saveAll(any()); + } + + @Test + void STT_결과_업데이트_빈_페이지_리스트() { + // given + UUID taskId = UUID.randomUUID(); + List words = List.of(new UpdateSttResultCommand.Word("테스트", 1.0, 2.0)); + + UpdateSttResultCommand command = new UpdateSttResultCommand(taskId, words); + + SttTask sttTask = mock(SttTask.class); + Stt stt = mock(Stt.class); + Recording recording = mock(Recording.class); + + when(sttTaskRepository.getById(taskId)).thenReturn(sttTask); + when(sttTask.getStt()).thenReturn(stt); + when(stt.getRecording()).thenReturn(recording); + when(recording.getId()).thenReturn(1L); + when(pageRecordingRepository.findAllByRecordingIdOrderByStartTime(1L)).thenReturn(List.of()); + + SttPageMatchedDto matchedResult = new SttPageMatchedDto(List.of()); + when(stt.matchWordsWithPages(words, List.of())).thenReturn(matchedResult); + + // when + sttService.updateSttResult(command); + + // then + verify(sttTask).complete(); + verify(sttTaskRepository).save(sttTask); + verify(sttRepository).saveAll(argThat(sttList -> ((List) sttList).isEmpty())); + } +} diff --git a/src/test/java/notai/stt/domain/SttTest.java b/src/test/java/notai/stt/domain/SttTest.java new file mode 100644 index 0000000..5a6df06 --- /dev/null +++ b/src/test/java/notai/stt/domain/SttTest.java @@ -0,0 +1,287 @@ +package notai.stt.domain; + +import notai.pageRecording.domain.PageRecording; +import notai.recording.domain.Recording; +import notai.stt.application.command.UpdateSttResultCommand; +import notai.stt.application.dto.SttPageMatchedDto; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; + +@ExtendWith(MockitoExtension.class) +class SttTest { + + @Test + void 페이지_매칭_빈_페이지_리스트() { + // given + Recording recording = mock(Recording.class); + Stt stt = new Stt(recording); + List words = List.of( + new UpdateSttResultCommand.Word("테스트", 1.0, 2.0) + ); + + // when + SttPageMatchedDto result = stt.matchWordsWithPages(words, List.of()); + + // then + assertThat(result.pageContents()).isEmpty(); + } + + @Test + void 페이지_매칭_정상_케이스() { + // given + Recording recording = mock(Recording.class); + Stt stt = new Stt(recording); + + List words = List.of( + new UpdateSttResultCommand.Word("첫번째", 1.0, 2.0), + new UpdateSttResultCommand.Word("두번째", 2.5, 3.5), + new UpdateSttResultCommand.Word("세번째", 4.0, 5.0) + ); + + + List pages = List.of( + createPageRecording(1, 0.0, 3.0), + createPageRecording(2, 3.0, 6.0) + ); + + // when + SttPageMatchedDto result = stt.matchWordsWithPages(words, pages); + + // then + assertAll( + () -> assertThat(result.pageContents()).hasSize(2), + () -> assertThat(result.pageContents().get(0).pageNumber()).isEqualTo(1), + () -> assertThat(result.pageContents().get(0).content()).isEqualTo("첫번째 두번째"), + () -> assertThat(result.pageContents().get(1).pageNumber()).isEqualTo(2), + () -> assertThat(result.pageContents().get(1).content()).isEqualTo("세번째") + ); + } + + @Test + void 페이지_컨텐츠로부터_STT_엔티티_생성() { + // given + Recording recording = mock(Recording.class); + List words = List.of( + new SttPageMatchedDto.PageMatchedWord("테스트", 100, 200), + new SttPageMatchedDto.PageMatchedWord("단어", 300, 400) + ); + SttPageMatchedDto.PageMatchedContent content = new SttPageMatchedDto.PageMatchedContent( + 1, + "테스트 단어", + words + ); + + // when + Stt result = Stt.createFromPageContent(recording, content); + + // then + assertAll( + () -> assertThat(result.getPageNumber()).isEqualTo(1), + () -> assertThat(result.getContent()).isEqualTo("테스트 단어"), + () -> assertThat(result.getStartTime()).isEqualTo(100), + () -> assertThat(result.getEndTime()).isEqualTo(400) + ); + } + + @Test + void 페이지_매칭_비순차적_페이지_번호() { + // given + Recording recording = mock(Recording.class); + Stt stt = new Stt(recording); + + List words = List.of( + new UpdateSttResultCommand.Word("word1", 1.0, 2.0), + new UpdateSttResultCommand.Word("word2", 6.0, 7.0), + new UpdateSttResultCommand.Word("word3", 8.0, 9.0), + new UpdateSttResultCommand.Word("word4", 10.0, 11.0) + ); + + List pages = List.of( + createPageRecording(1, 0.0, 3.0), + createPageRecording(5, 5.0, 7.0), + createPageRecording(3, 7.0, 9.0), + createPageRecording(4, 9.0, 12.0) + ); + + // when + SttPageMatchedDto result = stt.matchWordsWithPages(words, pages); + + // then + assertAll( + () -> assertThat(result.pageContents()).hasSize(4), + () -> assertThat(result.pageContents().get(0).pageNumber()).isEqualTo(1), + () -> assertThat(result.pageContents().get(0).content()).isEqualTo("word1"), + () -> assertThat(result.pageContents().get(1).pageNumber()).isEqualTo(3), + () -> assertThat(result.pageContents().get(1).content()).isEqualTo("word3"), + () -> assertThat(result.pageContents().get(2).pageNumber()).isEqualTo(4), + () -> assertThat(result.pageContents().get(2).content()).isEqualTo("word4"), + () -> assertThat(result.pageContents().get(3).pageNumber()).isEqualTo(5), + () -> assertThat(result.pageContents().get(3).content()).isEqualTo("word2") + ); + } + + @Test + void 페이지_매칭_시간_경계값_테스트() { + // given + Recording recording = mock(Recording.class); + Stt stt = new Stt(recording); + + List words = List.of( + new UpdateSttResultCommand.Word("경계단어1", 2.99, 3.5), // 첫 페이지 끝에 걸침 + new UpdateSttResultCommand.Word("경계단어2", 3.0, 3.8), // 정확히 두번째 페이지 시작 + new UpdateSttResultCommand.Word("경계단어3", 5.01, 6.0), // 두번째 페이지 끝에 걸침 + new UpdateSttResultCommand.Word("경계단어4", 5.51, 6.2) // 벗어나 세번째 페이지로 분류 + ); + + List pages = List.of( + createPageRecording(1, 0.0, 3.0), + createPageRecording(2, 3.0, 5.5), + createPageRecording(3, 5.5, 8.0) + ); + + // when + SttPageMatchedDto result = stt.matchWordsWithPages(words, pages); + + // then + assertAll( + () -> assertThat(result.pageContents()).hasSize(3), + () -> assertThat(result.pageContents().get(0).pageNumber()).isEqualTo(1), + () -> assertThat(result.pageContents().get(0).content()).isEqualTo("경계단어1"), + () -> assertThat(result.pageContents().get(1).pageNumber()).isEqualTo(2), + () -> assertThat(result.pageContents().get(1).content()).isEqualTo("경계단어2 경계단어3"), + () -> assertThat(result.pageContents().get(2).pageNumber()).isEqualTo(3), + () -> assertThat(result.pageContents().get(2).content()).isEqualTo("경계단어4") + ); + } + + @Test + void 페이지_매칭_마지막_페이지_특수처리() { + // given + Recording recording = mock(Recording.class); + Stt stt = new Stt(recording); + + List words = List.of( + new UpdateSttResultCommand.Word("정상단어", 1.0, 2.0), + new UpdateSttResultCommand.Word("늦은단어1", 7.0, 8.0), + // 마지막 페이지 종료 시간 이후 + new UpdateSttResultCommand.Word("늦은단어2", 8.0, 9.0) + // 마지막 페이지에 포함되어야 함 + ); + + List pages = List.of(createPageRecording(1, 0.0, 3.0), createPageRecording(2, 3.0, 6.0)); + + // when + SttPageMatchedDto result = stt.matchWordsWithPages(words, pages); + + // then + assertAll( + () -> assertThat(result.pageContents()).hasSize(2), + () -> assertThat(result.pageContents().get(0).pageNumber()).isEqualTo(1), + () -> assertThat(result.pageContents().get(0).content()).isEqualTo("정상단어"), + () -> assertThat(result.pageContents().get(1).pageNumber()).isEqualTo(2), + () -> assertThat(result.pageContents().get(1).content()).isEqualTo("늦은단어1 늦은단어2") + // 마지막 페이지는 시간 제한 없이 모든 단어 포함 + ); + } + + @Test + void 매칭_결과로부터_여러_STT_엔티티_생성() { + // given + Recording recording = mock(Recording.class); + List contents = List.of( + new SttPageMatchedDto.PageMatchedContent( + 1, + "첫번째 페이지", + List.of( + new SttPageMatchedDto.PageMatchedWord("첫번째", 1, 2), + new SttPageMatchedDto.PageMatchedWord("페이지", 2, 3) + ) + ), + new SttPageMatchedDto.PageMatchedContent( + 2, + "두번째 페이지", + List.of( + new SttPageMatchedDto.PageMatchedWord("두번째", 4, 5), + new SttPageMatchedDto.PageMatchedWord("페이지", 5, 6) + ) + ) + ); + SttPageMatchedDto matchedResult = new SttPageMatchedDto(contents); + + // when + List results = Stt.createFromMatchedResult(recording, matchedResult); + + // then + assertAll(() -> assertThat(results).hasSize(2), () -> { + Stt firstStt = results.get(0); + assertThat(firstStt.getRecording()).isEqualTo(recording); + assertThat(firstStt.getPageNumber()).isEqualTo(1); + assertThat(firstStt.getContent()).isEqualTo("첫번째 페이지"); + assertThat(firstStt.getStartTime()).isEqualTo(1); + assertThat(firstStt.getEndTime()).isEqualTo(3); + }, () -> { + Stt secondStt = results.get(1); + assertThat(secondStt.getRecording()).isEqualTo(recording); + assertThat(secondStt.getPageNumber()).isEqualTo(2); + assertThat(secondStt.getContent()).isEqualTo("두번째 페이지"); + assertThat(secondStt.getStartTime()).isEqualTo(4); + assertThat(secondStt.getEndTime()).isEqualTo(6); + }); + } + + @Test + void 매칭_결과가_비어있을때_빈_리스트_반환() { + // given + Recording recording = mock(Recording.class); + SttPageMatchedDto matchedResult = new SttPageMatchedDto(List.of()); + + // when + List results = Stt.createFromMatchedResult(recording, matchedResult); + + // then + assertThat(results).isEmpty(); + } + + @Test + void 페이지_매칭_결과_순서_보장() { + // given + Recording recording = mock(Recording.class); + Stt stt = new Stt(recording); + + // startTime 기준으로 정렬된 words + List words = List.of( + new UpdateSttResultCommand.Word("1번", 1.0, 2.0), + new UpdateSttResultCommand.Word("3번", 6.0, 7.0), + new UpdateSttResultCommand.Word("5번", 10.0, 11.0) + ); + + // startTime 기준으로 정렬된 pages + List pages = List.of( + createPageRecording(1, 0.0, 3.0), + createPageRecording(5, 5.0, 8.0), + createPageRecording(3, 9.0, 12.0) + ); + + // when + SttPageMatchedDto result = stt.matchWordsWithPages(words, pages); + + // then + assertThat(result.pageContents()).extracting(SttPageMatchedDto.PageMatchedContent::pageNumber) + .containsExactly(1, 3, 5); // 페이지 번호 순서 검증 + } + + private PageRecording createPageRecording(int pageNumber, double startTime, double endTime) { + PageRecording pageRecording = mock(PageRecording.class); + when(pageRecording.getPageNumber()).thenReturn(pageNumber); + when(pageRecording.getStartTime()).thenReturn(startTime); + when(pageRecording.getEndTime()).thenReturn(endTime); + return pageRecording; + } +}