diff --git a/.dockerignore b/.dockerignore index 022d924..2e774e1 100644 --- a/.dockerignore +++ b/.dockerignore @@ -9,3 +9,6 @@ node_modules .DS_Store *.iml .env + +# AI 서버 (별도 배포) +ai/ diff --git a/ai/.env.example b/ai/.env.example new file mode 100644 index 0000000..0407cb6 --- /dev/null +++ b/ai/.env.example @@ -0,0 +1,23 @@ +# Server Configuration +HOST=0.0.0.0 +PORT=8000 +ENVIRONMENT=development + +# AI Provider Configuration +# OpenAI +OPENAI_API_KEY=your-openai-api-key-here +OPENAI_MODEL=gpt-4o-2024-08-06 + +# Google Gemini (Optional) +GOOGLE_API_KEY=your-google-api-key-here +GEMINI_MODEL=gemini-1.5-pro + +# Default AI Provider (openai or gemini) +DEFAULT_AI_PROVIDER=openai + +# Timeout Configuration (seconds) +AI_REQUEST_TIMEOUT=240 +REQUEST_TIMEOUT=300 + +# Logging +LOG_LEVEL=WARNING diff --git a/ai/.gitignore b/ai/.gitignore new file mode 100644 index 0000000..857126b --- /dev/null +++ b/ai/.gitignore @@ -0,0 +1,57 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# Virtual Environment +venv/ +ENV/ +env/ +.venv + +# Environment Variables +.env +.env.local + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ +.DS_Store + +# Logs +*.log +logs/ + +# Testing +.pytest_cache/ +.coverage +htmlcov/ +.tox/ + +# MyPy +.mypy_cache/ +.dmypy.json +dmypy.json diff --git a/ai/README.md b/ai/README.md new file mode 100644 index 0000000..96843f3 --- /dev/null +++ b/ai/README.md @@ -0,0 +1,212 @@ +# AI Summary Service + +회의록 요약을 위한 AI 서비스입니다. FastAPI와 LangChain을 사용하여 구축되었습니다. + +## 기능 + +- 회의록 텍스트를 입력받아 구조화된 요약 생성 +- OpenAI 및 Google Gemini 지원 +- 역할별 액션 아이템 추출 +- 마일스톤 및 마감일 파싱 +- RESTful API 제공 + +## 디렉토리 구조 + +``` +ai/ +├── app/ +│ ├── api/ +│ │ └── summary.py # API 엔드포인트 +│ ├── schemas/ +│ │ ├── request.py # 요청 스키마 +│ │ └── response.py # 응답 스키마 +│ ├── chains/ +│ │ ├── prompts.py # 프롬프트 로더 +│ │ └── summary_chain.py # LangChain 요약 체인 +│ ├── core/ +│ │ ├── config.py # 설정 +│ │ ├── exceptions.py # 커스텀 예외 +│ │ └── provider.py # AI Provider 팩토리 +│ ├── utils/ # 유틸리티 (필요시) +│ └── main.py # FastAPI 애플리케이션 +├── prompts/ +│ └── meeting_summary.yml # 프롬프트 템플릿 +├── venv/ # Python 가상 환경 +├── .env # 환경 변수 (git ignored) +├── .env.example # 환경 변수 예시 +├── requirements.txt # Python 패키지 목록 +└── README.md # 문서 +``` + +## 설치 및 실행 + +### 1. 가상 환경 활성화 + +```bash +cd ai +source venv/bin/activate # macOS/Linux +# or +venv\Scripts\activate # Windows +``` + +### 2. 의존성 설치 + +```bash +pip install -r requirements.txt +``` + +### 3. 환경 변수 설정 + +`.env.example` 파일을 `.env`로 복사하고 필요한 값을 설정합니다: + +```bash +cp .env.example .env +``` + +`.env` 파일 예시: +```env +# Server Configuration +HOST=0.0.0.0 +PORT=8000 +ENVIRONMENT=development + +# OpenAI Configuration +OPENAI_API_KEY=sk-your-api-key-here +OPENAI_MODEL=gpt-4o-2024-08-06 + +# Google Gemini Configuration (Optional) +GOOGLE_API_KEY=your-google-api-key-here +GEMINI_MODEL=gemini-1.5-pro + +# Default Provider +DEFAULT_AI_PROVIDER=openai + +# Logging +LOG_LEVEL=WARNING +``` + +### 4. 서버 실행 + +#### 개발 모드 (자동 리로드) +```bash +uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 +``` + +#### 프로덕션 모드 +```bash +uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4 +``` + +#### Python으로 직접 실행 +```bash +python -m app.main +``` + +### 5. API 문서 확인 + +서버 실행 후 브라우저에서 다음 주소로 접속: +- Swagger UI: http://localhost:8000/docs +- ReDoc: http://localhost:8000/redoc + +## API 사용 예시 + +### 회의록 요약 생성 + +```bash +curl -X POST "http://localhost:8000/api/summary" \ + -H "Content-Type: application/json" \ + -d '{ + "meeting_content": "오늘 회의에서는 로그인 기능 구현에 대해 논의했습니다. PM은 요구사항을 정리하고, FE는 UI를 구현하며, BE는 API를 개발하기로 했습니다. 다음 주 금요일까지 완료하기로 합의했습니다.", + "provider": "openai" + }' +``` + +### 응답 예시 + +```json +{ + "summary": "로그인 기능 구현을 위한 역할별 작업 분담 및 일정 합의", + "milestones": [ + { + "task": "로그인 기능 구현 완료", + "deadline": "다음 주 금요일" + } + ], + "action_items_by_role": { + "PM": ["요구사항 정리"], + "PD": [], + "FE": ["로그인 UI 구현"], + "BE": ["로그인 API 개발"], + "AI": [], + "ALL": [] + } +} +``` + +## 백엔드 통합 가이드 + +### Spring Boot에서 호출 예시 + +```java +@Service +public class AiSummaryClient { + private final RestTemplate restTemplate; + private final String aiServerUrl = "http://localhost:8000/api"; + + public MeetingSummaryDto requestSummary(String meetingContent) { + String url = aiServerUrl + "/summary"; + + Map request = Map.of( + "meeting_content", meetingContent, + "provider", "openai" + ); + + return restTemplate.postForObject(url, request, MeetingSummaryDto.class); + } +} +``` + +## 환경 변수 설명 + +| 변수명 | 설명 | 기본값 | +|--------|------|--------| +| `HOST` | 서버 호스트 | `0.0.0.0` | +| `PORT` | 서버 포트 | `8000` | +| `ENVIRONMENT` | 실행 환경 | `development` | +| `OPENAI_API_KEY` | OpenAI API 키 | - | +| `OPENAI_MODEL` | OpenAI 모델명 | `gpt-4o-2024-08-06` | +| `GOOGLE_API_KEY` | Google API 키 | - | +| `GEMINI_MODEL` | Gemini 모델명 | `gemini-1.5-pro` | +| `DEFAULT_AI_PROVIDER` | 기본 AI 제공자 | `openai` | +| `LOG_LEVEL` | 로그 레벨 (WARNING, ERROR만 출력) | `WARNING` | + +## 개발 참고사항 + +### 프롬프트 수정 + +`prompts/meeting_summary.yml` 파일에서 프롬프트 템플릿을 수정할 수 있습니다. + +### 새로운 AI 제공자 추가 + +`app/core/provider.py`의 `AIProviderFactory.get_model` 메서드에 새로운 제공자를 추가할 수 있습니다. + +### 로깅 + +구조화된 로깅(structlog)을 사용하며, JSON 형식으로 출력됩니다. + +## 트러블슈팅 + +### 가상 환경 활성화 오류 +- macOS/Linux에서 권한 오류 발생 시: `chmod +x venv/bin/activate` + +### OpenAI API 키 오류 +- `.env` 파일에 올바른 API 키가 설정되어 있는지 확인 +- API 키에 충분한 크레딧이 있는지 확인 + +### 포트 충돌 +- `.env` 파일에서 `PORT` 값을 변경하거나 +- 실행 시 포트 지정: `uvicorn app.main:app --port 8001` + +## 라이선스 + +MIT diff --git a/ai/app/__init__.py b/ai/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ai/app/api/__init__.py b/ai/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ai/app/api/summary.py b/ai/app/api/summary.py new file mode 100644 index 0000000..61e3e25 --- /dev/null +++ b/ai/app/api/summary.py @@ -0,0 +1,54 @@ +"""Summary API endpoints.""" + +from fastapi import APIRouter, HTTPException, status +import structlog + +from app.schemas.request import SummaryRequest +from app.schemas.response import SummaryResponse, ErrorResponse +from app.chains.summary_chain import summary_chain +from app.core.exceptions import ResponseParseError, AIProviderError, InvalidRequestError + +logger = structlog.get_logger() + +router = APIRouter(prefix="/summary", tags=["summary"]) + + +@router.post( + "", + response_model=SummaryResponse, + status_code=status.HTTP_200_OK, + responses={ + 400: {"model": ErrorResponse, "description": "잘못된 요청"}, + 500: {"model": ErrorResponse, "description": "서버 오류"}, + }, + summary="회의록 요약 생성", + description="회의록 텍스트를 입력받아 AI를 사용하여 요약을 생성합니다.", +) +async def create_summary(request: SummaryRequest) -> SummaryResponse: + """ + 회의록 요약을 생성합니다. + + - **meeting_content**: 회의록 본문 텍스트 (필수) + - **provider**: AI 제공자 (openai 또는 gemini, 선택) + """ + try: + result = await summary_chain.generate_summary(request) + return result + except (ValueError, ResponseParseError, InvalidRequestError) as e: + logger.warning("Invalid request", error=str(e)) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e) + ) + except AIProviderError as e: + logger.error("AI provider error", error=str(e), exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="AI 서비스 연결 중 오류가 발생했습니다." + ) + except Exception as e: + logger.error("Failed to generate summary", error=str(e), exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="요약 생성 중 오류가 발생했습니다." + ) diff --git a/ai/app/chains/__init__.py b/ai/app/chains/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ai/app/chains/prompts.py b/ai/app/chains/prompts.py new file mode 100644 index 0000000..9a6a7c8 --- /dev/null +++ b/ai/app/chains/prompts.py @@ -0,0 +1,40 @@ +import yaml +from pathlib import Path +from typing import Dict, Any + + +class PromptLoader: + """프롬프트 템플릿 로더.""" + + def __init__(self, prompts_dir: str = "prompts"): + self.prompts_dir = Path(prompts_dir) + self._prompts_cache: Dict[str, Any] = {} + + def load_prompt(self, prompt_name: str) -> str: + """프롬프트 템플릿을 로드합니다.""" + if prompt_name in self._prompts_cache: + return self._prompts_cache[prompt_name] + + prompt_file = self.prompts_dir / f"{prompt_name}.yml" + if not prompt_file.exists(): + raise FileNotFoundError(f"Prompt file not found: {prompt_file}") + + with open(prompt_file, "r", encoding="utf-8") as f: + data = yaml.safe_load(f) + + # Extract template from nested structure + template = data.get("summary", {}).get("prompt", {}).get("template", "") + if not template: + raise ValueError(f"Invalid prompt format in {prompt_file}") + + self._prompts_cache[prompt_name] = template + return template + + def format_prompt(self, prompt_name: str, **kwargs) -> str: + """프롬프트 템플릿을 포맷팅합니다.""" + template = self.load_prompt(prompt_name) + return template.format(**kwargs) + + +# Global prompt loader instance +prompt_loader = PromptLoader() diff --git a/ai/app/chains/summary_chain.py b/ai/app/chains/summary_chain.py new file mode 100644 index 0000000..9725be1 --- /dev/null +++ b/ai/app/chains/summary_chain.py @@ -0,0 +1,134 @@ +"""Meeting summary chain using LangChain.""" + +import json +import re +import structlog +from typing import Dict, Any +from langchain_core.messages import HumanMessage + +from app.core.config import settings +from app.core.provider import ai_provider +from app.core.exceptions import ResponseParseError +from app.chains.prompts import prompt_loader +from app.schemas.request import SummaryRequest +from app.schemas.response import SummaryResponse, Milestone, ActionItemsByRole + +logger = structlog.get_logger() + + +class SummaryChain: + """회의록 요약 체인.""" + + def _safe_parse_json(self, response_text: str) -> Dict[str, Any]: + """ + AI 응답에서 JSON을 안전하게 파싱합니다 (Fallback용). + 마크다운 코드 블록이나 추가 텍스트를 제거합니다. + """ + try: + # 공백 제거 + response_text = response_text.strip() + + # 마크다운 코드 블록 제거 + if response_text.startswith("```json"): + response_text = response_text[7:] + elif response_text.startswith("```"): + response_text = response_text[3:] + + if response_text.endswith("```"): + response_text = response_text[:-3] + + response_text = response_text.strip() + + # JSON 객체 추출 (앞뒤 잡텍스트 제거) + # { ... } 또는 [ ... ] 패턴 찾기 + json_match = re.search(r'(\{.*\}|\[.*\])', response_text, re.DOTALL) + if json_match: + response_text = json_match.group(1) + + # JSON 파싱 + data = json.loads(response_text) + return data + + except json.JSONDecodeError as e: + logger.error("JSON parsing failed", error=str(e), response=response_text[:200]) + raise ResponseParseError(f"Invalid JSON response: {str(e)}") from e + + def _manual_convert_to_response(self, data: Dict[str, Any]) -> SummaryResponse: + """ + 파싱된 딕셔너리를 SummaryResponse로 수동 변환합니다 (Fallback용). + """ + try: + # milestones 변환 + milestones = [ + Milestone(**milestone) for milestone in data.get("milestones", []) + ] + + # actionItemsByRole 변환 (camelCase 키 사용) + action_items_data = data.get("actionItemsByRole", {}) + action_items = ActionItemsByRole( + PM=action_items_data.get("PM", []), + PD=action_items_data.get("PD", []), + FE=action_items_data.get("FE", []), + BE=action_items_data.get("BE", []), + AI=action_items_data.get("AI", []), + ALL=action_items_data.get("ALL", []), + ) + + return SummaryResponse( + summary=data.get("summary", ""), + milestones=milestones, + action_items_by_role=action_items, + ) + + except Exception as e: + logger.error("Manual conversion failed", error=str(e), data=data) + raise ResponseParseError(f"Failed to convert response: {str(e)}") from e + + async def generate_summary(self, request: SummaryRequest) -> SummaryResponse: + """회의록 요약을 생성합니다.""" + provider = request.provider or settings.default_ai_provider + + try: + prompt = prompt_loader.format_prompt( + "meeting_summary", + meeting_content=request.meeting_content + ) + + model = ai_provider.get_model(provider) + + try: + # 1차 시도: Structured Output 사용 + structured_model = model.with_structured_output( + SummaryResponse, + method="json_schema" + ) + result = await structured_model.ainvoke([HumanMessage(content=prompt)]) + logger.info("Structured output succeeded", provider=provider) + return result + + except Exception as structured_error: + # 2차 시도: Fallback to manual parsing + logger.warning( + "Structured output failed, trying manual parsing", + provider=provider, + error=str(structured_error) + ) + + # 일반 텍스트 응답 받기 + response = await model.ainvoke([HumanMessage(content=prompt)]) + response_text = response.content + + # 수동 파싱 및 변환 + parsed_data = self._safe_parse_json(response_text) + result = self._manual_convert_to_response(parsed_data) + + logger.info("Manual parsing succeeded", provider=provider) + return result + + except Exception as e: + logger.error("Failed to generate summary", error=str(e), exc_info=True) + raise + + +# Global chain instance +summary_chain = SummaryChain() diff --git a/ai/app/core/__init__.py b/ai/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ai/app/core/config.py b/ai/app/core/config.py new file mode 100644 index 0000000..ff65332 --- /dev/null +++ b/ai/app/core/config.py @@ -0,0 +1,37 @@ +from pydantic_settings import BaseSettings, SettingsConfigDict +from typing import Literal + + +class Settings(BaseSettings): + """Application settings loaded from environment variables.""" + + # Server Configuration + host: str = "0.0.0.0" + port: int = 8000 + environment: str = "development" + + # AI Provider Configuration + openai_api_key: str = "" + openai_model: str = "gpt-5-mini" + + google_api_key: str = "" + gemini_model: str = "gemini-1.5-flash" + + default_ai_provider: Literal["openai", "gemini"] = "openai" + + # Timeout Configuration (seconds) + ai_request_timeout: int = 240 + request_timeout: int = 300 + + # Logging + log_level: str = "WARNING" + + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + case_sensitive=False, + extra="ignore" + ) + + +settings = Settings() diff --git a/ai/app/core/exceptions.py b/ai/app/core/exceptions.py new file mode 100644 index 0000000..8640ea4 --- /dev/null +++ b/ai/app/core/exceptions.py @@ -0,0 +1,21 @@ +"""Custom exceptions for the application.""" + + +class AIProviderError(Exception): + """AI provider 관련 오류.""" + pass + + +class PromptLoadError(Exception): + """프롬프트 로드 오류.""" + pass + + +class ResponseParseError(Exception): + """AI 응답 파싱 오류.""" + pass + + +class InvalidRequestError(Exception): + """잘못된 요청 오류.""" + pass diff --git a/ai/app/core/provider.py b/ai/app/core/provider.py new file mode 100644 index 0000000..c4a2ab7 --- /dev/null +++ b/ai/app/core/provider.py @@ -0,0 +1,49 @@ +"""AI provider factory and management.""" + +from typing import Dict, Literal +from langchain_openai import ChatOpenAI +from langchain_google_genai import ChatGoogleGenerativeAI +from langchain_core.language_models.chat_models import BaseChatModel + +from app.core.config import settings +from app.core.exceptions import AIProviderError + + +class AIProviderFactory: + """AI provider 팩토리 클래스.""" + + def __init__(self): + self._models: Dict[str, BaseChatModel] = {} + + def get_model(self, provider: Literal["openai", "gemini"]) -> BaseChatModel: + """AI 모델 인스턴스를 가져옵니다.""" + if provider in self._models: + return self._models[provider] + + try: + if provider == "openai": + model = ChatOpenAI( + api_key=settings.openai_api_key, + model=settings.openai_model, + temperature=0.0, + timeout=settings.ai_request_timeout, + ) + elif provider == "gemini": + model = ChatGoogleGenerativeAI( + google_api_key=settings.google_api_key, + model=settings.gemini_model, + temperature=0.0, + timeout=settings.ai_request_timeout, + ) + else: + raise AIProviderError(f"Unsupported provider: {provider}") + + self._models[provider] = model + return model + + except Exception as e: + raise AIProviderError(f"Failed to initialize {provider} model: {str(e)}") from e + + +# Global provider factory instance +ai_provider = AIProviderFactory() diff --git a/ai/app/main.py b/ai/app/main.py new file mode 100644 index 0000000..8e1e5a9 --- /dev/null +++ b/ai/app/main.py @@ -0,0 +1,100 @@ +import structlog +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse + +from app.core.config import settings +from app.api import summary + +# Configure structured logging +structlog.configure( + processors=[ + structlog.stdlib.filter_by_level, + structlog.stdlib.add_logger_name, + structlog.stdlib.add_log_level, + structlog.stdlib.PositionalArgumentsFormatter(), + structlog.processors.TimeStamper(fmt="iso"), + structlog.processors.StackInfoRenderer(), + structlog.processors.format_exc_info, + structlog.processors.UnicodeDecoder(), + structlog.processors.JSONRenderer() + ], + wrapper_class=structlog.stdlib.BoundLogger, + logger_factory=structlog.stdlib.LoggerFactory(), + cache_logger_on_first_use=True, +) + +logger = structlog.get_logger() + + +# Create FastAPI application +app = FastAPI( + title="AI Summary Service", + description="회의록 요약을 위한 AI 서비스", + version="1.0.0", + docs_url="/docs", + redoc_url="/redoc", +) + +# CORS middleware +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # 프로덕션에서는 특정 오리진만 허용 + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Include routers +app.include_router(summary.router, prefix="/api") + + +@app.get("/", tags=["root"]) +async def root(): + """루트 엔드포인트.""" + return { + "service": "AI Summary Service", + "version": "1.0.0", + "status": "running", + "docs": "/docs" + } + + +@app.get("/health", tags=["health"]) +async def health(): + """헬스 체크 엔드포인트.""" + return { + "status": "healthy", + "environment": settings.environment + } + + +@app.exception_handler(Exception) +async def global_exception_handler(request, exc): + """전역 예외 핸들러.""" + logger.error( + "Unhandled exception", + path=request.url.path, + method=request.method, + error=str(exc), + exc_info=True + ) + return JSONResponse( + status_code=500, + content={ + "error": "Internal server error", + "detail": "An unexpected error occurred" + } + ) + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run( + "app.main:app", + host=settings.host, + port=settings.port, + reload=settings.environment == "development", + log_level=settings.log_level.lower() + ) diff --git a/ai/app/schemas/__init__.py b/ai/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ai/app/schemas/request.py b/ai/app/schemas/request.py new file mode 100644 index 0000000..683da40 --- /dev/null +++ b/ai/app/schemas/request.py @@ -0,0 +1,25 @@ +from pydantic import BaseModel, Field +from typing import Optional, Literal + + +class SummaryRequest(BaseModel): + """Request model for meeting summary generation.""" + + meeting_content: str = Field( + ..., + description="회의록 본문 텍스트", + min_length=1 + ) + + provider: Optional[Literal["openai", "gemini"]] = Field( + None, + description="사용할 AI 제공자 (미지정 시 기본값 사용)" + ) + + class Config: + json_schema_extra = { + "example": { + "meeting_content": "오늘 회의에서는 로그인 기능 구현에 대해 논의했습니다...", + "provider": "openai" + } + } diff --git a/ai/app/schemas/response.py b/ai/app/schemas/response.py new file mode 100644 index 0000000..eb1551d --- /dev/null +++ b/ai/app/schemas/response.py @@ -0,0 +1,61 @@ +from pydantic import BaseModel, Field +from typing import List, Dict + + +class Milestone(BaseModel): + """마일스톤 정보.""" + + task: str = Field(..., description="마일스톤 작업명") + deadline: str = Field(..., description="마감일") + + +class ActionItemsByRole(BaseModel): + """역할별 액션 아이템.""" + + PM: List[str] = Field(default_factory=list, description="프로젝트 매니저 할 일") + PD: List[str] = Field(default_factory=list, description="프로덕트 디자이너 할 일") + FE: List[str] = Field(default_factory=list, description="프론트엔드 개발자 할 일") + BE: List[str] = Field(default_factory=list, description="백엔드 개발자 할 일") + AI: List[str] = Field(default_factory=list, description="AI 엔지니어 할 일") + ALL: List[str] = Field(default_factory=list, description="팀 전체 할 일") + + +class SummaryResponse(BaseModel): + """Response model for meeting summary.""" + + summary: str = Field(..., description="회의 핵심 요약") + milestones: List[Milestone] = Field(default_factory=list, description="마일스톤 목록") + action_items_by_role: ActionItemsByRole = Field( + ..., + description="역할별 액션 아이템", + serialization_alias="actionItemsByRole" + ) + + class Config: + populate_by_name = True + json_schema_extra = { + "example": { + "summary": "로그인 기능 구현 및 UI 개선에 대한 논의", + "milestones": [ + { + "task": "로그인 기능 구현 완료", + "deadline": "다음 주 금요일" + } + ], + "actionItemsByRole": { + "PM": ["요구사항 문서 작성"], + "PD": ["로그인 화면 디자인"], + "FE": ["로그인 페이지 구현"], + "BE": ["인증 API 개발"], + "AI": [], + "ALL": ["다음 회의 참석"] + } + } + } + + +class ErrorResponse(BaseModel): + """Error response model.""" + + error: str = Field(..., description="에러 메시지") + detail: str = Field(default="", description="에러 상세 정보") diff --git a/ai/app/utils/__init__.py b/ai/app/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ai/prompts/meeting_summary.yml b/ai/prompts/meeting_summary.yml new file mode 100644 index 0000000..2d8b6db --- /dev/null +++ b/ai/prompts/meeting_summary.yml @@ -0,0 +1,37 @@ +summary: + prompt: + template: |- + --- + 당신은 IT 프로젝트 회의록을 전문적으로 분석하고 요약하는 AI 비서입니다. 당신의 임무는 아래 [회의록 본문]을 [역할군 정의]에 따라 분석하고, [출력 양식]에 "정확히" 맞는 유효한 JSON 객체 1개를 생성하는 것입니다. + --- + [중요 규칙] + 1. 회의록 본문에 명시적으로 언급된 내용만 기반으로 작성하세요. + 2. 각 역할에 할당할 작업이 없다면 절대 지어내지 말고 빈 배열 []을 반환하세요. + 3. 서론, 결론, 부연 설명 등 어떠한 추가 텍스트도 없이, [출력 양식]에 맞는 유효한 JSON 객체 하나만 응답으로 반환해야 합니다. + 4. 중복되거나 장황한 문장은 제거하고 간결하게 작성합니다. + 5. 한국어로 작성합니다. + --- + [역할군 정의] + * PM: 프로젝트 매니저 (기획, 일정 조율, 요구사항 정의) + * PD: 프로덕트 디자이너 (UI/UX, 화면 설계, 디자인) + * FE: 프론트엔드 개발자 (클라이언트, 웹, 앱 UI 개발) + * BE: 백엔드 개발자 (서버, API, 데이터베이스) + * AI: AI 엔지니어 (모델 개발, 데이터 처리) + * ALL: 특정 역할이 아닌 팀 전체 + --- + [회의록 본문] + {meeting_content} + --- + [출력 양식] + {{"summary": "회의의 핵심 결론과 목적을 1~2문장으로 명확하게 요약합니다.", + "milestones": [ + {{"task": "회의에서 합의된 주요 마일스톤 또는 핵심 목표 (예: '로그인 기능 구현 완료')", + "deadline": "언급된 마감일 (예: '다음 주 수요일', '11월 10일', '언급 없음')"}}], + "actionItemsByRole": {{ + "PM": ["PM이 수행해야 할 구체적인 할 일 (없으면 빈 배열 [])"], + "PD": ["PD가 수행해야 할 구체적인 할 일 (없으면 빈 배열 [])"], + "FE": ["FE가 수행해야 할 구체적인 할 일 (없으면 빈 배열 [])"], + "BE": ["BE가 수행해야 할 구체적인 할 일 (없으면 빈 배열 [])"], + "AI": ["AI가 수행해야 할 구체적인 할 일 (없으면 빈 배열 [])"], + "ALL": ["특정 역할이 아닌 모두가 함께 수행해야 할 일 (없으면 빈 배열 [])"]]}} + }} diff --git a/ai/requirements.txt b/ai/requirements.txt new file mode 100644 index 0000000..6cb7bf2 --- /dev/null +++ b/ai/requirements.txt @@ -0,0 +1,27 @@ +# FastAPI and server dependencies +fastapi==0.127.0 +uvicorn[standard]==0.40.0 +pydantic==2.12.5 +pydantic-settings==2.12.0 +python-multipart==0.0.21 + +# LangChain and AI providers +# Updated to 1.x - LangChain v0.3+ requires Pydantic 2 (Pydantic 1 support dropped) +langchain==1.2.0 +langchain-core==1.2.5 +langchain-openai==1.1.6 +langchain-google-genai==4.1.2 + +# OpenAI SDK +openai==2.14.0 + +# Utilities +python-dotenv==1.2.1 +pyyaml==6.0.3 +aiofiles==25.1.0 + +# HTTP client +httpx==0.28.1 + +# Logging and monitoring +structlog==25.5.0 diff --git a/ai/run.sh b/ai/run.sh new file mode 100755 index 0000000..e679b6a --- /dev/null +++ b/ai/run.sh @@ -0,0 +1,46 @@ +#!/bin/bash + +# AI Summary Service 실행 스크립트 + +# 색상 정의 +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +NC='\033[0m' # No Color + +echo -e "${GREEN}Starting AI Summary Service...${NC}" + +# 현재 디렉토리 확인 +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" +cd "$SCRIPT_DIR" + +# 가상 환경 확인 +if [ ! -d "venv" ]; then + echo -e "${RED}Virtual environment not found. Creating...${NC}" + python3 -m venv venv +fi + +# 가상 환경 활성화 +echo -e "${YELLOW}Activating virtual environment...${NC}" +source venv/bin/activate + +# 의존성 설치 +if [ ! -f "venv/.installed" ]; then + echo -e "${YELLOW}Installing dependencies...${NC}" + pip install -r requirements.txt + touch venv/.installed +else + echo -e "${GREEN}Dependencies already installed${NC}" +fi + +# .env 파일 확인 +if [ ! -f ".env" ]; then + echo -e "${YELLOW}.env file not found. Creating from .env.example...${NC}" + cp .env.example .env + echo -e "${RED}Please edit .env file with your API keys before running the server${NC}" + exit 1 +fi + +# 서버 실행 +echo -e "${GREEN}Starting FastAPI server...${NC}" +uvicorn app.main:app --reload --host 0.0.0.0 --port 8000