From cd8a7e3ab9df402e7b2798b73c834a740a57055e Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Sun, 28 Dec 2025 14:19:01 +0900 Subject: [PATCH 01/16] =?UTF-8?q?feat(ai):=20ai=20=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EC=8B=9C=EC=9E=91!?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ai/.env.example | 19 +++++++++++++++++ ai/.gitignore | 57 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+) create mode 100644 ai/.env.example create mode 100644 ai/.gitignore diff --git a/ai/.env.example b/ai/.env.example new file mode 100644 index 0000000..cfb9853 --- /dev/null +++ b/ai/.env.example @@ -0,0 +1,19 @@ +# 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 + +# 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 From ab8160b03f9076dce4015f53464efbec4fc02415 Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Sun, 28 Dec 2025 15:26:37 +0900 Subject: [PATCH 02/16] =?UTF-8?q?feat(ai):=20=EC=9A=94=EC=95=BD=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20api=20=EC=9A=94=EC=B2=AD/=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=20body=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ai/app/schemas/__init__.py | 0 ai/app/schemas/request.py | 25 +++++++++++++++++ ai/app/schemas/response.py | 56 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 81 insertions(+) create mode 100644 ai/app/schemas/__init__.py create mode 100644 ai/app/schemas/request.py create mode 100644 ai/app/schemas/response.py 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..4fbaf52 --- /dev/null +++ b/ai/app/schemas/response.py @@ -0,0 +1,56 @@ +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="역할별 액션 아이템") + + class Config: + json_schema_extra = { + "example": { + "summary": "로그인 기능 구현 및 UI 개선에 대한 논의", + "milestones": [ + { + "task": "로그인 기능 구현 완료", + "deadline": "다음 주 금요일" + } + ], + "action_items_by_role": { + "PM": ["요구사항 문서 작성"], + "PD": ["로그인 화면 디자인"], + "FE": ["로그인 페이지 구현"], + "BE": ["인증 API 개발"], + "AI": [], + "ALL": ["다음 회의 참석"] + } + } + } + + +class ErrorResponse(BaseModel): + """Error response model.""" + + error: str = Field(..., description="에러 메시지") + detail: str = Field(default="", description="에러 상세 정보") From f7a293dc06421ccd3d6f7df5842976e0f66bcc2a Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Sun, 28 Dec 2025 15:31:19 +0900 Subject: [PATCH 03/16] =?UTF-8?q?feat(ai):=20=ED=94=84=EB=A1=AC=ED=94=84?= =?UTF-8?q?=ED=8A=B8=EB=A5=BC=20ai=20=ED=8F=B4=EB=8D=94=20=EC=95=84?= =?UTF-8?q?=EB=9E=98=EC=97=90=20=EB=B3=B5=EC=82=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ai/prompts/meeting_summary.yml | 37 ++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 ai/prompts/meeting_summary.yml 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": ["특정 역할이 아닌 모두가 함께 수행해야 할 일 (없으면 빈 배열 [])"]]}} + }} From f4b86c298f10d4698608ec68b8133222f6c073a5 Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Sun, 28 Dec 2025 16:02:17 +0900 Subject: [PATCH 04/16] =?UTF-8?q?feat(ai):=20=ED=94=84=EB=A1=AC=ED=94=84?= =?UTF-8?q?=ED=8A=B8=20=EB=A1=9C=EB=8D=94=20=ED=8C=A8=ED=82=A4=EC=A7=80=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ai/app/chains/__init__.py | 0 ai/app/chains/prompts.py | 40 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 ai/app/chains/__init__.py create mode 100644 ai/app/chains/prompts.py 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() From 846ef3f8396597cdb0406ce3ff7be2d00c37555a Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Sun, 28 Dec 2025 16:58:59 +0900 Subject: [PATCH 05/16] =?UTF-8?q?feat(ai):=20=ED=99=98=EA=B2=BD=20?= =?UTF-8?q?=EB=B3=80=EC=88=98=20=EB=A1=9C=EB=8D=94,=20=EC=98=88=EC=99=B8?= =?UTF-8?q?=20=ED=81=B4=EB=9E=98=EC=8A=A4,=20ai=20=EB=AA=A8=EB=8D=B8=20?= =?UTF-8?q?=EB=A1=9C=EB=8D=94=EB=A5=BC=20=EC=A0=95=EC=9D=98=ED=95=9C=20cor?= =?UTF-8?q?e=20=EB=A0=88=EC=9D=B4=EC=96=B4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ai/app/core/__init__.py | 0 ai/app/core/config.py | 33 +++++++++++++++++++++++++++ ai/app/core/exceptions.py | 21 +++++++++++++++++ ai/app/core/provider.py | 47 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 101 insertions(+) create mode 100644 ai/app/core/__init__.py create mode 100644 ai/app/core/config.py create mode 100644 ai/app/core/exceptions.py create mode 100644 ai/app/core/provider.py 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..2ffed0b --- /dev/null +++ b/ai/app/core/config.py @@ -0,0 +1,33 @@ +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" + + # 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..f648ea9 --- /dev/null +++ b/ai/app/core/provider.py @@ -0,0 +1,47 @@ +"""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, + ) + elif provider == "gemini": + model = ChatGoogleGenerativeAI( + google_api_key=settings.google_api_key, + model=settings.gemini_model, + temperature=0.0, + ) + 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)}") + + +# Global provider factory instance +ai_provider = AIProviderFactory() From c04e6d7d0ee4a46f8bb53f7daebdf81a1bc71c41 Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Sun, 28 Dec 2025 17:55:52 +0900 Subject: [PATCH 06/16] =?UTF-8?q?feat(ai):=20=EB=9E=AD=EC=B2=B4=EC=9D=B8?= =?UTF-8?q?=EC=9D=84=20=ED=99=9C=EC=9A=A9=ED=95=9C=20ai=20=EC=9A=94?= =?UTF-8?q?=EC=95=BD=20=EC=83=9D=EC=84=B1=20=EC=84=9C=EB=B9=84=EC=8A=A4=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1차 시도는 랭체인의 장점을 살리기 위해 with_structured_output을 썼고, 2차 fallback 시도는 제미나이에서 가끔 실패하는 구조화(약5%)를 대비해 직접 템플릿 파싱하는 방식으로 구현했습니다. --- ai/app/chains/summary_chain.py | 127 +++++++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 ai/app/chains/summary_chain.py diff --git a/ai/app/chains/summary_chain.py b/ai/app/chains/summary_chain.py new file mode 100644 index 0000000..dbbbe9a --- /dev/null +++ b/ai/app/chains/summary_chain.py @@ -0,0 +1,127 @@ +"""Meeting summary chain using LangChain.""" + +import json +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 파싱 + 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)}") + + 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)}") + + 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() From cc737f3d2ce468761be716c8843073e3d012cf2b Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Sun, 28 Dec 2025 17:56:31 +0900 Subject: [PATCH 07/16] =?UTF-8?q?fix(ai):=20=EC=8A=A4=ED=94=84=EB=A7=81?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=B9=B4=EB=A9=9C=20=EC=BC=80=EC=9D=B4?= =?UTF-8?q?=EC=8A=A4=EB=A1=9C=20=EC=9D=91=EB=8B=B5=EC=9D=84=20=EA=B8=B0?= =?UTF-8?q?=EB=8C=80=ED=95=98=EA=B8=B0=20=EB=95=8C=EB=AC=B8=EC=97=90=20?= =?UTF-8?q?=EA=B7=B8=EC=97=90=20=EB=A7=9E=EA=B2=8C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ai/app/schemas/response.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/ai/app/schemas/response.py b/ai/app/schemas/response.py index 4fbaf52..eb1551d 100644 --- a/ai/app/schemas/response.py +++ b/ai/app/schemas/response.py @@ -25,9 +25,14 @@ class SummaryResponse(BaseModel): summary: str = Field(..., description="회의 핵심 요약") milestones: List[Milestone] = Field(default_factory=list, description="마일스톤 목록") - action_items_by_role: ActionItemsByRole = Field(..., description="역할별 액션 아이템") + action_items_by_role: ActionItemsByRole = Field( + ..., + description="역할별 액션 아이템", + serialization_alias="actionItemsByRole" + ) class Config: + populate_by_name = True json_schema_extra = { "example": { "summary": "로그인 기능 구현 및 UI 개선에 대한 논의", @@ -37,7 +42,7 @@ class Config: "deadline": "다음 주 금요일" } ], - "action_items_by_role": { + "actionItemsByRole": { "PM": ["요구사항 문서 작성"], "PD": ["로그인 화면 디자인"], "FE": ["로그인 페이지 구현"], From 64f9f8bc4667837d4845f85d75fc9eeb9af73ab2 Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Sun, 28 Dec 2025 18:27:17 +0900 Subject: [PATCH 08/16] =?UTF-8?q?refactor(ai):=20=EC=9E=A1=ED=85=8D?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=A0=9C=EA=B1=B0=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=EA=B3=BC=20=EC=97=90=EB=9F=AC=20=EC=9B=90=EC=9D=B8=EC=9D=98=20?= =?UTF-8?q?=EC=B2=B4=EC=9D=B8=EC=9D=84=20=EC=9C=A0=EC=A7=80=ED=95=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ai/app/chains/summary_chain.py | 11 +++++++++-- ai/app/core/provider.py | 2 +- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/ai/app/chains/summary_chain.py b/ai/app/chains/summary_chain.py index dbbbe9a..9725be1 100644 --- a/ai/app/chains/summary_chain.py +++ b/ai/app/chains/summary_chain.py @@ -1,6 +1,7 @@ """Meeting summary chain using LangChain.""" import json +import re import structlog from typing import Dict, Any from langchain_core.messages import HumanMessage @@ -38,13 +39,19 @@ def _safe_parse_json(self, response_text: str) -> Dict[str, Any]: 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)}") + raise ResponseParseError(f"Invalid JSON response: {str(e)}") from e def _manual_convert_to_response(self, data: Dict[str, Any]) -> SummaryResponse: """ @@ -75,7 +82,7 @@ def _manual_convert_to_response(self, data: Dict[str, Any]) -> SummaryResponse: except Exception as e: logger.error("Manual conversion failed", error=str(e), data=data) - raise ResponseParseError(f"Failed to convert response: {str(e)}") + raise ResponseParseError(f"Failed to convert response: {str(e)}") from e async def generate_summary(self, request: SummaryRequest) -> SummaryResponse: """회의록 요약을 생성합니다.""" diff --git a/ai/app/core/provider.py b/ai/app/core/provider.py index f648ea9..0ae021f 100644 --- a/ai/app/core/provider.py +++ b/ai/app/core/provider.py @@ -40,7 +40,7 @@ def get_model(self, provider: Literal["openai", "gemini"]) -> BaseChatModel: return model except Exception as e: - raise AIProviderError(f"Failed to initialize {provider} model: {str(e)}") + raise AIProviderError(f"Failed to initialize {provider} model: {str(e)}") from e # Global provider factory instance From 5f5d6e045f9ca0feebafa720a8a87b335222f269 Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Sun, 28 Dec 2025 18:28:32 +0900 Subject: [PATCH 09/16] =?UTF-8?q?feat(ai):=20api=20=EC=97=94=EB=93=9C?= =?UTF-8?q?=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ai/app/api/__init__.py | 0 ai/app/api/summary.py | 65 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 ai/app/api/__init__.py create mode 100644 ai/app/api/summary.py 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..fa029cc --- /dev/null +++ b/ai/app/api/summary.py @@ -0,0 +1,65 @@ +"""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="요약 생성 중 오류가 발생했습니다." + ) + + +@router.get( + "/health", + status_code=status.HTTP_200_OK, + summary="헬스 체크", + description="서비스 상태를 확인합니다.", +) +async def health_check(): + """서비스 헬스 체크 엔드포인트.""" + return {"status": "healthy", "service": "summary"} From 25d7ff846a95098398bc9a5fe7c5748256439cd5 Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Sun, 28 Dec 2025 19:32:26 +0900 Subject: [PATCH 10/16] =?UTF-8?q?feat(ai):=20fastapi=20=EC=97=94=ED=8A=B8?= =?UTF-8?q?=EB=A6=AC=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ai/app/__init__.py | 0 ai/app/main.py | 108 +++++++++++++++++++++++++++++++++++++++ ai/app/utils/__init__.py | 0 3 files changed, 108 insertions(+) create mode 100644 ai/app/__init__.py create mode 100644 ai/app/main.py create mode 100644 ai/app/utils/__init__.py diff --git a/ai/app/__init__.py b/ai/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ai/app/main.py b/ai/app/main.py new file mode 100644 index 0000000..bb06c02 --- /dev/null +++ b/ai/app/main.py @@ -0,0 +1,108 @@ +import structlog +from contextlib import asynccontextmanager +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() + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """애플리케이션 수명 주기 관리.""" + yield + + +# Create FastAPI application +app = FastAPI( + title="AI Summary Service", + description="회의록 요약을 위한 AI 서비스", + version="1.0.0", + lifespan=lifespan, + 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/utils/__init__.py b/ai/app/utils/__init__.py new file mode 100644 index 0000000..e69de29 From fe443c0aa4313bd6085ae12a37d03f2d25d27eba Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Sun, 28 Dec 2025 19:33:10 +0900 Subject: [PATCH 11/16] =?UTF-8?q?feat(ai):=20=ED=95=84=EC=9A=94=20?= =?UTF-8?q?=EC=9D=98=EC=A1=B4=EC=84=B1=EA=B3=BC=20=EB=A6=AC=EB=93=9C?= =?UTF-8?q?=EB=AF=B8=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ai/README.md | 212 ++++++++++++++++++++++++++++++++++++++++++++ ai/requirements.txt | 27 ++++++ 2 files changed, 239 insertions(+) create mode 100644 ai/README.md create mode 100644 ai/requirements.txt 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/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 From 7c336d25f8cfb9a9d7ec5db10076eecdd7dfd0db Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Sun, 28 Dec 2025 19:33:34 +0900 Subject: [PATCH 12/16] =?UTF-8?q?feat(ai):=20ai=20=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=EC=8B=A4=ED=96=89=20=EC=8A=A4=ED=81=AC=EB=A6=BD=ED=8A=B8=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ai/run.sh | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100755 ai/run.sh 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 From bfbc74f368c283106be6e2080fb163924149c39d Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Sun, 28 Dec 2025 19:55:30 +0900 Subject: [PATCH 13/16] =?UTF-8?q?refactor(ai):=20cold=20start=20=EB=8C=80?= =?UTF-8?q?=EB=B9=84=20lifespan=20=EB=A1=9C=EC=A7=81=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ai/app/main.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/ai/app/main.py b/ai/app/main.py index bb06c02..8e1e5a9 100644 --- a/ai/app/main.py +++ b/ai/app/main.py @@ -1,5 +1,4 @@ import structlog -from contextlib import asynccontextmanager from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse @@ -28,18 +27,11 @@ logger = structlog.get_logger() -@asynccontextmanager -async def lifespan(app: FastAPI): - """애플리케이션 수명 주기 관리.""" - yield - - # Create FastAPI application app = FastAPI( title="AI Summary Service", description="회의록 요약을 위한 AI 서비스", version="1.0.0", - lifespan=lifespan, docs_url="/docs", redoc_url="/redoc", ) From 0575003d1152e1f2acef14b2d8b3bbdf6ce22309 Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Sun, 28 Dec 2025 19:56:12 +0900 Subject: [PATCH 14/16] =?UTF-8?q?refactor(ai):=20summary=20=EB=AA=A8?= =?UTF-8?q?=EB=93=88=EC=9D=98=20=ED=97=AC=EC=8A=A4=EC=B2=B4=ED=81=AC=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ai/app/api/summary.py | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/ai/app/api/summary.py b/ai/app/api/summary.py index fa029cc..61e3e25 100644 --- a/ai/app/api/summary.py +++ b/ai/app/api/summary.py @@ -52,14 +52,3 @@ async def create_summary(request: SummaryRequest) -> SummaryResponse: status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="요약 생성 중 오류가 발생했습니다." ) - - -@router.get( - "/health", - status_code=status.HTTP_200_OK, - summary="헬스 체크", - description="서비스 상태를 확인합니다.", -) -async def health_check(): - """서비스 헬스 체크 엔드포인트.""" - return {"status": "healthy", "service": "summary"} From a56bc569f27f715b565bc52e6d01d84cba54b2b0 Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Sun, 28 Dec 2025 19:56:35 +0900 Subject: [PATCH 15/16] =?UTF-8?q?feat(ai):=20ai=20=ED=98=B8=EC=B6=9C?= =?UTF-8?q?=EC=97=90=20=ED=83=80=EC=9E=84=EC=95=84=EC=9B=83=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ai/.env.example | 4 ++++ ai/app/core/config.py | 4 ++++ ai/app/core/provider.py | 2 ++ 3 files changed, 10 insertions(+) diff --git a/ai/.env.example b/ai/.env.example index cfb9853..0407cb6 100644 --- a/ai/.env.example +++ b/ai/.env.example @@ -15,5 +15,9 @@ 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/app/core/config.py b/ai/app/core/config.py index 2ffed0b..b5ec968 100644 --- a/ai/app/core/config.py +++ b/ai/app/core/config.py @@ -19,6 +19,10 @@ class Settings(BaseSettings): default_ai_provider: Literal["openai", "gemini"] = "openai" + # Timeout Configuration (seconds) + ai_request_timeout: int = 240 # AI 호출 타임아웃 (4분) + request_timeout: int = 300 # 전체 요청 타임아웃 (5분) + # Logging log_level: str = "WARNING" diff --git a/ai/app/core/provider.py b/ai/app/core/provider.py index 0ae021f..c4a2ab7 100644 --- a/ai/app/core/provider.py +++ b/ai/app/core/provider.py @@ -26,12 +26,14 @@ def get_model(self, provider: Literal["openai", "gemini"]) -> BaseChatModel: 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}") From 1c1ad429654b1fad81bc6459fa2e266076cd2d5e Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Sun, 28 Dec 2025 20:03:57 +0900 Subject: [PATCH 16/16] =?UTF-8?q?feat(infra):=20=EB=B0=B1=EC=97=94?= =?UTF-8?q?=EB=93=9C=20=EB=B0=B0=ED=8F=AC=20=EC=8A=A4=ED=81=AC=EB=A6=BD?= =?UTF-8?q?=ED=8A=B8=EC=97=90=20ai=20=ED=8F=B4=EB=8D=94=EB=8A=94=20?= =?UTF-8?q?=EB=B0=B0=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 3 +++ ai/app/core/config.py | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) 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/app/core/config.py b/ai/app/core/config.py index b5ec968..ff65332 100644 --- a/ai/app/core/config.py +++ b/ai/app/core/config.py @@ -20,8 +20,8 @@ class Settings(BaseSettings): default_ai_provider: Literal["openai", "gemini"] = "openai" # Timeout Configuration (seconds) - ai_request_timeout: int = 240 # AI 호출 타임아웃 (4분) - request_timeout: int = 300 # 전체 요청 타임아웃 (5분) + ai_request_timeout: int = 240 + request_timeout: int = 300 # Logging log_level: str = "WARNING"