Feat: AI 서비스 통합 및 회의록 기능 개선

- AI 서비스와 Meeting 서비스 통합 개선
  - AgendaSummaryDTO에 decisions 필드 추가 (안건별 결정사항 배열)
  - EndMeetingService에서 AI 서비스 타임아웃 처리 개선
  - AIServiceClient에 상세한 에러 로깅 추가

- 회의록 consolidate 프롬프트 개선
  - Todo 추출 로직 강화 (자연스러운 표현 인식)
  - 안건별 decisions 필드 추가 (대시보드 표시용)
  - 담당자 패턴 인식 개선

- Kubernetes 배포 설정 개선
  - meeting-service.yaml에 AI_SERVICE_URL 환경변수 추가
  - AI_SERVICE_TIMEOUT 설정 추가

- 데이터베이스 관리 SQL 스크립트 추가
  - check-agenda-sections.sql: 안건 섹션 확인
  - cleanup-test-data.sql: 테스트 데이터 정리
  - insert-test-data-final.sql: 최종 테스트 데이터

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Minseo-Jo
2025-10-30 18:07:57 +09:00
parent 4a87be88f0
commit 0caa1ec3b6
11 changed files with 458 additions and 54 deletions
+1
View File
@@ -30,6 +30,7 @@ class AgendaSummary(BaseModel):
agenda_title: str = Field(..., description="안건 제목")
summary_short: str = Field(..., description="AI 생성 짧은 요약 (1줄, 20자 이내)")
summary: str = Field(..., description="안건별 회의록 요약 (논의사항+결정사항, 사용자 수정 가능)")
decisions: List[str] = Field(default_factory=list, description="안건별 결정사항 배열 (대시보드 표시용)")
pending: List[str] = Field(default_factory=list, description="보류 사항")
todos: List[ExtractedTodo] = Field(default_factory=list, description="Todo 목록 (제목만)")
+25 -7
View File
@@ -49,13 +49,23 @@ def get_consolidate_prompt(participant_minutes: list, agendas: list = None) -> s
- **agenda_number**: 안건 번호 (1, 2, 3...)
- **agenda_title**: 안건 제목 (간결하게)
- **summary_short**: AI가 생성한 1줄 요약 (20자 이내, 사용자 수정 불가)
- **summary**: 안건별 회의록 요약 (논의사항과 결정사항을 포함한 전체 요약)
- **summary**: 안건별 회의록 요약 (논의사항과 결정사항 모두 포함)
* 회의록 수정 페이지에서 사용자가 수정할 수 있는 입력 필드
* 형식: "**논의 사항:**\n- 논의내용1\n- 논의내용2\n\n**결정 사항:**\n- 결정1\n- 결정2"
* 사용자가 자유롭게 편집할 수 있도록 구조화된 텍스트로 작성
- **decisions**: 안건별 결정사항 배열 (대시보드 표시용, summary의 결정사항 부분을 배열로 추출)
* 형식: ["결정사항1", "결정사항2", "결정사항3"]
* 회의에서 최종 결정된 사항만 포함
- **pending**: 보류 사항 배열 (추가 논의 필요 사항)
- **todos**: Todo 배열 (제목만, 담당자/마감일/우선순위 없음)
- title: Todo 제목만 추출 (예: "시장 조사 보고서 작성")
- **todos**: Todo 배열 (제목 담당자 추출)
- title: Todo 제목 (예: "시장 조사 보고서 작성")
- assignee: 담당자 이름 (있는 경우에만, 예: "김대리", "박과장")
**Todo 추출 가이드:**
- 자연스러운 표현도 인식: "김대리가 ~하기로 함", "박과장은 ~준비합니다", "이차장님께서 ~하시기로 하셨습니다"
- 실행 동사 패턴: ~하기로, ~준비, ~작성, ~제출, ~완료, ~진행, ~검토, ~분석
- 담당자 패턴: "OO님", "OO이/가", "OO은/는", "OO께서"
- 기한 표현: "다음주", "이번주", "~까지", "~일까지", "~월까지"
---
@@ -77,10 +87,16 @@ def get_consolidate_prompt(participant_minutes: list, agendas: list = None) -> s
"agenda_title": "안건 제목",
"summary_short": "짧은 요약 (20자 이내)",
"summary": "**논의 사항:**\\n- 논의내용1\\n- 논의내용2\\n\\n**결정 사항:**\\n- 결정1\\n- 결정2",
"decisions": ["결정사항1", "결정사항2"],
"pending": ["보류사항"],
"todos": [
{{
"title": "Todo 제목"
"title": "인플루언서 리스트 작성",
"assignee": "김대리"
}},
{{
"title": "캠페인 콘텐츠 기획안 초안 작성",
"assignee": "박과장"
}}
]
}}
@@ -97,11 +113,13 @@ def get_consolidate_prompt(participant_minutes: list, agendas: list = None) -> s
3. **완전성**: 모든 필드를 빠짐없이 작성
4. **구조화**: 안건별로 명확히 분리
5. **결정사항 추출**:
- 회의 전체 결정사항(decisions) 모든 안건의 결정사항을 포함
- 안건별 summary에도 결정사항을 포함하여 사용자가 수정 가능하도록 작성
- 회의 전체 결정사항(decisions): 모든 안건의 결정사항을 포함 (TEXT 형식)
- 안건별 결정사항(agenda_summaries[].decisions): 각 안건의 결정사항을 배열로 추출
- 결정사항이 명확하게 언급된 경우에만 포함
6. **summary 작성**:
- summary_short: AI가 자동 생성한 1줄 요약 (사용자 수정 불가)
- summary: 논의사항과 결정사항을 포함한 전체 요약 (사용자 수정 가능)
- summary: 논의사항과 결정사항 모두 포함 (사용자 수정 가능)
- decisions: summary의 결정사항 부분을 배열로 별도 추출 (대시보드 표시용)
7. **Todo 추출**: 제목만 추출 (담당자나 마감일 없어도 됨)
8. **JSON만 출력**: 추가 설명 없이 JSON만 반환
@@ -96,6 +96,7 @@ class TranscriptService:
agenda_title=agenda_data.get("agenda_title", ""),
summary_short=agenda_data.get("summary_short", ""),
summary=agenda_data.get("summary", ""),
decisions=agenda_data.get("decisions", []),
pending=agenda_data.get("pending", []),
todos=todos
)
+4
View File
@@ -65,6 +65,10 @@ spec:
key: eventhub-connection-string
- name: NOTIFICATION_SERVICE_URL
value: "http://notification-service:8082"
- name: AI_SERVICE_URL
value: "http://ai-service:8087"
- name: AI_SERVICE_TIMEOUT
value: "60000"
resources:
requests:
cpu: 256m
@@ -13,11 +13,14 @@ import com.unicorn.hgzero.meeting.infra.gateway.entity.MeetingEntity;
import com.unicorn.hgzero.meeting.infra.gateway.entity.MinutesEntity;
import com.unicorn.hgzero.meeting.infra.gateway.entity.MinutesSectionEntity;
import com.unicorn.hgzero.meeting.infra.gateway.entity.TodoEntity;
import com.unicorn.hgzero.meeting.infra.gateway.entity.AgendaSectionEntity;
import com.unicorn.hgzero.meeting.infra.gateway.repository.AgendaSectionJpaRepository;
import com.unicorn.hgzero.meeting.infra.gateway.repository.MeetingAnalysisJpaRepository;
import com.unicorn.hgzero.meeting.infra.gateway.repository.MeetingJpaRepository;
import com.unicorn.hgzero.meeting.infra.gateway.repository.MinutesJpaRepository;
import com.unicorn.hgzero.meeting.infra.gateway.repository.MinutesSectionJpaRepository;
import com.unicorn.hgzero.meeting.infra.gateway.repository.TodoJpaRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Primary;
@@ -45,7 +48,9 @@ public class EndMeetingService implements EndMeetingUseCase {
private final MinutesSectionJpaRepository minutesSectionRepository;
private final TodoJpaRepository todoRepository;
private final MeetingAnalysisJpaRepository analysisRepository;
private final AgendaSectionJpaRepository agendaSectionRepository;
private final AIServiceClient aiServiceClient;
private final ObjectMapper objectMapper;
/**
* 회의 종료 및 AI 분석 실행
@@ -58,17 +63,26 @@ public class EndMeetingService implements EndMeetingUseCase {
public MeetingEndDTO endMeeting(String meetingId) {
log.info("회의 종료 시작 - meetingId: {}", meetingId);
try {
// 1. 회의 정보 조회
MeetingEntity meeting = meetingRepository.findById(meetingId)
.orElseThrow(() -> new IllegalArgumentException("회의를 찾을 수 없습니다: " + meetingId));
.orElseThrow(() -> {
log.error("회의를 찾을 수 없음 - meetingId: {}", meetingId);
return new IllegalArgumentException("회의를 찾을 수 없습니다: " + meetingId);
});
log.info("회의 정보 조회 완료 - title: {}, status: {}", meeting.getTitle(), meeting.getStatus());
// 2. 참석자별 회의록 조회 (userId가 있는 회의록들)
List<MinutesEntity> participantMinutesList = minutesRepository.findByMeetingIdAndUserIdIsNotNull(meetingId);
if (participantMinutesList.isEmpty()) {
log.error("참석자 회의록이 없음 - meetingId: {}", meetingId);
throw new IllegalStateException("참석자 회의록이 없습니다: " + meetingId);
}
log.info("참석자 회의록 조회 완료 - 참석자 수: {}", participantMinutesList.size());
// 3. 각 회의록의 sections 조회 및 통합
List<MinutesSectionEntity> allMinutesSections = new ArrayList<>();
for (MinutesEntity minutes : participantMinutesList) {
@@ -76,26 +90,46 @@ public class EndMeetingService implements EndMeetingUseCase {
minutes.getMinutesId()
);
allMinutesSections.addAll(sections);
log.debug("회의록 섹션 조회 - minutesId: {}, userId: {}, 섹션 수: {}",
minutes.getMinutesId(), minutes.getUserId(), sections.size());
}
log.info("전체 회의록 섹션 조회 완료 - 총 섹션 수: {}", allMinutesSections.size());
// 4. AI 통합 분석 요청 데이터 생성
ConsolidateRequest request = createConsolidateRequest(meeting, allMinutesSections, participantMinutesList);
log.info("AI 통합 분석 요청 데이터 생성 완료 - participantMinutes 수: {}",
request.getParticipantMinutes().size());
// 5. AI Service 호출
log.info("AI Service 호출 시작...");
ConsolidateResponse aiResponse = aiServiceClient.consolidateMinutes(request);
log.info("AI Service 호출 완료 - 안건 수: {}", aiResponse.getAgendaSummaries().size());
// 5. AI 분석 결과 저장
// 6. AI 분석 결과 저장
MeetingAnalysis analysis = saveAnalysisResult(meeting, aiResponse);
// 6. Todo 생성 및 저장
// 7. Todo 생성 및 저장
List<TodoEntity> todos = createAndSaveTodos(meeting, aiResponse, analysis);
// 6. 회의 종료 처리
// 8. 회의 종료 처리
meeting.end();
meetingRepository.save(meeting);
log.info("회의 상태 업데이트 완료 - status: {}", meeting.getStatus());
// 7. 응답 DTO 생성
return createMeetingEndDTO(meeting, analysis, todos, participantMinutesList.size());
// 9. 응답 DTO 생성
MeetingEndDTO result = createMeetingEndDTO(meeting, analysis, todos, participantMinutesList.size());
log.info("회의 종료 처리 완료 - meetingId: {}, 안건 수: {}, Todo 수: {}",
meetingId, analysis.getAgendaAnalyses().size(), todos.size());
return result;
} catch (Exception e) {
log.error("회의 종료 처리 중 오류 발생 - meetingId: {}, 에러: {}",
meetingId, e.getMessage(), e);
throw e;
}
}
/**
@@ -141,7 +175,7 @@ public class EndMeetingService implements EndMeetingUseCase {
.title(summary.getAgendaTitle())
.aiSummaryShort(summary.getSummaryShort())
.discussion(summary.getSummary() != null ? summary.getSummary() : "")
.decisions(List.of())
.decisions(summary.getDecisions() != null ? summary.getDecisions() : List.of())
.pending(summary.getPending() != null ? summary.getPending() : List.of())
.extractedTodos(summary.getTodos() != null
? summary.getTodos().stream()
@@ -168,18 +202,61 @@ public class EndMeetingService implements EndMeetingUseCase {
MeetingAnalysisEntity entity = MeetingAnalysisEntity.fromDomain(analysis);
analysisRepository.save(entity);
// AgendaSection 저장 (안건별 회의록)
saveAgendaSections(meeting.getMeetingId(), aiResponse);
log.info("AI 분석 결과 저장 완료 - analysisId: {}", analysis.getAnalysisId());
return analysis;
}
/**
* AgendaSection 저장
*/
private void saveAgendaSections(String meetingId, ConsolidateResponse aiResponse) {
int agendaNumber = 1;
for (var summary : aiResponse.getAgendaSummaries()) {
try {
// pending items와 todos를 JSON 문자열로 변환
String pendingItemsJson = summary.getPending() != null && !summary.getPending().isEmpty()
? objectMapper.writeValueAsString(summary.getPending())
: null;
String todosJson = summary.getTodos() != null && !summary.getTodos().isEmpty()
? objectMapper.writeValueAsString(summary.getTodos())
: null;
AgendaSectionEntity agendaSection = AgendaSectionEntity.builder()
.id(UUID.randomUUID().toString())
.minutesId(meetingId) // AI 통합 회의록 ID로 사용
.meetingId(meetingId)
.agendaNumber(agendaNumber++)
.agendaTitle(summary.getAgendaTitle())
.aiSummaryShort(summary.getSummaryShort())
.summary(summary.getSummary())
.pendingItems(pendingItemsJson)
.todos(todosJson)
.build();
agendaSectionRepository.save(agendaSection);
log.debug("AgendaSection 저장 완료 - agendaTitle: {}", summary.getAgendaTitle());
} catch (Exception e) {
log.error("AgendaSection 저장 실패 - agendaTitle: {}", summary.getAgendaTitle(), e);
}
}
log.info("AgendaSection 저장 완료 - meetingId: {}, count: {}", meetingId, aiResponse.getAgendaSummaries().size());
}
/**
* Todo 생성 및 저장
*/
private List<TodoEntity> createAndSaveTodos(MeetingEntity meeting, ConsolidateResponse aiResponse, MeetingAnalysis analysis) {
List<TodoEntity> todos = aiResponse.getAgendaSummaries().stream()
.<TodoEntity>flatMap(agenda -> {
String agendaId = findAgendaIdByTitle(analysis, agenda.getAgendaTitle());
// agendaId는 향후 Todo와 안건 매핑에 사용될 수 있음 (현재는 사용하지 않음)
List<ExtractedTodoDTO> todoList = agenda.getTodos() != null ? agenda.getTodos() : List.of();
return todoList.stream()
.<TodoEntity>map(todo -> TodoEntity.builder()
@@ -67,7 +67,7 @@ public class MeetingMemoService implements SaveMeetingMemoUseCase {
// 4. 기존 메모 섹션 조회 또는 새로운 섹션 생성
MinutesSection memoSection = minutesSectionReader.findFirstByMinutesIdAndType(
minutes.getMinutesId(),
"AI_MEMO"
"MEMO"
).orElseGet(() -> createNewMemoSection(minutes.getMinutesId()));
// 5. 메모 내용 업데이트 (기존 내용에 추가)
@@ -81,7 +81,7 @@ public class MeetingMemoService implements SaveMeetingMemoUseCase {
MinutesSection updatedSection = MinutesSection.builder()
.sectionId(memoSection.getSectionId())
.minutesId(memoSection.getMinutesId())
.type("AI_MEMO")
.type("MEMO")
.title("회의 메모")
.content(updatedContent)
.order(memoSection.getOrder())
@@ -105,7 +105,7 @@ public class MeetingMemoService implements SaveMeetingMemoUseCase {
return MinutesSection.builder()
.sectionId(generateSectionId())
.minutesId(minutesId)
.type("AI_MEMO")
.type("MEMO")
.title("회의 메모")
.content("")
.order(maxOrder + 1)
@@ -43,11 +43,10 @@ public class AIServiceClient {
* @return 통합 요약 응답
*/
public ConsolidateResponse consolidateMinutes(ConsolidateRequest request) {
log.info("AI Service 호출 - 회의록 통합 요약: {}", request.getMeetingId());
String url = aiServiceUrl + "/api/transcripts/consolidate";
log.info("AI Service 호출 시작 - URL: {}, meetingId: {}", url, request.getMeetingId());
try {
String url = aiServiceUrl + "/api/transcripts/consolidate";
// HTTP 헤더 설정
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
@@ -55,6 +54,9 @@ public class AIServiceClient {
// HTTP 요청 생성
HttpEntity<ConsolidateRequest> httpEntity = new HttpEntity<>(request, headers);
log.debug("AI Service 요청 데이터 - participantMinutes 수: {}",
request.getParticipantMinutes() != null ? request.getParticipantMinutes().size() : 0);
// API 호출
ResponseEntity<ConsolidateResponse> response = restTemplate.postForEntity(
url,
@@ -65,15 +67,30 @@ public class AIServiceClient {
ConsolidateResponse result = response.getBody();
if (result == null) {
log.error("AI Service 응답이 비어있습니다 - HTTP Status: {}", response.getStatusCode());
throw new RuntimeException("AI Service 응답이 비어있습니다");
}
log.info("AI Service 응답 수신 완료 - 안건 수: {}", result.getAgendaSummaries().size());
log.info("AI Service 응답 수신 완료 - 안건 수: {}, HTTP Status: {}",
result.getAgendaSummaries() != null ? result.getAgendaSummaries().size() : 0,
response.getStatusCode());
return result;
} catch (org.springframework.web.client.ResourceAccessException e) {
log.error("AI Service 연결 실패 - URL: {}, 에러: {}", url, e.getMessage());
throw new RuntimeException("AI 서비스에 연결할 수 없습니다. 서비스가 실행 중인지 확인해주세요.", e);
} catch (org.springframework.web.client.HttpClientErrorException e) {
log.error("AI Service 클라이언트 오류 - HTTP Status: {}, 응답: {}",
e.getStatusCode(), e.getResponseBodyAsString());
throw new RuntimeException("AI 서비스 요청이 거부되었습니다: " + e.getMessage(), e);
} catch (org.springframework.web.client.HttpServerErrorException e) {
log.error("AI Service 서버 오류 - HTTP Status: {}, 응답: {}",
e.getStatusCode(), e.getResponseBodyAsString());
throw new RuntimeException("AI 서비스에서 오류가 발생했습니다: " + e.getMessage(), e);
} catch (Exception e) {
log.error("AI Service 호출 실패: {}", e.getMessage(), e);
log.error("AI Service 호출 중 예상치 못한 오류 - 타입: {}, 메시지: {}",
e.getClass().getSimpleName(), e.getMessage(), e);
throw new RuntimeException("AI 회의록 통합 처리 중 오류가 발생했습니다: " + e.getMessage(), e);
}
}
@@ -37,10 +37,15 @@ public class AgendaSummaryDTO {
/**
* 안건별 회의록 요약
* 사용자가 입력한 회의록 내용을 요약한 결과
* 사용자가 입력한 회의록 내용을 요약한 결과 (논의사항 + 결정사항)
*/
private String summary;
/**
* 안건별 결정사항 배열 (대시보드 표시용)
*/
private List<String> decisions;
/**
* 보류 사항
*/
+17
View File
@@ -0,0 +1,17 @@
-- =====================================================
-- Check agenda_sections data for meeting-test
-- =====================================================
SELECT
id,
meeting_id,
agenda_number,
agenda_title,
ai_summary_short,
LENGTH(summary) as summary_length,
pending_items,
todos,
created_at
FROM agenda_sections
WHERE meeting_id = 'meeting-test'
ORDER BY agenda_number;
+26
View File
@@ -0,0 +1,26 @@
-- =====================================================
-- Cleanup Test Data for meeting-test
-- =====================================================
-- 1. agenda_sections 삭제 (외래키 제약이 있을 수 있으므로 먼저)
DELETE FROM agenda_sections WHERE meeting_id = 'meeting-test';
-- 2. todos 삭제
DELETE FROM todos WHERE meeting_id = 'meeting-test';
-- 3. meeting_analysis 관련 삭제
DELETE FROM meeting_keywords WHERE analysis_id IN (SELECT analysis_id FROM meeting_analysis WHERE meeting_id = 'meeting-test');
DELETE FROM meeting_analysis WHERE meeting_id = 'meeting-test';
-- 4. minutes_sections 삭제
DELETE FROM minutes_sections WHERE minutes_id IN (SELECT minutes_id FROM minutes WHERE meeting_id = 'meeting-test');
-- 5. minutes 삭제
DELETE FROM minutes WHERE meeting_id = 'meeting-test';
-- 6. 확인
SELECT 'Cleanup completed' AS status;
SELECT COUNT(*) as remaining_minutes FROM minutes WHERE meeting_id = 'meeting-test';
SELECT COUNT(*) as remaining_sections FROM minutes_sections WHERE minutes_id IN (SELECT minutes_id FROM minutes WHERE meeting_id = 'meeting-test');
SELECT COUNT(*) as remaining_agenda FROM agenda_sections WHERE meeting_id = 'meeting-test';
SELECT COUNT(*) as remaining_todos FROM todos WHERE meeting_id = 'meeting-test';
+238
View File
@@ -0,0 +1,238 @@
-- =====================================================
-- Meeting Test Data for AI Service Testing (UPDATED with user_id)
-- =====================================================
-- 1. meeting-test 데이터가 있는지 확인
SELECT * FROM meetings WHERE meeting_id = 'meeting-test';
-- 2. 기존 meeting-test 관련 데이터 정리 (있다면)
DELETE FROM minutes_sections WHERE minutes_id IN (SELECT minutes_id FROM minutes WHERE meeting_id = 'meeting-test');
DELETE FROM minutes WHERE meeting_id = 'meeting-test';
-- 3. 참석자별 회의록(minutes) 생성 - user1
INSERT INTO minutes (
minutes_id,
meeting_id,
user_id,
title,
status,
version,
created_by,
created_at,
updated_at
) VALUES (
'minutes-test-user1',
'meeting-test',
'user1',
'Q4 마케팅 전략 회의 - user1 작성',
'DRAFT',
1,
'user1',
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
);
-- 4. user1 회의록 섹션 - MEMO
INSERT INTO minutes_sections (
id,
section_id,
minutes_id,
type,
title,
content,
"order",
verified,
locked,
created_at,
updated_at
) VALUES
(
'section-user1-1',
'section-user1-1',
'minutes-test-user1',
'MEMO',
'회의 안건',
'1. Q4 마케팅 캠페인 기획
2. 예산 배분 논의
3. 실행 일정 수립
[주요 논의 사항]
마케팅 캠페인에 대한 논의를 진행했습니다. 소셜 미디어 광고와 인플루언서 마케팅을 결합한 통합 캠페인을 제안했으며, 예산은 총 5천만원으로 책정하는 것이 적절하다는 의견이 나왔습니다. 실행 시기는 11월 초부터 12월 말까지로 설정하기로 했습니다.
김대리가 인플루언서 리스트 작성하기로 함, 다음주 금요일까지 완료 예정. 박과장은 캠페인 콘텐츠 기획안 초안을 이번주 내로 준비하기로 했습니다.
[결정 사항]
1. 소셜 미디어 + 인플루언서 통합 캠페인 실행
2. 예산: 5천만원 배정
3. 기간: 11월 초 ~ 12월 말',
1,
false,
false,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
);
-- 8. 참석자별 회의록(minutes) 생성 - user2
INSERT INTO minutes (
minutes_id,
meeting_id,
user_id,
title,
status,
version,
created_by,
created_at,
updated_at
) VALUES (
'minutes-test-user2',
'meeting-test',
'user2',
'Q4 마케팅 전략 회의 - user2 작성',
'DRAFT',
1,
'user2',
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
);
-- 9. user2 회의록 섹션 - MEMO
INSERT INTO minutes_sections (
id,
section_id,
minutes_id,
type,
title,
content,
"order",
verified,
locked,
created_at,
updated_at
) VALUES
(
'section-user2-1',
'section-user2-1',
'minutes-test-user2',
'MEMO',
'회의 목표',
'Q4 마케팅 전략 수립 및 예산 확정
[논의 내용]
Q4 마케팅 전략으로 디지털 마케팅 강화 방안을 논의했습니다. 특히 인스타그램과 유튜브를 활용한 인플루언서 마케팅이 효과적일 것으로 판단됩니다. 타겟 연령층은 20-30대이며, 예산은 광고비 3천만원, 인플루언서 비용 2천만원으로 분배하는 것이 좋겠다는 의견이 있었습니다.
이대리가 경쟁사 소셜 미디어 분석 보고서를 다음 주 수요일까지 제출하기로 했고, 최주임은 광고 플랫폼 선정 및 견적 비교 자료를 월요일까지 준비합니다.
[결정 사항]
1. 인스타그램, 유튜브 중심 인플루언서 마케팅 진행
2. 타겟: 20-30대
3. 예산: 광고비 3천만원, 인플루언서 2천만원',
1,
false,
false,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
);
-- 참석자별 회의록(minutes) 생성 - user3
INSERT INTO minutes (
minutes_id,
meeting_id,
user_id,
title,
status,
version,
created_by,
created_at,
updated_at
) VALUES (
'minutes-test-user3',
'meeting-test',
'user3',
'Q4 마케팅 전략 회의 - user3 작성',
'DRAFT',
1,
'user3',
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
);
-- 14. user3 회의록 섹션 - MEMO
INSERT INTO minutes_sections (
id,
section_id,
minutes_id,
type,
title,
content,
"order",
verified,
locked,
created_at,
updated_at
) VALUES
(
'section-user3-1',
'section-user3-1',
'minutes-test-user3',
'MEMO',
'안건',
'1. Q4 마케팅 목표 설정
2. 채널별 예산 배분
3. KPI 지표 정의
[회의 내용]
Q4 마케팅 목표는 브랜드 인지도 상승과 매출 증대로 설정. 예산 5천만원을 디지털 광고와 인플루언서 마케팅에 분배하며, 성과 측정을 위해 도달률, 참여율, 전환율을 KPI로 설정. ROI 목표는 150%
정차장님께서 예산 집행 계획서를 이번 주 목요일까지 작성하시기로 하셨습니다. KPI 대시보드 구축은 한팀장님이 담당하시고 11월 첫째 주까지 완료 예정입니다.',
1,
false,
false,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
);
/*
-- 16. user3 회의록 섹션 - DECISION
INSERT INTO minutes_sections (
id,
section_id,
minutes_id,
type,
title,
content,
"order",
verified,
locked,
created_at,
updated_at
) VALUES
(
'section-user3-3',
'section-user3-3',
'minutes-test-user3',
'DECISION',
'결정 사항',
'1. 목표: 브랜드 인지도 상승 + 매출 증대\n2. 예산: 총 5천만원 (디지털 광고 + 인플루언서)\n3. KPI: 도달률, 참여율, 전환율, ROI 150% 이상',
3,
false,
false,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
);
*/
-- 18. 데이터 확인
SELECT 'Minutes 데이터:' AS info;
SELECT * FROM minutes WHERE meeting_id = 'meeting-test';
SELECT 'Minutes Sections 데이터:' AS info;
SELECT ms.section_id, ms.minutes_id, m.created_by, m.user_id, ms.type, ms.title,
LENGTH(ms.content) as content_length, ms."order"
FROM minutes_sections ms
JOIN minutes m ON ms.minutes_id = m.minutes_id
WHERE m.meeting_id = 'meeting-test'
ORDER BY m.created_by, ms."order";