# 회의록 작성

> **작성일**: 2026-03-06
> **상태**: 운영 중
> **라우트**: `/juil/meeting-minutes`
> **관련**: [README.md](README.md) | [사진대지](construction-photos.md) | [뷰 화면](planning-views.md)

---

## 1. 개요

음성으로 회의 내용을 기록하고, **Google STT(화자 분리)** + **Gemini AI(요약/결정사항/액션아이템)** 로 자동 정리하는 회의록 시스템. 브라우저 MediaRecorder로 녹음하고, GCS에 오디오를 저장하며, 세그먼트(화자별 발화)를 관리한다.

---

## 2. 라우트

```
/juil/meeting-minutes
├── GET  /                              → index (목록 페이지)
├── GET  /list                          → list (JSON 목록)
├── POST /                              → store (새 회의록 생성)
├── POST /log-stt-usage                 → logSttUsage (STT 시간 기록)
├── GET  /{id}                          → show (상세 조회 + segments)
├── PUT  /{id}                          → update (메타데이터 수정)
├── DELETE /{id}                        → destroy (삭제)
├── POST /{id}/segments                 → saveSegments (세그먼트 저장)
├── POST /{id}/upload-audio             → uploadAudio (오디오 업로드)
├── POST /{id}/summarize                → summarize (AI 요약 생성)
├── POST /{id}/diarize                  → diarize (자동 화자 분리)
└── GET  /{id}/download-audio           → downloadAudio (오디오 다운로드)
```

---

## 3. 데이터베이스

### 3.1 meeting_minutes (회의록)

| 컬럼 | 타입 | 설명 |
|------|------|------|
| `id` | BIGINT PK | |
| `tenant_id` | BIGINT FK | 테넌트 격리 |
| `user_id` | BIGINT FK | 작성자 |
| `title` | VARCHAR(300) | 제목 (기본: "무제 회의록") |
| `folder` | VARCHAR(100) NULL | 폴더 분류 |
| `participants` | JSON NULL | 참여자 목록 배열 |
| `meeting_date` | DATE | 회의 날짜 |
| `meeting_time` | TIME NULL | 회의 시작 시간 |
| `duration_seconds` | INT UNSIGNED | 녹음 총 시간(초) |
| `audio_file_path` | VARCHAR(500) NULL | 오디오 GCS 경로 |
| `audio_gcs_uri` | VARCHAR(500) NULL | 오디오 GCS URI |
| `audio_file_size` | BIGINT UNSIGNED NULL | 오디오 파일 크기 (bytes) |
| `full_transcript` | LONGTEXT NULL | 전체 트랜스크립트 |
| `summary` | LONGTEXT NULL | AI 요약 |
| `decisions` | JSON NULL | 결정사항 배열 |
| `action_items` | JSON NULL | 액션아이템 배열 |
| `status` | VARCHAR(20) | 상태 (5가지) |
| `stt_language` | VARCHAR(10) | STT 언어 (기본: ko-KR) |
| `deleted_at` | TIMESTAMP NULL | 소프트 삭제 |

**인덱스**: `tenant_id`, `user_id`, `(tenant_id, meeting_date)`, `status`

### 3.2 meeting_minute_segments (세그먼트)

| 컬럼 | 타입 | 설명 |
|------|------|------|
| `id` | BIGINT PK | |
| `meeting_minute_id` | BIGINT FK | 회의록 (cascade delete) |
| `segment_order` | INT UNSIGNED | 순서 |
| `speaker_name` | VARCHAR(100) | 화자 이름 (기본: "화자 1") |
| `speaker_label` | VARCHAR(20) NULL | 화자 라벨/번호 |
| `text` | TEXT | 발화 텍스트 |
| `start_time_ms` | INT UNSIGNED | 시작 시간 (ms, 기본: 0) |
| `end_time_ms` | INT UNSIGNED NULL | 종료 시간 (ms) |
| `is_manual_speaker` | BOOLEAN | 수동 화자 전환 여부 (기본: true) |

**인덱스**: `meeting_minute_id`, `(meeting_minute_id, segment_order)`

### 3.3 테이블 관계

```
meeting_minutes
    │ 1:N
    ▼
meeting_minute_segments (segment_order ASC)
    ├── speaker_name (화자명)
    ├── text (발화 내용)
    └── start_time_ms / end_time_ms (타임스탬프)
```

---

## 4. 상태 관리

### 4.1 상태값

| 상태 | 코드 | 색상 | 설명 |
|------|------|------|------|
| 초안 | `DRAFT` | 회색 | 생성 직후, 편집 가능 |
| 녹음중 | `RECORDING` | 빨강 | (클라이언트 상태) |
| 처리중 | `PROCESSING` | 노랑 | AI 요약/화자분리 처리 중 |
| 완료 | `COMPLETED` | 초록 | AI 처리 완료 |
| 실패 | `FAILED` | 빨강 | AI 처리 실패 |

### 4.2 상태 전이

```
DRAFT
    ↓ [오디오 업로드, 세그먼트 추가]
DRAFT (계속 편집)
    ↓ [summarize() 호출]
PROCESSING
    ↓
COMPLETED (성공) 또는 FAILED (실패)

DRAFT
    ↓ [diarize() 호출 → 화자 분리]
DRAFT (세그먼트 갱신, 상태 유지)
```

---

## 5. API 명세

### 5.1 목록 조회

```
GET /juil/meeting-minutes/list
```

| 파라미터 | 타입 | 설명 |
|---------|------|------|
| `search` | string | 제목 검색 |
| `date_from` | date | 시작일 |
| `date_to` | date | 종료일 |
| `status` | string | 상태 필터 |
| `per_page` | int | 페이지당 건수 |

### 5.2 생성

```
POST /juil/meeting-minutes
```

| 필드 | 규칙 | 설명 |
|------|------|------|
| `title` | nullable, max:300 | 제목 (미입력 시 "무제 회의록") |
| `folder` | nullable, max:100 | 폴더 분류 |
| `participants` | nullable, array | 참여자 목록 |
| `meeting_date` | required, date | 회의 날짜 |
| `meeting_time` | nullable | 회의 시간 |
| `stt_language` | nullable, max:10 | STT 언어 (기본: ko-KR) |

### 5.3 세그먼트 저장

```
POST /juil/meeting-minutes/{id}/segments
```

```json
{
    "segments": [
        {
            "speaker_name": "김과장",
            "speaker_label": "1",
            "text": "블라인드 납기일 확인 필요합니다.",
            "start_time_ms": 0,
            "end_time_ms": 5000,
            "is_manual_speaker": true
        }
    ]
}
```

> **전처리**: 빈 텍스트 필터링, 언더스코어 노이즈 제거, 다중 공백 정규화
> **자동 생성**: `full_transcript` = `[화자명] 발화텍스트\n...` 형식

### 5.4 오디오 업로드

```
POST /juil/meeting-minutes/{id}/upload-audio
```

| 필드 | 규칙 | 설명 |
|------|------|------|
| `audio` | required, file | webm/mp3 등 |
| `duration_seconds` | required, integer, min:1 | 녹음 시간(초) |

### 5.5 AI 요약 생성

```
POST /juil/meeting-minutes/{id}/summarize
```

**요청**: 없음 (서버에서 `full_transcript` 사용)

**응답 예시:**

```json
{
    "success": true,
    "message": "AI 요약이 완료되었습니다.",
    "data": {
        "summary": "블라인드 납품 일정과 현장 설치 계획을 논의했습니다...",
        "decisions": [
            "납품일을 3월 15일로 확정",
            "현장 실측은 3월 10일 진행"
        ],
        "action_items": [
            {
                "assignee": "김과장",
                "task": "거래처에 납기 확인 연락",
                "deadline": "2026-03-08"
            }
        ],
        "status": "COMPLETED"
    }
}
```

### 5.6 자동 화자 분리

```
POST /juil/meeting-minutes/{id}/diarize
```

| 필드 | 설명 | 기본값 |
|------|------|--------|
| `min_speakers` | 최소 화자 수 | 2 |
| `max_speakers` | 최대 화자 수 | 6 |

**응답:**

```json
{
    "success": true,
    "message": "자동 화자 분리가 완료되었습니다. (3명 감지)",
    "data": { /* Meeting with segments */ },
    "speaker_count": 3
}
```

---

## 6. AI 통합 상세

### 6.1 화자 분리 (Diarization) 3단계 폴백

```
[1단계] Google STT V2 (Chirp2) ← 최우선
    │   speechToTextWithDiarizationAuto()
    │   최신 모델, 높은 정확도
    │   도메인 용어 힌트 포함
    │
    ↓ (실패 시)
[2단계] Google STT V1 (latest_long) ← 폴백
    │   안정적이지만 약간 덜 정확
    │
    ↓ (1명만 인식 시)
[3단계] Gemini AI 화자 재분배
        splitSpeakersWithGemini()
        대화 맥락/호칭/질답 패턴/어투 변화 분석
        2명 이상으로 재분배
```

### 6.2 요약 생성 (Gemini API)

```
입력: full_transcript (전체 트랜스크립트)
    ↓
Gemini API 호출
    ├── 모드 1: Vertex AI (projectId, region, JWT)
    └── 모드 2: Google AI Studio (API key) ← 폴백
    │
    │ Temperature: 0.3 (결정적)
    │ Max tokens: 4096
    ↓
출력 JSON:
{
    "summary": "3-5문장 요약",
    "decisions": ["결정사항 1", "..."],
    "action_items": [
        { "assignee": "담당자", "task": "할일", "deadline": "기한" }
    ],
    "keywords": ["키워드1", "..."]
}
```

### 6.3 Gemini 화자 재분배

Google STT가 1명만 인식할 때 Gemini로 대화 맥락 분석:

```
입력: 단일 화자 트랜스크립트 + 예상 화자 수
    ↓
Gemini 프롬프트:
    - 대화 맥락 분석 (호칭, 질답, 어투 변화)
    - 지정된 수의 화자로 분리
    ↓
출력: 화자별 세그먼트 배열
    → DB 세그먼트 교체
```

---

## 7. 오디오 관리 (GCS)

### 7.1 GCS 경로 패턴

```
meeting-minutes/{tenant_id}/{meeting_id}/{timestamp}.webm
```

### 7.2 녹음 흐름

```
브라우저 MediaRecorder API
    ├── navigator.mediaDevices.getUserMedia({ audio: true })
    ├── new MediaRecorder(stream)
    ├── recorder.ondataavailable → webm 블롭 수집
    └── 녹음 종료 → FormData로 업로드
    ↓
POST /{id}/upload-audio
    ├── GCS 업로드
    ├── DB: audio_file_path, audio_gcs_uri, audio_file_size, duration_seconds
    └── AiTokenHelper::saveGcsStorageUsage()
```

### 7.3 다운로드

```
GET /{id}/download-audio
    → GCS에서 파일 콘텐츠 다운로드
    → Content-Disposition: attachment; filename="{title}.webm"
```

---

## 8. 세그먼트 처리 로직

### 8.1 저장 시 전처리

```php
// 1. 빈 텍스트 필터링
trim($segment['text']) !== ''

// 2. 언더스코어 노이즈 제거
str_replace('_', '', $text)

// 3. 다중 공백 정규화
preg_replace('/\s{2,}/', ' ', $text)
```

### 8.2 전체 트랜스크립트 자동 생성

```
[김과장] 블라인드 납기일 확인 필요합니다.
[박부장] 3월 15일로 확정합시다.
[김과장] 네, 거래처에 연락하겠습니다.
```

### 8.3 화자 분리 결과 세그먼트 변환

```
Google STT 결과 → MeetingMinuteSegment 변환:
{
    segment_order: 순서,
    speaker_name: "화자 N",
    speaker_label: "N",
    text: 발화 텍스트,
    start_time_ms: 시작시간,
    end_time_ms: 종료시간,
    is_manual_speaker: false    // 자동 분리
}
```

---

## 9. UI 구성 (React)

### 9.1 화자 색상

| 화자 | 배경색 | 뱃지색 |
|------|--------|--------|
| 화자 1 | `bg-blue-50` | `bg-blue-100 text-blue-800` |
| 화자 2 | `bg-green-50` | `bg-green-100 text-green-800` |
| 화자 3 | `bg-purple-50` | `bg-purple-100 text-purple-800` |
| 화자 4 | `bg-orange-50` | `bg-orange-100 text-orange-800` |

### 9.2 지원 언어

| 코드 | 라벨 |
|------|------|
| `ko-KR` | 한국어 |
| `en-US` | English |
| `ja-JP` | 日本語 |
| `zh-CN` | 中文 |

---

## 10. 사용량 추적

| 추적 항목 | 레이블 | Helper |
|----------|--------|--------|
| Web Speech API 사용 | `회의록-음성인식` | `AiTokenHelper::saveSttUsage()` |
| Google STT V1 화자 분리 | `회의록-화자분리` | `AiTokenHelper::saveSttUsage()` |
| Google STT V2 화자 분리 | `회의록-화자분리(Chirp2)` | `AiTokenHelper::saveSttUsage()` |
| GCS 오디오 저장 | `회의록-GCS저장` | `AiTokenHelper::saveGcsStorageUsage()` |
| Gemini 요약/분리 | `회의록-AI요약` | `AiTokenHelper::saveGeminiUsage()` |

---

## 11. 모델 메서드

### 11.1 MeetingMinute

```php
user()                          # BelongsTo User
segments()                      # HasMany Segment (segment_order ASC)
getFormattedDurationAttribute() # "H:MM:SS" 또는 "MM:SS"
```

**Cast**: `participants`, `decisions`, `action_items` → array, `meeting_date` → date

### 11.2 MeetingMinuteService

```php
# CRUD
getList(array $filters)                                     # 검색/필터 목록
create(array $data)                                         # 생성 (DRAFT)
update(MeetingMinute, array $data)                          # 수정
delete(MeetingMinute)                                       # GCS 삭제 → soft delete

# 세그먼트
saveSegments(MeetingMinute, array $segments)                # 전처리 + 저장 + 트랜스크립트 생성
uploadAudio(MeetingMinute, UploadedFile, int $seconds)      # GCS 업로드
logSttUsage(int $seconds)                                   # STT 사용량 기록

# AI
generateSummary(MeetingMinute)                              # Gemini 요약 생성
processDiarization(MeetingMinute, int $min, int $max)       # 3단계 화자 분리
splitSpeakersWithGemini(string $text, int $expected)        # Gemini 화자 재분배
```

---

## 관련 문서

- [README.md](README.md) — 기획 메뉴 전체 개요
- [공사현장 사진대지](construction-photos.md) — GCS 파일 관리, 음성 입력
- [견적/프로젝트/워크플로우](planning-views.md) — 화면 명세

---

**최종 업데이트**: 2026-03-06
