Skip to content
Open
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
25 changes: 13 additions & 12 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,19 +13,20 @@ Server (Spring Boot) ──REST──▶ ai (FastAPI)
│ │ ├─ Gemini (텍스트 생성·임베딩)
│ │ ├─ Deepgram (음성 전사)
│ │ └─ ChromaDB (커밋·적용사항 임베딩)
│ └──REST──▶ LiveKit (실시간 오디오/비디오 SFU + 녹음 egress)
└──REST──▶ GitHub (커밋 이력 조회, kohsuke GitHub API)
└──S3

Web ◀──P2P WebRTC(오디오)──▶ Web (시그널링은 Server WebSocket이 중계)
```

Web과 LiveKit은 예외적으로 직접 연결된다(RTC). 그 외 모든 통신은 Server를 거친다 — ai·GitHub·S3·LiveKit 토큰 발급은 Web이 직접 호출하지 않는다.
브라우저끼리의 오디오만 P2P로 직접 오간다. 그 외 모든 통신은 Server를 거친다 — ai·GitHub·S3를 Web이 직접 호출하지 않는다.

## 파트별 역할

| 파트 | 책임 | 책임이 아닌 것 |
| --- | --- | --- |
| **Web** | 화면, 브라우저 Web Speech API로 실시간 자막 생성, LiveKit RTC 클라이언트 연결, Server REST/WebSocket 호출 | 원문 회의 분석, 커밋 매칭 점수 계산, 파일 저장 — 전부 Server·ai가 한다 |
| **Server** | 도메인 상태(Team/Meeting/Decision/Application/Commit)의 정본 저장, 외부 시스템(ai·LiveKit·GitHub·S3) 오케스트레이션, 인증 | 전사·요약·임베딩·매칭 점수 계산 자체는 하지 않는다 — 전부 ai에 위임하고 결과만 저장 |
| **Web** | 화면, 브라우저 Web Speech API로 실시간 자막 생성, P2P WebRTC mesh 연결과 발화 표시, Server REST/WebSocket 호출 | 원문 회의 분석, 커밋 매칭 점수 계산, 파일 저장 — 전부 Server·ai가 한다 |
| **Server** | 도메인 상태(Team/Meeting/Decision/Application/Commit)의 정본 저장, 외부 시스템(ai·GitHub·S3) 오케스트레이션, WebRTC 시그널링 중계, 인증 | 전사·요약·임베딩·매칭 점수 계산 자체는 하지 않는다 — 전부 ai에 위임하고 결과만 저장 |
| **ai** | 음성 전사(Deepgram), 회의 분석·적용사항 추출(Gemini), 커밋 요약·임베딩(Gemini), 임베딩 유사도 기반 매칭 점수 계산(ChromaDB) | 도메인 데이터의 정본을 갖지 않는다 — 매 요청마다 필요한 컨텍스트를 Server가 실어 보낸다. 인증/인가도 하지 않는다 |

## 인증
Expand All @@ -34,18 +35,18 @@ Web과 LiveKit은 예외적으로 직접 연결된다(RTC). 그 외 모든 통
- Web은 access 토큰을 메모리에만 보관하고, 매 요청에 `Authorization: Bearer`로 주입한다. 401을 받으면 refresh로 재발급을 시도한다.
- ai는 별도 인증이 없다 — Server만 호출한다는 전제로 신뢰 경계 안에 둔다.

## 서비스 흐름 1 — 실시간 회의 (Web ↔ LiveKit, Web ↔ Server)
## 서비스 흐름 1 — 실시간 회의 (Web ↔ Web, Web ↔ Server)

회의 중 오디오/비디오와 "실시간 텍스트"는 서로 다른 두 경로로 흐른다.
회의 중 오디오와 "실시간 텍스트"는 서로 다른 두 경로로 흐른다.

- **오디오/비디오**: Web은 Server가 발급한 토큰으로 LiveKit에 직접 연결해 RTC를 주고받는다. LiveKit이 room 전체 오디오를 녹음(egress)해 S3에 저장하고, 이 원본 오디오가 회의 종료 후 "흐름 2"의 공식 전사 입력이 된다.
- **오디오**: 브라우저끼리 P2P WebRTC mesh로 직접 주고받는다. offer/answer/ICE 시그널링만 Server의 회의 WebSocket이 1:1로 중계한다. **녹음은 하지 않는다** — 회의록의 원본은 아래 실시간 텍스트다.
- **실시간 텍스트**: Web이 브라우저 Web Speech API로 발화를 즉석에서 전사하고, 그 결과를 Server가 여는 별도의 WebSocket으로 보낸다. Server는 이를 참가자에게 브로드캐스트하는 동시에 회의별로 버퍼링해둔다 — 화면 자막용이자, 회의 종료 후 공식 분석에 참고 컨텍스트로 함께 전달된다.
- 회의 종료(참여자가 직접 종료하거나, 실시간 참여자가 전부 빠져서 자동 종료)는 항상 같은 처리로 수렴한다: 녹음 중지 → 방 정리 → "흐름 2" 비동기 시작.
- 회의 종료(참여자가 직접 종료하거나, 실시간 참여자가 전부 빠져서 자동 종료)는 항상 같은 처리로 수렴한다: 방 정리 → "흐름 2" 비동기 시작.

## 서비스 흐름 2 — 회의 종료 후 AI 분석 (Server ↔ ai)

1. Server가 녹음된 오디오와 실시간 텍스트 버퍼를 ai에 넘겨 전사+분석 실행(run)을 비동기로 시작시킨다.
2. **ai**가 Deepgram으로 오디오를 전사하고, Gemini로 논의 주제·결정 근거·적용사항·타임라인을 추출한다. 진행 단계는 전사중 → 전사완료 → 요약완료 → 적용사항추출완료로 나뉘며, Server는 완료될 때까지 상태를 조회(폴링)한다.
1. Server가 저장된 발화(회의록)를 전사 세그먼트로 만들어 ai의 회의 분석 API에 넘긴다. 재생 가능한 오디오가 남아 있는 과거 회의만 예외적으로 Deepgram 전사 경로를 탄다.
2. **ai**가 Gemini로 논의 주제·결정 근거·적용사항·타임라인을 추출한다. 오디오 전사 경로일 때만 진행 단계(전사중 → 전사완료 → 요약완료 → 적용사항추출완료)를 Server가 폴링한다.
3. **Server**가 결과를 도메인 엔티티로 저장한다: 발화 목록, 회의 요약, 결정(Decision, 회의당 1개), 적용사항과 그 근거·타임라인. **적용사항 계열은 매번 전부 삭제 후 재생성한다 — 재분석은 append가 아니라 스냅샷 교체다.**
4. 저장이 끝나면 Server가 그 적용사항들을 다시 **ai**에 보내 임베딩을 만들게 하고(ChromaDB 저장), 이어서 커밋 자동 매칭을 요청한다. 이 두 단계는 실패해도 전체 흐름을 되돌리지 않는 best-effort다 — 분석 결과 저장 자체는 이미 끝났기 때문이다.
5. 매칭 점수 계산 로직은 **ai**에 있고(`docs/domain.md` 참조), Server는 결과를 신뢰도 임계값으로 한 번 더 걸러 저장할 뿐 점수 자체를 계산하지 않는다.
Expand All @@ -65,7 +66,7 @@ AI 추천 매칭과 별개로, Web에서 사용자가 적용사항에 커밋을

## 경계와 소유권

- **ai를 직접 호출하는 곳은 Server 안의 한 경계 뿐이다.** 도메인 서비스가 ai SDK나 HTTP 호출을 직접 들고 있지 않고, 전용 클라이언트를 주입받아 쓴다. LiveKit·S3·GitHub 연동도 같은 원칙으로 별도 경계에 모여 있다.
- **ai를 직접 호출하는 곳은 Server 안의 한 경계 뿐이다.** 도메인 서비스가 ai SDK나 HTTP 호출을 직접 들고 있지 않고, 전용 클라이언트를 주입받아 쓴다. S3·GitHub 연동도 같은 원칙으로 별도 경계에 모여 있다.
- **ai는 도메인 데이터의 정본을 갖지 않는다.** ChromaDB 임베딩은 매칭 계산용 파생 데이터일 뿐, 결정·적용사항·커밋 연결의 정본은 항상 Server DB다.
- ai 쪽에서 프롬프트·매칭 가중치·ChromaDB 스키마를 바꾸면 Server가 파싱하는 응답 형태도 함께 깨질 수 있어 담당자 확인이 필요하다.
- 외부 호출(S3, ai, LiveKit, GitHub)은 Server의 트랜잭션 안에서 하지 않는다. 커밋 이후 실행이 필요하면 afterCommit 시점으로 미룬다.
- 외부 호출(S3, ai, GitHub)은 Server의 트랜잭션 안에서 하지 않는다. 커밋 이후 실행이 필요하면 afterCommit 시점으로 미룬다.
1 change: 0 additions & 1 deletion docs/domain.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,6 @@ WhyLog는 팀 회의를 녹음·전사하고 AI로 논의 주제와 결정 근
- 실시간 참여자가 모두 빠지면(`meetingSocketRoomService`의 참여자 목록이 비면) 서버가 자동으로 회의를 종료 처리한다(`autoEndMeetingIfEmpty`). 이때는 참여자에게 종료를 브로드캐스트하지 않는다(정상 종료와 구분).
- 회의 종료 시 `isNormallyEnded = true`로 세팅한다. 진행 시간(`getDuration`)은 `endDateTime`이 있는, 즉 종료된 회의에서만 계산 가능하다(진행중이면 null).
- 회의가 끝나면 커밋 이후 비동기로 오디오 분석(`MeetingAnalysisService.analyzeMeetingAudio`)이 트리거된다. 분석 실패는 로그만 남기고 회의 종료 자체를 실패시키지 않는다.
- 회의 삭제 시 녹음 중이면(`audioEgressId` 존재) 먼저 LiveKit egress를 중지하고, 실패해도 로그만 남기고 삭제를 계속 진행한다(녹음 중지 실패가 데이터 정리를 막지 않는다).

### 팀 (Team)

Expand Down
38 changes: 38 additions & 0 deletions docs/pr-reviews/PR-16.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# PR-16 AI 리뷰 기록

- PR: https://github.com/WhyLog-App/WhyLog/pull/16
- 제목: feat(web, server): 실시간 회의 구현
- 브랜치: `develop` ← `feat/meeting-webrtc`
- HEAD: `846fc3a140974c5fc5f10fe73bf6dd4602a0567e`
- 입력 digest: `2e1055dcd0106a5d7b01942700289c0319fea05bd614e1d08a3a6b93e6a2dcfe`
- 모델: Google `gemini-3.6-flash`
- 상태: **PASS**
- 생성 시각(UTC): 2026-08-21T09:28:37+00:00

## 요약

실시간 회의 방식을 LiveKit SFU에서 P2P WebRTC mesh로 전환하고, 오디오 녹음 없이 실시간 자막(Dialogue)으로 회의를 분석하는 파이프라인을 구현했습니다. 관련 LiveKit 코드 정리 및 DB 마이그레이션(V2), mock 데이터 수정이 충실히 포함되었으며 차단 항목은 없습니다.

## 이번 실행에서 새로 발견됨

없음

## 이전 실행부터 계속 남아있음

없음

## 현재까지 사라짐(자동 추정)

|상태|구분|위치|제목|이유|근거|권장 처리|반영 HEAD|
|---|---|---|---|---|---|---|---|
|resolved|차단|server/src/main/java/com/whylog/server/domain/meeting/service/MeetingDialogueCommandService.java:31|발화 저장 시 memberId 대신 meetingId 전달 오류|Member 엔티티 조회 시 memberId가 아닌 meetingId를 인자로 전달하여 잘못된 회원 엔티티가 매핑되거나 DB 식별자 불일치 예외가 발생합니다.|데이터 무결성 및 비즈니스 로직 정확성|memberRepository.getReferenceById(memberId); 로 수정합니다.|846fc3a140974c5fc5f10fe73bf6dd4602a0567e|

## 실행 이력

|HEAD|상태|모델|전체|신규|계속|해결|시각|
|---|---|---|---:|---:|---:|---:|---|
|846fc3a14097|PASS|Google gemini-3.6-flash||||1|2026-08-21T09:28:37+00:00|
|2b812d4d1449|BLOCKED|Google gemini-3.6-flash|1|1|||2026-08-21T03:52:28+00:00|

<!-- whylog-ai-pr-review-state {"findings":[],"head_sha":"846fc3a140974c5fc5f10fe73bf6dd4602a0567e","history":[{"generated_at":"2026-08-21T09:28:37+00:00","head_sha":"846fc3a140974c5fc5f10fe73bf6dd4602a0567e","model":"gemini-3.6-flash","new":0,"ongoing":0,"provider":"Google","resolved":1,"status":"PASS","total":0},{"generated_at":"2026-08-21T03:52:28+00:00","head_sha":"2b812d4d14490edd50a852f970fbbee43d35f1fd","model":"gemini-3.6-flash","new":1,"ongoing":0,"provider":"Google","resolved":0,"status":"BLOCKED","total":1}],"model":"gemini-3.6-flash","pr":{"author":"hyesngy","base":"develop","head":"feat/meeting-webrtc","number":16,"title":"feat(web, server): 실시간 회의 구현","url":"https://github.com/WhyLog-App/WhyLog/pull/16"},"provider":"Google","resolved":[{"file":"server/src/main/java/com/whylog/server/domain/meeting/service/MeetingDialogueCommandService.java","fingerprint":"db7a927aa35ee8ed16c474ee","kind":"blocking","line":31,"reason":"Member 엔티티 조회 시 memberId가 아닌 meetingId를 인자로 전달하여 잘못된 회원 엔티티가 매핑되거나 DB 식별자 불일치 예외가 발생합니다.","recommendation":"memberRepository.getReferenceById(memberId); 로 수정합니다.","resolved_by_head_sha":"846fc3a140974c5fc5f10fe73bf6dd4602a0567e","rule_reference":"데이터 무결성 및 비즈니스 로직 정확성","status":"resolved","title":"발화 저장 시 memberId 대신 meetingId 전달 오류"}],"review_input_digest":"2e1055dcd0106a5d7b01942700289c0319fea05bd614e1d08a3a6b93e6a2dcfe","schema":1,"status":"PASS","summary":"실시간 회의 방식을 LiveKit SFU에서 P2P WebRTC mesh로 전환하고, 오디오 녹음 없이 실시간 자막(Dialogue)으로 회의를 분석하는 파이프라인을 구현했습니다. 관련 LiveKit 코드 정리 및 DB 마이그레이션(V2), mock 데이터 수정이 충실히 포함되었으며 차단 항목은 없습니다."} -->
<!-- whylog-ai-pr-review-signature 600e27a48376784041d0b07ce64469d61f370adce6691433d280e87f73bdaa71 -->
12 changes: 6 additions & 6 deletions server/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
- `XxxCommandService`는 클래스 레벨에 `@Transactional`을 붙입니다.
- `XxxQueryService`는 클래스 레벨에 `@Transactional(readOnly = true)`를 붙입니다.
- 같은 클래스 내부 호출은 프록시를 거치지 않으므로, 호출 대상 메서드에 별도로 선언한 `@Transactional` 속성(전파·격리·`readOnly` 등)이 적용되지 않습니다. 호출자가 연 트랜잭션은 유지됩니다. 별도 트랜잭션 경계가 필요하면 다른 빈으로 분리합니다.
- **외부 호출(S3, FastAPI, LiveKit, GitHub API)을 트랜잭션 안에서 하지 않습니다.** 커밋 이후 실행이 필요하면 `TransactionSynchronizationManager`로 afterCommit에 등록합니다.
- **외부 호출(S3, FastAPI, GitHub API)을 트랜잭션 안에서 하지 않습니다.** 커밋 이후 실행이 필요하면 `TransactionSynchronizationManager`로 afterCommit에 등록합니다.

```java
// ❌ 트랜잭션 안에서 S3 업로드 — 업로드가 걸리는 시간만큼 DB 커넥션을 붙잡는다
Expand Down Expand Up @@ -71,13 +71,13 @@ scheduleAfterCommit(() -> s3Client.deleteFile(team.getImage()));

```java
// ❌ 같은 prefix를 @Value로 나눠 주입 — 설정 하나 바꾸려면 어느 클래스를 봐야 하는지 알 수 없다
@Value("${livekit.api-key}") private String apiKey;
@Value("${livekit.api-secret}") private String apiSecret;
@Value("${livekit.url}") private String url;
@Value("${cloud.aws.credentials.access-key}") private String accessKey;
@Value("${cloud.aws.credentials.secret-key}") private String secretKey;
@Value("${cloud.aws.region.static}") private String region;

// ✅ 하나로 묶는다
@ConfigurationProperties(prefix = "livekit")
public record LiveKitProperties(String apiKey, String apiSecret, String url) {}
@ConfigurationProperties(prefix = "cloud.aws")
public record AwsProperties(String accessKey, String secretKey, String region) {}
```

### 코드 작성 공통
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,42 +20,18 @@ public class AdminController {

private final AdminMeetingRoomService adminMeetingRoomService;

@GetMapping("/livekit/rooms")
@Operation(summary = "LiveKit 열린 room 목록 조회", description = "LiveKit에 현재 열려 있는 room 목록을 조회합니다.")
public ApiResponse<AdminResponse.LiveKitRoomListDTO> listLiveKitRooms() {
return ApiResponse.onSuccess(adminMeetingRoomService.listLiveKitRooms());
}

@DeleteMapping("/livekit/rooms/{roomName}")
@Operation(summary = "LiveKit room 삭제", description = "지정한 LiveKit room을 삭제하고 연결된 참여자를 모두 종료합니다.")
public ApiResponse<AdminResponse.LiveKitRoomDeleteDTO> deleteLiveKitRoom(
@PathVariable String roomName
) {
return ApiResponse.onSuccess(adminMeetingRoomService.deleteLiveKitRoom(roomName));
}

@GetMapping("/livekit/rooms/{roomName}/participants")
@Operation(summary = "LiveKit room 참여자 목록 조회", description = "지정한 LiveKit room에 현재 참여 중인 사용자 목록을 조회합니다.")
public ApiResponse<AdminResponse.LiveKitParticipantListDTO> listLiveKitParticipants(
@PathVariable String roomName
) {
return ApiResponse.onSuccess(adminMeetingRoomService.listLiveKitParticipants(roomName));
}

@DeleteMapping("/meeting-rooms/{meetingId}/participants/{memberId}")
@Operation(summary = "관리자용 참여자 강제 제거", description = "지정한 미팅룸에서 특정 참여자를 LiveKit room과 웹소켓 방에서 제거합니다.")
@Operation(summary = "관리자용 참여자 강제 제거", description = "지정한 미팅룸에서 특정 참여자를 웹소켓 방에서 제거합니다.")
public ApiResponse<AdminResponse.KickParticipantResponseDTO> removeParticipant(
@PathVariable Long meetingId,
@PathVariable Long memberId
) {
return ApiResponse.onSuccess(adminMeetingRoomService.removeParticipant(meetingId, memberId));
@PathVariable Long meetingId, @PathVariable Long memberId) {
return ApiResponse.onSuccess(
adminMeetingRoomService.removeParticipant(meetingId, memberId));
}

@GetMapping("/meeting-rooms/{meetingId}/websocket-sessions")
@Operation(summary = "회의 웹소켓 세션 정보 조회", description = "지정한 meetingId에 연결된 웹소켓 세션 정보를 조회합니다.")
public ApiResponse<AdminResponse.WebSocketSessionListDTO> listWebSocketSessions(
@PathVariable Long meetingId
) {
@PathVariable Long meetingId) {
return ApiResponse.onSuccess(adminMeetingRoomService.listWebSocketSessions(meetingId));
}
}
Original file line number Diff line number Diff line change
@@ -1,85 +1,20 @@
package com.whylog.server.admin.dto;

import io.swagger.v3.oas.annotations.media.Schema;

import java.util.List;

public class AdminResponse {

@Schema(description = "LiveKit 열린 room 목록 응답")
public record LiveKitRoomListDTO(
List<LiveKitRoomDTO> rooms
) {
}

@Schema(description = "LiveKit room 정보")
public record LiveKitRoomDTO(
String sid,
String name,
Integer numParticipants,
Boolean activeRecording,
String creationTime,
String metadata
) {
}

@Schema(description = "LiveKit room 삭제 응답")
public record LiveKitRoomDeleteDTO(
String roomName,
Boolean deleted
) {
}

@Schema(description = "LiveKit room 참여자 목록 응답")
public record LiveKitParticipantListDTO(
String roomName,
List<LiveKitParticipantDTO> participants
) {
}

@Schema(description = "LiveKit room 참여자 정보")
public record LiveKitParticipantDTO(
String sid,
String identity,
String name,
String state,
String joinedAt,
Boolean isPublisher,
String metadata
) {
}

@Schema(description = "관리자용 미팅 참여자 정보")
public record ParticipantDTO(
Long memberId,
String name,
String sessionId
) {
}
public record ParticipantDTO(Long memberId, String name, String sessionId) {}

@Schema(description = "회의 웹소켓 세션 목록 응답")
public record WebSocketSessionListDTO(
Long meetingId,
List<WebSocketSessionDTO> sessions
) {
}
public record WebSocketSessionListDTO(Long meetingId, List<WebSocketSessionDTO> sessions) {}

@Schema(description = "회의 웹소켓 세션 정보")
public record WebSocketSessionDTO(
String sessionId,
Long memberId,
String name,
Boolean open
) {
}
public record WebSocketSessionDTO(String sessionId, Long memberId, String name, Boolean open) {}

@Schema(description = "관리자용 참여자 강제 제거 응답")
public record KickParticipantResponseDTO(
Long meetingId,
Long memberId,
Boolean removed,
Integer removedSessionCount,
Boolean liveKitRemoved
) {
}
Long meetingId, Long memberId, Boolean removed, Integer removedSessionCount) {}
}
Loading