diff --git a/ai-python/app/api/v1/__init__.py b/ai-python/app/api/v1/__init__.py new file mode 100644 index 0000000..cf5f171 --- /dev/null +++ b/ai-python/app/api/v1/__init__.py @@ -0,0 +1,8 @@ +"""API v1 Router""" +from fastapi import APIRouter +from .transcripts import router as transcripts_router + +router = APIRouter() + +# 라우터 등록 +router.include_router(transcripts_router, prefix="/transcripts", tags=["Transcripts"]) diff --git a/ai-python/app/api/v1/transcripts.py b/ai-python/app/api/v1/transcripts.py new file mode 100644 index 0000000..35f3d81 --- /dev/null +++ b/ai-python/app/api/v1/transcripts.py @@ -0,0 +1,53 @@ +"""Transcripts API Router""" +from fastapi import APIRouter, HTTPException, status +import logging +from app.models.transcript import ConsolidateRequest, ConsolidateResponse +from app.services.transcript_service import transcript_service + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +@router.post("/consolidate", response_model=ConsolidateResponse, status_code=status.HTTP_200_OK) +async def consolidate_minutes(request: ConsolidateRequest): + """ + 회의록 통합 요약 + + 참석자별로 작성한 회의록을 AI가 통합하여 요약합니다. + + - **meeting_id**: 회의 ID + - **participant_minutes**: 참석자별 회의록 목록 + - **agendas**: 안건 목록 (선택) + - **duration_minutes**: 회의 시간(분) (선택) + + Returns: + - 통합 요약, 키워드, 안건별 분석, Todo 자동 추출 + """ + try: + logger.info(f"POST /transcripts/consolidate - Meeting ID: {request.meeting_id}") + + # 입력 검증 + if not request.participant_minutes: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="참석자 회의록이 비어있습니다" + ) + + # 회의록 통합 처리 + response = await transcript_service.consolidate_minutes(request) + + return response + + except ValueError as e: + logger.error(f"입력 값 오류: {e}") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e) + ) + except Exception as e: + logger.error(f"회의록 통합 실패: {e}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"회의록 통합 처리 중 오류가 발생했습니다: {str(e)}" + ) diff --git a/ai-python/app/config.py b/ai-python/app/config.py new file mode 100644 index 0000000..798b319 --- /dev/null +++ b/ai-python/app/config.py @@ -0,0 +1,57 @@ +"""환경 설정""" +from pydantic_settings import BaseSettings +from functools import lru_cache +from typing import List + + +class Settings(BaseSettings): + """환경 설정 클래스""" + + # 서버 설정 + app_name: str = "AI Service (Python)" + host: str = "0.0.0.0" + port: int = 8087 # feature/stt-ai 브랜치 AI Service(8086)와 충돌 방지 + + # Claude API + claude_api_key: str = "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" + claude_model: str = "claude-3-5-sonnet-20241022" + claude_max_tokens: int = 250000 + claude_temperature: float = 0.7 + + # Redis + redis_host: str = "20.249.177.114" + redis_port: int = 6379 + redis_password: str = "" + redis_db: int = 4 + + # Azure Event Hub + eventhub_connection_string: str = "Endpoint=sb://hgzero-eventhub-ns.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=VUqZ9vFgu35E3c6RiUzoOGVUP8IZpFvlV+AEhC6sUpo=" + eventhub_name: str = "hgzero-eventhub-name" + eventhub_consumer_group: str = "ai-transcript-group" + + # CORS + cors_origins: List[str] = [ + "http://localhost:8888", + "http://localhost:8080", + "http://localhost:3000", + "http://127.0.0.1:8888", + "http://127.0.0.1:8080", + "http://127.0.0.1:3000" + ] + + # 로깅 + log_level: str = "INFO" + + # 분석 임계값 + min_segments_for_analysis: int = 10 + text_retention_seconds: int = 300 # 5분 + + class Config: + env_file = ".env" + case_sensitive = False + + +@lru_cache() +def get_settings() -> Settings: + """싱글톤 설정 인스턴스""" + return Settings() diff --git a/ai-python/app/models/__init__.py b/ai-python/app/models/__init__.py new file mode 100644 index 0000000..a8b9a1b --- /dev/null +++ b/ai-python/app/models/__init__.py @@ -0,0 +1,16 @@ +"""Data Models""" +from .transcript import ( + ConsolidateRequest, + ConsolidateResponse, + AgendaSummary, + ParticipantMinutes, + ExtractedTodo +) + +__all__ = [ + "ConsolidateRequest", + "ConsolidateResponse", + "AgendaSummary", + "ParticipantMinutes", + "ExtractedTodo", +] diff --git a/ai-python/app/models/keyword.py b/ai-python/app/models/keyword.py new file mode 100644 index 0000000..7f8ee06 --- /dev/null +++ b/ai-python/app/models/keyword.py @@ -0,0 +1,69 @@ +"""Keyword Models""" +from pydantic import BaseModel, Field +from typing import List +from datetime import datetime + + +class KeywordExtractRequest(BaseModel): + """주요 키워드 추출 요청""" + meeting_id: str = Field(..., description="회의 ID") + transcript_text: str = Field(..., description="전체 회의록 텍스트") + max_keywords: int = Field(default=10, ge=1, le=20, description="최대 키워드 개수") + + class Config: + json_schema_extra = { + "example": { + "meeting_id": "550e8400-e29b-41d4-a716-446655440000", + "transcript_text": "안건 1: 신제품 기획...\n타겟 고객을 20-30대로 설정...", + "max_keywords": 10 + } + } + + +class ExtractedKeyword(BaseModel): + """추출된 키워드""" + keyword: str = Field(..., description="키워드") + relevance_score: float = Field(..., ge=0.0, le=1.0, description="관련성 점수") + frequency: int = Field(..., description="출현 빈도") + category: str = Field(..., description="카테고리 (예: 기술, 전략, 일정 등)") + + class Config: + json_schema_extra = { + "example": { + "keyword": "신제품기획", + "relevance_score": 0.95, + "frequency": 15, + "category": "전략" + } + } + + +class KeywordExtractResponse(BaseModel): + """주요 키워드 추출 응답""" + meeting_id: str = Field(..., description="회의 ID") + keywords: List[ExtractedKeyword] = Field(..., description="추출된 키워드 목록") + total_count: int = Field(..., description="전체 키워드 개수") + extracted_at: datetime = Field(default_factory=datetime.utcnow, description="추출 시각") + + class Config: + json_schema_extra = { + "example": { + "meeting_id": "550e8400-e29b-41d4-a716-446655440000", + "keywords": [ + { + "keyword": "신제품기획", + "relevance_score": 0.95, + "frequency": 15, + "category": "전략" + }, + { + "keyword": "예산편성", + "relevance_score": 0.88, + "frequency": 12, + "category": "재무" + } + ], + "total_count": 10, + "extracted_at": "2025-01-23T10:30:00Z" + } + } diff --git a/ai-python/app/models/todo.py b/ai-python/app/models/todo.py new file mode 100644 index 0000000..4e45e51 --- /dev/null +++ b/ai-python/app/models/todo.py @@ -0,0 +1,80 @@ +"""Todo Models""" +from pydantic import BaseModel, Field +from typing import List, Optional +from datetime import datetime, date +from enum import Enum + + +class PriorityLevel(str, Enum): + """우선순위""" + HIGH = "HIGH" + MEDIUM = "MEDIUM" + LOW = "LOW" + + +class TodoExtractRequest(BaseModel): + """Todo 자동 추출 요청""" + meeting_id: str = Field(..., description="회의 ID") + transcript_text: str = Field(..., description="전체 회의록 텍스트") + participants: List[str] = Field(..., description="참석자 목록") + + class Config: + json_schema_extra = { + "example": { + "meeting_id": "550e8400-e29b-41d4-a716-446655440000", + "transcript_text": "안건 1: 신제품 기획...\n결정사항: API 설계서는 박민수님이 1월 30일까지 작성...", + "participants": ["김민준", "박서연", "이준호", "박민수"] + } + } + + +class ExtractedTodo(BaseModel): + """추출된 Todo""" + title: str = Field(..., description="Todo 제목") + description: Optional[str] = Field(None, description="상세 설명") + assignee: str = Field(..., description="담당자 이름") + due_date: Optional[date] = Field(None, description="마감일") + priority: PriorityLevel = Field(default=PriorityLevel.MEDIUM, description="우선순위") + section_reference: str = Field(..., description="섹션 참조 (예: '결정사항 #1')") + confidence_score: float = Field(..., ge=0.0, le=1.0, description="신뢰도 점수") + + class Config: + json_schema_extra = { + "example": { + "title": "API 설계서 작성", + "description": "신규 프로젝트 API 설계서 작성 완료", + "assignee": "박민수", + "due_date": "2025-01-30", + "priority": "HIGH", + "section_reference": "결정사항 #1", + "confidence_score": 0.92 + } + } + + +class TodoExtractResponse(BaseModel): + """Todo 자동 추출 응답""" + meeting_id: str = Field(..., description="회의 ID") + todos: List[ExtractedTodo] = Field(..., description="추출된 Todo 목록") + total_count: int = Field(..., description="전체 Todo 개수") + extracted_at: datetime = Field(default_factory=datetime.utcnow, description="추출 시각") + + class Config: + json_schema_extra = { + "example": { + "meeting_id": "550e8400-e29b-41d4-a716-446655440000", + "todos": [ + { + "title": "API 설계서 작성", + "description": "신규 프로젝트 API 설계서 작성 완료", + "assignee": "박민수", + "due_date": "2025-01-30", + "priority": "HIGH", + "section_reference": "결정사항 #1", + "confidence_score": 0.92 + } + ], + "total_count": 5, + "extracted_at": "2025-01-23T10:30:00Z" + } + } diff --git a/ai-python/app/models/transcript.py b/ai-python/app/models/transcript.py new file mode 100644 index 0000000..5eaa7a6 --- /dev/null +++ b/ai-python/app/models/transcript.py @@ -0,0 +1,44 @@ +"""Transcript Models""" +from pydantic import BaseModel, Field +from typing import List, Optional, Dict +from datetime import datetime + + +class ParticipantMinutes(BaseModel): + """참석자별 회의록""" + user_id: str = Field(..., description="사용자 ID") + user_name: str = Field(..., description="사용자 이름") + content: str = Field(..., description="회의록 전체 내용 (MEMO 섹션)") + + +class ConsolidateRequest(BaseModel): + """회의록 통합 요약 요청""" + meeting_id: str = Field(..., description="회의 ID") + participant_minutes: List[ParticipantMinutes] = Field(..., description="참석자별 회의록 목록") + agendas: Optional[List[str]] = Field(None, description="안건 목록") + duration_minutes: Optional[int] = Field(None, description="회의 시간(분)") + + +class ExtractedTodo(BaseModel): + """추출된 Todo (제목만)""" + title: str = Field(..., description="Todo 제목") + + +class AgendaSummary(BaseModel): + """안건별 요약""" + agenda_number: int = Field(..., description="안건 번호") + agenda_title: str = Field(..., description="안건 제목") + summary_short: str = Field(..., description="짧은 요약 (1줄)") + discussion: 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 목록 (제목만)") + + +class ConsolidateResponse(BaseModel): + """회의록 통합 요약 응답""" + meeting_id: str = Field(..., description="회의 ID") + keywords: List[str] = Field(..., description="주요 키워드") + statistics: Dict[str, int] = Field(..., description="통계 정보") + agenda_summaries: List[AgendaSummary] = Field(..., description="안건별 요약") + generated_at: datetime = Field(default_factory=datetime.utcnow, description="생성 시각") diff --git a/ai-python/app/prompts/consolidate_prompt.py b/ai-python/app/prompts/consolidate_prompt.py new file mode 100644 index 0000000..aeb2810 --- /dev/null +++ b/ai-python/app/prompts/consolidate_prompt.py @@ -0,0 +1,98 @@ +"""회의록 통합 요약 프롬프트""" + + +def get_consolidate_prompt(participant_minutes: list, agendas: list = None) -> str: + """ + 참석자별 회의록을 통합하여 요약하는 프롬프트 생성 + """ + + # 참석자 회의록 결합 + participants_content = "\n\n".join([ + f"## {p['user_name']}님의 회의록:\n{p['content']}" + for p in participant_minutes + ]) + + # 안건 정보 (있는 경우) + agendas_info = "" + if agendas: + agendas_info = f"\n\n**사전 정의된 안건**:\n" + "\n".join([ + f"{i+1}. {agenda}" for i, agenda in enumerate(agendas) + ]) + + prompt = f"""당신은 회의록 작성 전문가입니다. 여러 참석자가 작성한 회의록을 통합하여 정확하고 체계적인 회의록을 생성해주세요. + +# 입력 데이터 + +{participants_content}{agendas_info} + +--- + +# 작업 지침 + +1. **주요 키워드 (keywords)**: + - 회의에서 자주 언급된 핵심 키워드 5-10개 추출 + - 단어 또는 짧은 구문 (예: "신제품기획", "예산편성") + +2. **통계 정보 (statistics)**: + - agendas_count: 안건 개수 (내용 기반 추정) + - todos_count: 추출된 Todo 총 개수 + +3. **안건별 요약 (agenda_summaries)**: + 회의 내용을 분석하여 안건별로 구조화: + + 각 안건마다: + - **agenda_number**: 안건 번호 (1, 2, 3...) + - **agenda_title**: 안건 제목 (간결하게) + - **summary_short**: 1줄 요약 (20자 이내) + - **discussion**: 논의 주제 (핵심 내용 3-5문장) + - **decisions**: 결정 사항 배열 (해당 안건 관련) + - **pending**: 보류 사항 배열 (추가 논의 필요 사항) + - **todos**: Todo 배열 (제목만, 담당자/마감일/우선순위 없음) + - title: Todo 제목만 추출 (예: "시장 조사 보고서 작성") + +--- + +# 출력 형식 + +반드시 아래 JSON 형식으로만 응답하세요. 다른 텍스트는 포함하지 마세요. + +```json +{{ + "keywords": ["키워드1", "키워드2", "키워드3"], + "statistics": {{ + "agendas_count": 숫자, + "todos_count": 숫자 + }}, + "agenda_summaries": [ + {{ + "agenda_number": 1, + "agenda_title": "안건 제목", + "summary_short": "짧은 요약", + "discussion": "논의 내용", + "decisions": ["결정사항"], + "pending": ["보류사항"], + "todos": [ + {{ + "title": "Todo 제목" + }} + ] + }} + ] +}} +``` + +--- + +# 중요 규칙 + +1. **정확성**: 참석자 회의록에 명시된 내용만 사용 +2. **객관성**: 추측이나 가정 없이 사실만 기록 +3. **완전성**: 모든 필드를 빠짐없이 작성 +4. **구조화**: 안건별로 명확히 분리 +5. **Todo 추출**: 제목만 추출 (담당자나 마감일 없어도 됨) +6. **JSON만 출력**: 추가 설명 없이 JSON만 반환 + +이제 위 회의록들을 분석하여 통합 요약을 JSON 형식으로 생성해주세요. +""" + + return prompt diff --git a/ai-python/app/services/claude_service.py b/ai-python/app/services/claude_service.py new file mode 100644 index 0000000..5dfcd6e --- /dev/null +++ b/ai-python/app/services/claude_service.py @@ -0,0 +1,90 @@ +"""Claude API Service""" +import anthropic +import json +import logging +from typing import Dict, Any +from app.config import get_settings + +logger = logging.getLogger(__name__) +settings = get_settings() + + +class ClaudeService: + """Claude API 호출 서비스""" + + def __init__(self): + self.client = anthropic.Anthropic(api_key=settings.claude_api_key) + self.model = settings.claude_model + self.max_tokens = settings.claude_max_tokens + self.temperature = settings.claude_temperature + + async def generate_completion( + self, + prompt: str, + system_prompt: str = None + ) -> Dict[str, Any]: + """ + Claude API 호출하여 응답 생성 + + Args: + prompt: 사용자 프롬프트 + system_prompt: 시스템 프롬프트 (선택) + + Returns: + Claude API 응답 (JSON 파싱) + """ + try: + # 메시지 구성 + messages = [ + { + "role": "user", + "content": prompt + } + ] + + # API 호출 + logger.info(f"Claude API 호출 시작 - Model: {self.model}") + + if system_prompt: + response = self.client.messages.create( + model=self.model, + max_tokens=self.max_tokens, + temperature=self.temperature, + system=system_prompt, + messages=messages + ) + else: + response = self.client.messages.create( + model=self.model, + max_tokens=self.max_tokens, + temperature=self.temperature, + messages=messages + ) + + # 응답 텍스트 추출 + response_text = response.content[0].text + logger.info(f"Claude API 응답 수신 완료 - Tokens: {response.usage.input_tokens + response.usage.output_tokens}") + + # JSON 파싱 + # ```json ... ``` 블록 제거 + if "```json" in response_text: + response_text = response_text.split("```json")[1].split("```")[0].strip() + elif "```" in response_text: + response_text = response_text.split("```")[1].split("```")[0].strip() + + result = json.loads(response_text) + + return result + + except json.JSONDecodeError as e: + logger.error(f"JSON 파싱 실패: {e}") + logger.error(f"응답 텍스트: {response_text[:500]}...") + raise ValueError(f"Claude API 응답을 JSON으로 파싱할 수 없습니다: {str(e)}") + + except Exception as e: + logger.error(f"Claude API 호출 실패: {e}") + raise + + +# 싱글톤 인스턴스 +claude_service = ClaudeService() diff --git a/ai-python/app/services/transcript_service.py b/ai-python/app/services/transcript_service.py new file mode 100644 index 0000000..2f2342d --- /dev/null +++ b/ai-python/app/services/transcript_service.py @@ -0,0 +1,114 @@ +"""Transcript Service - 회의록 통합 처리""" +import logging +from datetime import datetime +from app.models.transcript import ( + ConsolidateRequest, + ConsolidateResponse, + AgendaSummary, + ExtractedTodo +) +from app.services.claude_service import claude_service +from app.prompts.consolidate_prompt import get_consolidate_prompt + +logger = logging.getLogger(__name__) + + +class TranscriptService: + """회의록 통합 서비스""" + + async def consolidate_minutes( + self, + request: ConsolidateRequest + ) -> ConsolidateResponse: + """ + 참석자별 회의록을 통합하여 AI 요약 생성 + """ + logger.info(f"회의록 통합 시작 - Meeting ID: {request.meeting_id}") + logger.info(f"참석자 수: {len(request.participant_minutes)}") + + try: + # 1. 프롬프트 생성 + participant_data = [ + { + "user_name": pm.user_name, + "content": pm.content + } + for pm in request.participant_minutes + ] + + prompt = get_consolidate_prompt( + participant_minutes=participant_data, + agendas=request.agendas + ) + + # 2. Claude API 호출 + start_time = datetime.utcnow() + ai_result = await claude_service.generate_completion(prompt) + processing_time = (datetime.utcnow() - start_time).total_seconds() * 1000 + + logger.info(f"AI 처리 완료 - {processing_time:.0f}ms") + + # 3. 응답 구성 + response = self._build_response( + meeting_id=request.meeting_id, + ai_result=ai_result, + participants_count=len(request.participant_minutes), + duration_minutes=request.duration_minutes + ) + + logger.info(f"통합 요약 완료 - 안건 수: {len(response.agenda_summaries)}, Todo 수: {response.statistics['todos_count']}") + + return response + + except Exception as e: + logger.error(f"회의록 통합 실패: {e}", exc_info=True) + raise + + def _build_response( + self, + meeting_id: str, + ai_result: dict, + participants_count: int, + duration_minutes: int = None + ) -> ConsolidateResponse: + """AI 응답을 ConsolidateResponse로 변환""" + + # 안건별 요약 변환 + agenda_summaries = [] + for agenda_data in ai_result.get("agenda_summaries", []): + # Todo 변환 (제목만) + todos = [ + ExtractedTodo(title=todo.get("title", "")) + for todo in agenda_data.get("todos", []) + ] + + agenda_summaries.append( + AgendaSummary( + agenda_number=agenda_data.get("agenda_number", 0), + agenda_title=agenda_data.get("agenda_title", ""), + summary_short=agenda_data.get("summary_short", ""), + discussion=agenda_data.get("discussion", ""), + decisions=agenda_data.get("decisions", []), + pending=agenda_data.get("pending", []), + todos=todos + ) + ) + + # 통계 정보 + statistics = ai_result.get("statistics", {}) + statistics["participants_count"] = participants_count + if duration_minutes: + statistics["duration_minutes"] = duration_minutes + + # 응답 생성 + return ConsolidateResponse( + meeting_id=meeting_id, + keywords=ai_result.get("keywords", []), + statistics=statistics, + agenda_summaries=agenda_summaries, + generated_at=datetime.utcnow() + ) + + +# 싱글톤 인스턴스 +transcript_service = TranscriptService() diff --git a/ai-python/main.py b/ai-python/main.py new file mode 100644 index 0000000..a92cf16 --- /dev/null +++ b/ai-python/main.py @@ -0,0 +1,58 @@ +"""AI Service FastAPI Application""" +import uvicorn +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from app.config import get_settings +from app.api.v1 import router as api_v1_router +import logging + +# 로깅 설정 +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + +# Settings +settings = get_settings() + +# FastAPI 앱 생성 +app = FastAPI( + title=settings.app_name, + description="AI-powered meeting minutes analysis service", + version="1.0.0", + docs_url="/api/docs", + redoc_url="/api/redoc", + openapi_url="/api/openapi.json" +) + +# CORS 미들웨어 설정 +app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# API 라우터 등록 +app.include_router(api_v1_router, prefix="/api/v1") + + +@app.get("/health") +async def health_check(): + """헬스 체크""" + return { + "status": "healthy", + "service": settings.app_name + } + + +if __name__ == "__main__": + uvicorn.run( + "main:app", + host=settings.host, + port=settings.port, + reload=True, + log_level=settings.log_level.lower() + ) diff --git a/ai-python/requirements.txt b/ai-python/requirements.txt new file mode 100644 index 0000000..17e911b --- /dev/null +++ b/ai-python/requirements.txt @@ -0,0 +1,11 @@ +# FastAPI +fastapi==0.115.0 +uvicorn[standard]==0.32.0 +pydantic==2.9.2 +pydantic-settings==2.6.0 + +# Claude API +anthropic==0.39.0 + +# Utilities +python-dotenv==1.0.1 diff --git a/claude/MEETING-AI-TEST-GUIDE.md b/claude/MEETING-AI-TEST-GUIDE.md new file mode 100644 index 0000000..06a9a91 --- /dev/null +++ b/claude/MEETING-AI-TEST-GUIDE.md @@ -0,0 +1,392 @@ +# Meeting AI 통합 실행 및 테스트 가이드 + +작성일: 2025-10-28 +작성자: 이동욱 (Backend Developer) + +## 📋 목차 +1. [사전 준비](#사전-준비) +2. [AI Python Service 실행](#ai-python-service-실행) +3. [Meeting Service 실행](#meeting-service-실행) +4. [통합 테스트](#통합-테스트) +5. [트러블슈팅](#트러블슈팅) + +--- + +## 사전 준비 + +### 1. 포트 확인 +```bash +# 포트 사용 확인 +lsof -i :8082 # Meeting Service +lsof -i :8087 # AI Python Service +``` + +### 2. 데이터베이스 확인 +```sql +-- PostgreSQL 연결 확인 +psql -h 4.230.48.72 -U hgzerouser -d meetingdb + +-- 필요한 테이블 확인 +\dt meeting_analysis +\dt todos +\dt meetings +\dt agenda_sections +``` + +### 3. Redis 확인 +```bash +# Redis 연결 테스트 +redis-cli -h 20.249.177.114 -p 6379 -a Hi5Jessica! ping +``` + +--- + +## AI Python Service 실행 + +### 1. 디렉토리 이동 +```bash +cd /Users/jominseo/HGZero/ai-python +``` + +### 2. 환경 변수 확인 +```bash +# .env 파일 확인 (없으면 .env.example에서 복사) +cat .env + +# 필수 환경 변수: +# - PORT=8087 +# - CLAUDE_API_KEY=sk-ant-api03-... +# - REDIS_HOST=20.249.177.114 +# - REDIS_PORT=6379 +``` + +### 3. 의존성 설치 +```bash +# Python 가상환경 활성화 (선택사항) +source venv/bin/activate # 또는 python3 -m venv venv + +# 의존성 설치 +pip install -r requirements.txt +``` + +### 4. 서비스 실행 +```bash +# 방법 1: 직접 실행 +python3 main.py + +# 방법 2: uvicorn으로 실행 +uvicorn main:app --host 0.0.0.0 --port 8087 --reload + +# 방법 3: 백그라운드 실행 +nohup python3 main.py > logs/ai-python.log 2>&1 & echo "Started AI Python Service with PID: $!" +``` + +### 5. 상태 확인 +```bash +# Health Check +curl http://localhost:8087/health + +# 기대 응답: +# {"status":"healthy","service":"AI Service (Python)"} + +# API 문서 확인 +open http://localhost:8087/docs +``` + +--- + +## Meeting Service 실행 + +### 1. 디렉토리 이동 +```bash +cd /Users/jominseo/HGZero +``` + +### 2. 빌드 +```bash +# Java 21 사용 +export JAVA_HOME=/Library/Java/JavaVirtualMachines/jdk-21.jdk/Contents/Home + +# 빌드 +./gradlew :meeting:clean :meeting:build -x test +``` + +### 3. 실행 +```bash +# 방법 1: Gradle로 실행 +./gradlew :meeting:bootRun + +# 방법 2: JAR 실행 +java -jar meeting/build/libs/meeting-0.0.1-SNAPSHOT.jar + +# 방법 3: IntelliJ 실행 프로파일 사용 +python3 tools/run-intellij-service-profile.py meeting +``` + +### 4. 상태 확인 +```bash +# Health Check +curl http://localhost:8082/actuator/health + +# Swagger UI +open http://localhost:8082/swagger-ui.html +``` + +--- + +## 통합 테스트 + +### 테스트 시나리오 + +#### 1. 회의 생성 (사전 작업) +```bash +curl -X POST http://localhost:8082/api/meetings \ + -H "Content-Type: application/json" \ + -H "X-User-Id: user123" \ + -H "X-User-Name: 홍길동" \ + -H "X-User-Email: hong@example.com" \ + -d '{ + "title": "AI 통합 테스트 회의", + "purpose": "Meeting AI 기능 테스트", + "scheduledAt": "2025-10-28T14:00:00", + "endTime": "2025-10-28T15:00:00", + "location": "회의실 A", + "participantIds": ["user123", "user456"] + }' +``` + +응답에서 `meetingId` 저장 + + +#### 2. 회의 시작 +```bash +MEETING_ID="위에서 받은 meetingId" + +curl -X POST http://localhost:8082/api/meetings/${MEETING_ID}/start \ + -H "X-User-Id: user123" \ + -H "X-User-Name: 홍길동" \ + -H "X-User-Email: hong@example.com" +``` + + +#### 3. 안건 섹션 생성 (테스트 데이터) +```sql +-- PostgreSQL에서 직접 실행 +INSERT INTO agenda_sections ( + id, minutes_id, meeting_id, agenda_number, agenda_title, + ai_summary_short, discussions, + decisions, pending_items, opinions, todos, + created_at, updated_at +) VALUES ( + 'agenda-001', 'minutes-001', '위의_meetingId', 1, '신제품 기획', + NULL, + '타겟 고객층을 20-30대로 설정하고 UI/UX 개선에 집중하기로 논의했습니다.', + '["타겟 고객: 20-30대 직장인", "UI/UX 개선 최우선"]'::json, + '["가격 정책 추가 검토 필요"]'::json, + '[]'::json, + '[]'::json, + NOW(), NOW() +), +( + 'agenda-002', 'minutes-001', '위의_meetingId', 2, '마케팅 전략', + NULL, + 'SNS 마케팅과 인플루언서 협업을 통한 브랜드 인지도 제고 방안을 논의했습니다.', + '["SNS 광고 집행", "인플루언서 3명과 계약"]'::json, + '["예산 승인 대기"]'::json, + '[]'::json, + '[]'::json, + NOW(), NOW() +); +``` + + +#### 4. **핵심 테스트: 회의 종료 API 호출** +```bash +curl -X POST http://localhost:8082/api/meetings/${MEETING_ID}/end \ + -H "X-User-Id: user123" \ + -H "X-User-Name: 홍길동" \ + -H "X-User-Email: hong@example.com" \ + -v +``` + +**기대 응답:** +```json +{ + "success": true, + "data": { + "title": "AI 통합 테스트 회의", + "participantCount": 2, + "durationMinutes": 60, + "agendaCount": 2, + "todoCount": 5, + "keywords": ["신제품", "UI/UX", "마케팅", "SNS", "인플루언서"], + "agendaSummaries": [ + { + "title": "안건 1: 신제품 기획", + "aiSummaryShort": "타겟 고객 설정 및 UI/UX 개선 방향 논의", + "details": { + "discussion": "타겟 고객층을 20-30대로 설정...", + "decisions": ["타겟 고객: 20-30대 직장인", "UI/UX 개선 최우선"], + "pending": ["가격 정책 추가 검토 필요"] + }, + "todos": [ + {"title": "시장 조사 보고서 작성"}, + {"title": "UI/UX 개선안 프로토타입 제작"} + ] + }, + { + "title": "안건 2: 마케팅 전략", + "aiSummaryShort": "SNS 마케팅 및 인플루언서 협업 계획", + "details": { + "discussion": "SNS 마케팅과 인플루언서 협업...", + "decisions": ["SNS 광고 집행", "인플루언서 3명과 계약"], + "pending": ["예산 승인 대기"] + }, + "todos": [ + {"title": "인플루언서 계약서 작성"}, + {"title": "SNS 광고 컨텐츠 제작"}, + {"title": "예산안 제출"} + ] + } + ] + } +} +``` + + +#### 5. 결과 확인 + +**데이터베이스 확인:** +```sql +-- 회의 상태 확인 +SELECT meeting_id, title, status, ended_at +FROM meetings +WHERE meeting_id = '위의_meetingId'; +-- 기대: status = 'COMPLETED' + +-- AI 분석 결과 확인 +SELECT analysis_id, meeting_id, keywords, status, completed_at +FROM meeting_analysis +WHERE meeting_id = '위의_meetingId'; + +-- Todo 확인 +SELECT todo_id, title, status +FROM todos +WHERE meeting_id = '위의_meetingId'; +-- 기대: 5개의 Todo 생성 +``` + +**로그 확인:** +```bash +# AI Python Service 로그 +tail -f logs/ai-python.log + +# Meeting Service 로그 +tail -f meeting/logs/meeting-service.log +``` + +--- + +## 트러블슈팅 + +### 1. AI Python Service 연결 실패 +``` +에러: Connection refused (8087) + +해결: +1. AI Python Service가 실행 중인지 확인 + ps aux | grep python | grep main.py +2. 포트 확인 + lsof -i :8087 +3. 로그 확인 + tail -f logs/ai-python.log +``` + +### 2. Claude API 오류 +``` +에러: Invalid API key + +해결: +1. .env 파일의 CLAUDE_API_KEY 확인 +2. API 키 유효성 확인 + curl https://api.anthropic.com/v1/messages \ + -H "x-api-key: $CLAUDE_API_KEY" \ + -H "anthropic-version: 2023-06-01" +``` + +### 3. 데이터베이스 연결 실패 +``` +에러: Connection to 4.230.48.72:5432 refused + +해결: +1. PostgreSQL 서버 상태 확인 +2. 방화벽 규칙 확인 +3. application.yml의 DB 설정 확인 +``` + +### 4. 타임아웃 오류 +``` +에러: Read timeout (30초) + +해결: +1. application.yml에서 타임아웃 증가 + ai.service.timeout=60000 +2. Claude API 응답 시간 확인 +3. 네트워크 상태 확인 +``` + +### 5. 안건 데이터 없음 +``` +에러: No agenda sections found + +해결: +1. agenda_sections 테이블에 데이터 확인 + SELECT * FROM agenda_sections WHERE meeting_id = '해당ID'; +2. 테스트 데이터 삽입 (위 SQL 참조) +``` + +--- + +## 성능 측정 + +### 응답 시간 측정 +```bash +# 회의 종료 API 응답 시간 +time curl -X POST http://localhost:8082/api/meetings/${MEETING_ID}/end \ + -H "X-User-Id: user123" \ + -H "X-User-Name: 홍길동" \ + -H "X-User-Email: hong@example.com" + +# 기대 시간: 5-15초 (Claude API 호출 포함) +``` + +### 동시성 테스트 +```bash +# Apache Bench로 부하 테스트 (선택사항) +ab -n 10 -c 2 -H "X-User-Id: user123" \ + http://localhost:8087/health +``` + +--- + +## 체크리스트 + +- [ ] AI Python Service 실행 (8087) +- [ ] Meeting Service 실행 (8082) +- [ ] 데이터베이스 연결 확인 +- [ ] Redis 연결 확인 +- [ ] 회의 생성 API 성공 +- [ ] 회의 시작 API 성공 +- [ ] 안건 데이터 삽입 +- [ ] **회의 종료 API 성공** +- [ ] AI 분석 결과 저장 확인 +- [ ] Todo 자동 생성 확인 +- [ ] 회의 상태 COMPLETED 확인 + +--- + +## 참고 링크 + +- AI Python Service: http://localhost:8087/docs +- Meeting Service Swagger: http://localhost:8082/swagger-ui.html +- Claude API 문서: https://docs.anthropic.com/claude/reference diff --git a/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/service/EndMeetingService.java b/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/service/EndMeetingService.java new file mode 100644 index 0000000..57e8a6f --- /dev/null +++ b/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/service/EndMeetingService.java @@ -0,0 +1,237 @@ +package com.unicorn.hgzero.meeting.biz.service; + +import com.unicorn.hgzero.meeting.biz.domain.MeetingAnalysis; +import com.unicorn.hgzero.meeting.biz.dto.MeetingEndDTO; +import com.unicorn.hgzero.meeting.biz.usecase.in.meeting.EndMeetingUseCase; +import com.unicorn.hgzero.meeting.infra.client.AIServiceClient; +import com.unicorn.hgzero.meeting.infra.dto.ai.AgendaSummaryDTO; +import com.unicorn.hgzero.meeting.infra.dto.ai.ConsolidateRequest; +import com.unicorn.hgzero.meeting.infra.dto.ai.ConsolidateResponse; +import com.unicorn.hgzero.meeting.infra.dto.ai.ExtractedTodoDTO; +import com.unicorn.hgzero.meeting.infra.dto.ai.ParticipantMinutesDTO; +import com.unicorn.hgzero.meeting.infra.gateway.entity.AgendaSectionEntity; +import com.unicorn.hgzero.meeting.infra.gateway.entity.MeetingAnalysisEntity; +import com.unicorn.hgzero.meeting.infra.gateway.entity.MeetingEntity; +import com.unicorn.hgzero.meeting.infra.gateway.entity.TodoEntity; +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.TodoJpaRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Primary; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +/** + * 회의 종료 비즈니스 로직 (AI 통합) + */ +@Slf4j +@Service +@Primary +@RequiredArgsConstructor +public class EndMeetingService implements EndMeetingUseCase { + + private final MeetingJpaRepository meetingRepository; + private final AgendaSectionJpaRepository agendaRepository; + private final TodoJpaRepository todoRepository; + private final MeetingAnalysisJpaRepository analysisRepository; + private final AIServiceClient aiServiceClient; + + /** + * 회의 종료 및 AI 분석 실행 + * + * @param meetingId 회의 ID + * @return 회의 종료 결과 DTO + */ + @Override + @Transactional + public MeetingEndDTO endMeeting(String meetingId) { + log.info("회의 종료 시작 - meetingId: {}", meetingId); + + // 1. 회의 정보 조회 + MeetingEntity meeting = meetingRepository.findById(meetingId) + .orElseThrow(() -> new IllegalArgumentException("회의를 찾을 수 없습니다: " + meetingId)); + + // 2. 안건 목록 조회 (실제로는 참석자별 메모 섹션) + List agendaSections = agendaRepository.findByMeetingIdOrderByAgendaNumberAsc(meetingId); + + // 3. AI 통합 분석 요청 데이터 생성 + ConsolidateRequest request = createConsolidateRequest(meeting, agendaSections); + + // 4. AI Service 호출 + ConsolidateResponse aiResponse = aiServiceClient.consolidateMinutes(request); + + // 5. AI 분석 결과 저장 + MeetingAnalysis analysis = saveAnalysisResult(meeting, aiResponse); + + // 6. Todo 생성 및 저장 + List todos = createAndSaveTodos(meeting, aiResponse, analysis); + + // 7. 회의 종료 처리 + meeting.end(); + meetingRepository.save(meeting); + + // 8. 응답 DTO 생성 + return createMeetingEndDTO(meeting, analysis, todos, agendaSections.size()); + } + + /** + * AI 통합 분석 요청 데이터 생성 + */ + private ConsolidateRequest createConsolidateRequest(MeetingEntity meeting, List agendaSections) { + // 참석자별 회의록 변환 (AgendaSection → ParticipantMinutes) + List participantMinutes = agendaSections.stream() + .map(section -> ParticipantMinutesDTO.builder() + .userId(section.getMeetingId()) // 실제로는 participantId 필요 + .userName(section.getAgendaTitle()) // 실제로는 participantName 필요 + .content(section.getDiscussions() != null ? section.getDiscussions() : "") + .build()) + .collect(Collectors.toList()); + + return ConsolidateRequest.builder() + .meetingId(meeting.getMeetingId()) + .participantMinutes(participantMinutes) + .build(); + } + + /** + * AI 분석 결과 저장 + */ + private MeetingAnalysis saveAnalysisResult(MeetingEntity meeting, ConsolidateResponse aiResponse) { + // AgendaAnalysis 리스트 생성 + List agendaAnalyses = aiResponse.getAgendaSummaries().stream() + .map(summary -> MeetingAnalysis.AgendaAnalysis.builder() + .agendaId(UUID.randomUUID().toString()) + .title(summary.getAgendaTitle()) + .aiSummaryShort(summary.getSummaryShort()) + .discussion(summary.getDiscussion() != null ? summary.getDiscussion() : "") + .decisions(summary.getDecisions() != null ? summary.getDecisions() : List.of()) + .pending(summary.getPending() != null ? summary.getPending() : List.of()) + .extractedTodos(summary.getTodos() != null + ? summary.getTodos().stream() + .map(todo -> todo.getTitle()) + .collect(Collectors.toList()) + : List.of()) + .build()) + .collect(Collectors.toList()); + + // MeetingAnalysis 도메인 생성 + MeetingAnalysis analysis = MeetingAnalysis.builder() + .analysisId(UUID.randomUUID().toString()) + .meetingId(meeting.getMeetingId()) + .minutesId(meeting.getMeetingId()) // 실제로는 minutesId 필요 + .keywords(aiResponse.getKeywords()) + .agendaAnalyses(agendaAnalyses) + .status("COMPLETED") + .completedAt(LocalDateTime.now()) + .createdAt(LocalDateTime.now()) + .build(); + + // Entity 저장 + MeetingAnalysisEntity entity = MeetingAnalysisEntity.fromDomain(analysis); + analysisRepository.save(entity); + + log.info("AI 분석 결과 저장 완료 - analysisId: {}", analysis.getAnalysisId()); + + return analysis; + } + + /** + * Todo 생성 및 저장 + */ + private List createAndSaveTodos(MeetingEntity meeting, ConsolidateResponse aiResponse, MeetingAnalysis analysis) { + List todos = aiResponse.getAgendaSummaries().stream() + .flatMap(agenda -> { + String agendaId = findAgendaIdByTitle(analysis, agenda.getAgendaTitle()); + List todoList = agenda.getTodos() != null ? agenda.getTodos() : List.of(); + return todoList.stream() + .map(todo -> TodoEntity.builder() + .todoId(UUID.randomUUID().toString()) + .meetingId(meeting.getMeetingId()) + .minutesId(meeting.getMeetingId()) // 실제로는 minutesId 필요 + .title(todo.getTitle()) + .assigneeId("") // AI가 담당자를 추출하지 않으므로 빈 값 + .status("PENDING") + .build()); + }) + .collect(Collectors.toList()); + + if (!todos.isEmpty()) { + todoRepository.saveAll(todos); + log.info("Todo 생성 완료 - 총 {}개", todos.size()); + } + + return todos; + } + + /** + * 안건 제목으로 안건 ID 찾기 + */ + private String findAgendaIdByTitle(MeetingAnalysis analysis, String title) { + return analysis.getAgendaAnalyses().stream() + .filter(agenda -> agenda.getTitle().equals(title)) + .findFirst() + .map(MeetingAnalysis.AgendaAnalysis::getAgendaId) + .orElse(null); + } + + /** + * 회의 종료 결과 DTO 생성 + */ + private MeetingEndDTO createMeetingEndDTO(MeetingEntity meeting, MeetingAnalysis analysis, + List todos, int participantCount) { + // 회의 소요 시간 계산 + int durationMinutes = calculateDurationMinutes(meeting.getStartedAt(), meeting.getEndedAt()); + + // 안건별 요약 DTO 생성 + List agendaSummaries = analysis.getAgendaAnalyses().stream() + .map(agenda -> { + // 해당 안건의 Todo 필터링 (agendaId가 없을 수 있음) + List agendaTodos = todos.stream() + .filter(todo -> agenda.getAgendaId().equals(todo.getMinutesId())) // 임시 매핑 + .map(todo -> MeetingEndDTO.TodoSummaryDTO.builder() + .title(todo.getTitle()) + .build()) + .collect(Collectors.toList()); + + return MeetingEndDTO.AgendaSummaryDTO.builder() + .title(agenda.getTitle()) + .aiSummaryShort(agenda.getAiSummaryShort()) + .details(MeetingEndDTO.AgendaDetailsDTO.builder() + .discussion(agenda.getDiscussion()) + .decisions(agenda.getDecisions()) + .pending(agenda.getPending()) + .build()) + .todos(agendaTodos) + .build(); + }) + .collect(Collectors.toList()); + + return MeetingEndDTO.builder() + .title(meeting.getTitle()) + .participantCount(participantCount) + .durationMinutes(durationMinutes) + .agendaCount(analysis.getAgendaAnalyses().size()) + .todoCount(todos.size()) + .keywords(analysis.getKeywords()) + .agendaSummaries(agendaSummaries) + .build(); + } + + /** + * 회의 소요 시간 계산 (분 단위) + */ + private int calculateDurationMinutes(LocalDateTime startedAt, LocalDateTime endedAt) { + if (startedAt == null || endedAt == null) { + return 0; + } + return (int) Duration.between(startedAt, endedAt).toMinutes(); + } +} diff --git a/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/client/AIServiceClient.java b/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/client/AIServiceClient.java new file mode 100644 index 0000000..b93983a --- /dev/null +++ b/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/client/AIServiceClient.java @@ -0,0 +1,80 @@ +package com.unicorn.hgzero.meeting.infra.client; + +import com.unicorn.hgzero.meeting.infra.dto.ai.ConsolidateRequest; +import com.unicorn.hgzero.meeting.infra.dto.ai.ConsolidateResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +import java.time.Duration; + +/** + * AI Service 호출 클라이언트 + */ +@Slf4j +@Component +public class AIServiceClient { + + private final RestTemplate restTemplate; + private final String aiServiceUrl; + + public AIServiceClient( + RestTemplateBuilder restTemplateBuilder, + @Value("${ai.service.url:http://localhost:8087}") String aiServiceUrl, + @Value("${ai.service.timeout:30000}") int timeout + ) { + this.restTemplate = restTemplateBuilder + .setConnectTimeout(Duration.ofMillis(timeout)) + .setReadTimeout(Duration.ofMillis(timeout)) + .build(); + this.aiServiceUrl = aiServiceUrl; + } + + /** + * 회의록 통합 요약 API 호출 + * + * @param request 통합 요약 요청 + * @return 통합 요약 응답 + */ + public ConsolidateResponse consolidateMinutes(ConsolidateRequest request) { + log.info("AI Service 호출 - 회의록 통합 요약: {}", request.getMeetingId()); + + try { + String url = aiServiceUrl + "/api/v1/transcripts/consolidate"; + + // HTTP 헤더 설정 + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + // HTTP 요청 생성 + HttpEntity httpEntity = new HttpEntity<>(request, headers); + + // API 호출 + ResponseEntity response = restTemplate.postForEntity( + url, + httpEntity, + ConsolidateResponse.class + ); + + ConsolidateResponse result = response.getBody(); + + if (result == null) { + throw new RuntimeException("AI Service 응답이 비어있습니다"); + } + + log.info("AI Service 응답 수신 완료 - 안건 수: {}", result.getAgendaSummaries().size()); + + return result; + + } catch (Exception e) { + log.error("AI Service 호출 실패: {}", e.getMessage(), e); + throw new RuntimeException("AI 회의록 통합 처리 중 오류가 발생했습니다: " + e.getMessage(), e); + } + } +} diff --git a/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/dto/ai/AgendaSummaryDTO.java b/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/dto/ai/AgendaSummaryDTO.java new file mode 100644 index 0000000..926cdd3 --- /dev/null +++ b/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/dto/ai/AgendaSummaryDTO.java @@ -0,0 +1,57 @@ +package com.unicorn.hgzero.meeting.infra.dto.ai; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * 안건별 요약 DTO + */ +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AgendaSummaryDTO { + + /** + * 안건 번호 + */ + @JsonProperty("agenda_number") + private Integer agendaNumber; + + /** + * 안건 제목 + */ + @JsonProperty("agenda_title") + private String agendaTitle; + + /** + * 짧은 요약 (1줄) + */ + @JsonProperty("summary_short") + private String summaryShort; + + /** + * 논의 주제 + */ + private String discussion; + + /** + * 결정 사항 + */ + private List decisions; + + /** + * 보류 사항 + */ + private List pending; + + /** + * Todo 목록 + */ + private List todos; +} diff --git a/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/dto/ai/ConsolidateRequest.java b/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/dto/ai/ConsolidateRequest.java new file mode 100644 index 0000000..6a34540 --- /dev/null +++ b/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/dto/ai/ConsolidateRequest.java @@ -0,0 +1,42 @@ +package com.unicorn.hgzero.meeting.infra.dto.ai; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * AI Service - 회의록 통합 요약 요청 DTO + */ +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ConsolidateRequest { + + /** + * 회의 ID + */ + @JsonProperty("meeting_id") + private String meetingId; + + /** + * 참석자별 회의록 목록 + */ + @JsonProperty("participant_minutes") + private List participantMinutes; + + /** + * 안건 목록 (선택) + */ + private List agendas; + + /** + * 회의 시간(분) (선택) + */ + @JsonProperty("duration_minutes") + private Integer durationMinutes; +} diff --git a/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/dto/ai/ConsolidateResponse.java b/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/dto/ai/ConsolidateResponse.java new file mode 100644 index 0000000..42124e8 --- /dev/null +++ b/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/dto/ai/ConsolidateResponse.java @@ -0,0 +1,53 @@ +package com.unicorn.hgzero.meeting.infra.dto.ai; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +/** + * AI Service - 회의록 통합 요약 응답 DTO + */ +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ConsolidateResponse { + + /** + * 회의 ID + */ + @JsonProperty("meeting_id") + private String meetingId; + + /** + * 주요 키워드 + */ + private List keywords; + + /** + * 통계 정보 + * - participants_count: 참석자 수 + * - agendas_count: 안건 수 + * - todos_count: Todo 개수 + * - duration_minutes: 회의 시간(분) + */ + private Map statistics; + + /** + * 안건별 요약 + */ + @JsonProperty("agenda_summaries") + private List agendaSummaries; + + /** + * 생성 시각 + */ + @JsonProperty("generated_at") + private LocalDateTime generatedAt; +} diff --git a/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/dto/ai/ExtractedTodoDTO.java b/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/dto/ai/ExtractedTodoDTO.java new file mode 100644 index 0000000..a293ff7 --- /dev/null +++ b/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/dto/ai/ExtractedTodoDTO.java @@ -0,0 +1,21 @@ +package com.unicorn.hgzero.meeting.infra.dto.ai; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * AI 추출 Todo DTO (제목만) + */ +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ExtractedTodoDTO { + + /** + * Todo 제목 + */ + private String title; +} diff --git a/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/dto/ai/ParticipantMinutesDTO.java b/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/dto/ai/ParticipantMinutesDTO.java new file mode 100644 index 0000000..bc08aa7 --- /dev/null +++ b/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/dto/ai/ParticipantMinutesDTO.java @@ -0,0 +1,31 @@ +package com.unicorn.hgzero.meeting.infra.dto.ai; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 참석자별 회의록 DTO (AI Service 요청용) + */ +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ParticipantMinutesDTO { + + /** + * 사용자 ID + */ + private String userId; + + /** + * 사용자 이름 + */ + private String userName; + + /** + * 회의록 전체 내용 (MEMO 섹션) + */ + private String content; +} diff --git a/meeting/src/main/resources/application.yml b/meeting/src/main/resources/application.yml index 5fa5bf6..d5ff5b8 100644 --- a/meeting/src/main/resources/application.yml +++ b/meeting/src/main/resources/application.yml @@ -133,3 +133,9 @@ azure: storage: connection-string: ${AZURE_STORAGE_CONNECTION_STRING:} container: ${AZURE_STORAGE_CONTAINER:hgzero-checkpoints} + +# AI Service Configuration +ai: + service: + url: ${AI_SERVICE_URL:http://localhost:8087} + timeout: ${AI_SERVICE_TIMEOUT:30000} diff --git a/test-meeting-ai.sh b/test-meeting-ai.sh new file mode 100755 index 0000000..ab47a42 --- /dev/null +++ b/test-meeting-ai.sh @@ -0,0 +1,214 @@ +#!/bin/bash + +# Meeting AI 통합 테스트 스크립트 +# 작성: 이동욱 + +set -e + +echo "==========================================" +echo "Meeting AI 통합 테스트" +echo "==========================================" + +# 색상 정의 +GREEN='\033[0;32m' +RED='\033[0;31m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# 테스트 변수 +MEETING_SERVICE="http://localhost:8082" +AI_SERVICE="http://localhost:8087" +USER_ID="test-user-001" +USER_NAME="홍길동" +USER_EMAIL="hong@example.com" + +# 1. 서비스 Health Check +echo "" +echo "1️⃣ 서비스 Health Check..." +echo "----------------------------------------" + +echo -n "AI Python Service (8087): " +if curl -s -f "$AI_SERVICE/health" > /dev/null; then + echo -e "${GREEN}✓ 정상${NC}" +else + echo -e "${RED}✗ 실패${NC}" + echo "AI Python Service가 실행되지 않았습니다." + exit 1 +fi + +echo -n "Meeting Service (8082): " +if curl -s -f "$MEETING_SERVICE/actuator/health" > /dev/null; then + echo -e "${GREEN}✓ 정상${NC}" +else + echo -e "${RED}✗ 실패${NC}" + echo "Meeting Service가 실행되지 않았습니다." + exit 1 +fi + +# 2. 회의 생성 +echo "" +echo "2️⃣ 회의 생성..." +echo "----------------------------------------" + +MEETING_RESPONSE=$(curl -s -X POST "$MEETING_SERVICE/api/meetings" \ + -H "Content-Type: application/json" \ + -H "X-User-Id: $USER_ID" \ + -H "X-User-Name: $USER_NAME" \ + -H "X-User-Email: $USER_EMAIL" \ + -d '{ + "title": "AI 통합 테스트 회의", + "purpose": "Meeting AI 기능 테스트", + "scheduledAt": "2025-10-28T14:00:00", + "endTime": "2025-10-28T15:00:00", + "location": "회의실 A", + "participantIds": ["'$USER_ID'", "user-002"] + }') + +MEETING_ID=$(echo "$MEETING_RESPONSE" | grep -o '"meetingId":"[^"]*"' | cut -d'"' -f4) + +if [ -z "$MEETING_ID" ]; then + echo -e "${RED}✗ 회의 생성 실패${NC}" + echo "$MEETING_RESPONSE" + exit 1 +fi + +echo -e "${GREEN}✓ 회의 생성 성공${NC}" +echo "Meeting ID: $MEETING_ID" + +# 3. 회의 시작 +echo "" +echo "3️⃣ 회의 시작..." +echo "----------------------------------------" + +START_RESPONSE=$(curl -s -X POST "$MEETING_SERVICE/api/meetings/$MEETING_ID/start" \ + -H "X-User-Id: $USER_ID" \ + -H "X-User-Name: $USER_NAME" \ + -H "X-User-Email: $USER_EMAIL") + +if echo "$START_RESPONSE" | grep -q '"success":true'; then + echo -e "${GREEN}✓ 회의 시작 성공${NC}" +else + echo -e "${RED}✗ 회의 시작 실패${NC}" + echo "$START_RESPONSE" + exit 1 +fi + +# 4. 테스트 데이터 삽입 안내 +echo "" +echo "4️⃣ 테스트 데이터 준비..." +echo "----------------------------------------" +echo -e "${YELLOW}⚠️ 수동 작업 필요${NC}" +echo "" +echo "PostgreSQL에 아래 SQL을 실행해주세요:" +echo "" +echo "psql -h 4.230.48.72 -U hgzerouser -d meetingdb" +echo "" +cat << 'SQL' +INSERT INTO agenda_sections ( + id, minutes_id, meeting_id, agenda_number, agenda_title, + ai_summary_short, discussions, + decisions, pending_items, opinions, todos, + created_at, updated_at +) VALUES +( + 'test-agenda-001', 'test-minutes-001', '여기에_MEETING_ID', 1, '신제품 기획 방향', + NULL, + '타겟 고객층을 20-30대 직장인으로 설정하고 UI/UX 개선에 집중하기로 논의했습니다. 모바일 우선 전략을 채택하고, 직관적인 인터페이스 디자인을 최우선 과제로 삼았습니다.', + '["타겟 고객: 20-30대 직장인", "UI/UX 개선 최우선", "모바일 우선 전략 채택"]'::json, + '["가격 정책 추가 검토 필요", "경쟁사 벤치마킹 분석"]'::json, + '[]'::json, + '[]'::json, + NOW(), NOW() +), +( + 'test-agenda-002', 'test-minutes-001', '여기에_MEETING_ID', 2, '마케팅 전략 수립', + NULL, + 'SNS 마케팅과 인플루언서 협업을 통한 브랜드 인지도 제고 방안을 논의했습니다. 인스타그램과 유튜브를 주요 채널로 선정하고, 마이크로 인플루언서 3명과 계약을 진행하기로 결정했습니다.', + '["SNS 광고 집행 (Instagram, YouTube)", "인플루언서 3명과 계약", "월 500만원 마케팅 예산"]'::json, + '["최종 예산 승인 대기", "인플루언서 선정 기준 확정"]'::json, + '[]'::json, + '[]'::json, + NOW(), NOW() +); +SQL + +echo "" +echo "위 SQL에서 '여기에_MEETING_ID'를 아래 값으로 치환하세요:" +echo -e "${GREEN}$MEETING_ID${NC}" +echo "" +echo -n "데이터 삽입 완료 후 Enter를 누르세요..." +read + +# 5. 회의 종료 (핵심 테스트) +echo "" +echo "5️⃣ 🔥 회의 종료 API 호출 (AI 통합 테스트)..." +echo "----------------------------------------" + +END_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "$MEETING_SERVICE/api/meetings/$MEETING_ID/end" \ + -H "X-User-Id: $USER_ID" \ + -H "X-User-Name: $USER_NAME" \ + -H "X-User-Email: $USER_EMAIL") + +HTTP_CODE=$(echo "$END_RESPONSE" | tail -n1) +BODY=$(echo "$END_RESPONSE" | sed '$d') + +if [ "$HTTP_CODE" -eq 200 ]; then + echo -e "${GREEN}✓ 회의 종료 성공 (HTTP $HTTP_CODE)${NC}" + echo "" + echo "📊 응답 데이터:" + echo "$BODY" | python3 -m json.tool 2>/dev/null || echo "$BODY" + + # 주요 데이터 추출 + AGENDA_COUNT=$(echo "$BODY" | grep -o '"agendaCount":[0-9]*' | cut -d':' -f2) + TODO_COUNT=$(echo "$BODY" | grep -o '"todoCount":[0-9]*' | cut -d':' -f2) + + echo "" + echo -e "${GREEN}✅ AI 분석 완료${NC}" + echo " - 안건 수: $AGENDA_COUNT" + echo " - Todo 수: $TODO_COUNT" +else + echo -e "${RED}✗ 회의 종료 실패 (HTTP $HTTP_CODE)${NC}" + echo "$BODY" + exit 1 +fi + +# 6. 데이터베이스 검증 +echo "" +echo "6️⃣ 데이터베이스 결과 확인..." +echo "----------------------------------------" +echo "" +echo "PostgreSQL에서 아래 쿼리로 결과를 확인하세요:" +echo "" +cat << SQL +-- 회의 상태 확인 +SELECT meeting_id, title, status, ended_at +FROM meetings +WHERE meeting_id = '$MEETING_ID'; + +-- AI 분석 결과 확인 +SELECT analysis_id, meeting_id, keywords, status, completed_at +FROM meeting_analysis +WHERE meeting_id = '$MEETING_ID'; + +-- Todo 확인 +SELECT todo_id, title, status +FROM todos +WHERE meeting_id = '$MEETING_ID'; +SQL + +echo "" +echo "==========================================" +echo -e "${GREEN}✅ 통합 테스트 완료!${NC}" +echo "==========================================" +echo "" +echo "📝 체크리스트:" +echo " ✓ AI Python Service 실행" +echo " ✓ Meeting Service 실행" +echo " ✓ 회의 생성" +echo " ✓ 회의 시작" +echo " ✓ 회의 종료 + AI 분석" +echo "" +echo "📁 로그 위치:" +echo " - AI Service: logs/ai-python.log" +echo " - Meeting Service: meeting/logs/meeting-service.log" +echo ""