commit b54811ad8d35cd2210089b18477cc40259bda594 Author: jungwoo choi Date: Tue Feb 10 06:53:16 2026 +0900 Initial commit: 프로젝트 초기 구성 Co-Authored-By: Claude Opus 4.6 diff --git a/.claude/skills/README.md b/.claude/skills/README.md new file mode 100644 index 0000000..685426f --- /dev/null +++ b/.claude/skills/README.md @@ -0,0 +1,47 @@ +# Prototype Workspace 공통 Skills + +이 워크스페이스의 모든 프로젝트에 적용되는 개발 표준 가이드입니다. +개별 프로젝트의 `.claude/skills/`에 동일 파일이 있으면 프로젝트별 설정이 우선합니다. + +## 중요도 1 (핵심) + +| Skill | 설명 | 파일 | +|-------|------|------| +| deployment-standards | Docker 배포 규칙 | [deployment-standards.md](deployment-standards.md) | +| project-stack | React+Next.js+FastAPI 기본 구조 | [project-stack.md](project-stack.md) | +| korean-dev-conventions | 한국어 주석, 에러 처리 등 | [korean-dev-conventions.md](korean-dev-conventions.md) | + +## 중요도 2 (주요) + +| Skill | 설명 | 파일 | +|-------|------|------| +| ai-api-integration | AI 모델 FastAPI 통합 패턴 | [ai-api-integration.md](ai-api-integration.md) | +| api-design-standards | RESTful API 설계 규칙 | [api-design-standards.md](api-design-standards.md) | +| frontend-component-patterns | React 컴포넌트 패턴 | [frontend-component-patterns.md](frontend-component-patterns.md) | +| database-patterns | MongoDB 설계 패턴 | [database-patterns.md](database-patterns.md) | + +## 프로젝트 생성 도구 + +| Skill | 설명 | 파일 | +|-------|------|------| +| prj-archetypes | 프로젝트 아키타입 정의 (6종) | [prj-archetypes.md](prj-archetypes.md) | + +> `/prj` 커맨드: `.claude/commands/prj.md` — 프로젝트 생성 에이전트 + +## 중요도 3 (참고) + +| Skill | 설명 | 파일 | +|-------|------|------| +| infrastructure-setup | MAAS, K8s, Rancher 인프라 구축 | [infrastructure-setup.md](infrastructure-setup.md) | +| testing-standards | Jest/pytest 테스트 작성 | [testing-standards.md](testing-standards.md) | +| monitoring-logging | Prometheus/Grafana 모니터링 | [monitoring-logging.md](monitoring-logging.md) | +| gitea-workflow | Gitea 리포지토리 워크플로우 | [gitea-workflow.md](gitea-workflow.md) | + +## 기술 스택 요약 + +- **Frontend**: Next.js 16 + React 19 + Tailwind CSS 4 + shadcn/ui +- **Backend**: FastAPI + Python 3.11 +- **Database**: MongoDB 7.0 + Redis 7 +- **Containerization**: Docker + Docker Compose +- **AI**: Claude API + OpenAI API +- **Git**: Gitea (http://gitea.yakenator.io/yakenator/) diff --git a/.claude/skills/ai-api-integration.md b/.claude/skills/ai-api-integration.md new file mode 100644 index 0000000..1e2abdf --- /dev/null +++ b/.claude/skills/ai-api-integration.md @@ -0,0 +1,213 @@ +# AI API 통합 패턴 (AI API Integration) + +이 프로젝트의 AI 모델 API 통합 패턴입니다. + +## Claude API 통합 + +### 클라이언트 초기화 +```python +from anthropic import AsyncAnthropic + +class AIArticleGeneratorWorker: + def __init__(self): + self.claude_api_key = os.getenv("CLAUDE_API_KEY") + self.claude_client = None + + async def start(self): + if self.claude_api_key: + self.claude_client = AsyncAnthropic(api_key=self.claude_api_key) + else: + logger.error("Claude API key not configured") + return +``` + +### API 호출 패턴 +```python +async def _call_claude_api(self, prompt: str) -> str: + """Claude API 호출""" + try: + response = await self.claude_client.messages.create( + model="claude-sonnet-4-20250514", # 또는 claude-3-5-sonnet-latest + max_tokens=8192, + messages=[ + { + "role": "user", + "content": prompt + } + ] + ) + return response.content[0].text + except Exception as e: + logger.error(f"Claude API error: {e}") + raise +``` + +### JSON 응답 파싱 +```python +async def _generate_article(self, prompt: str) -> Dict[str, Any]: + """기사 생성 및 JSON 파싱""" + response_text = await self._call_claude_api(prompt) + + # JSON 블록 추출 + json_match = re.search(r'```json\s*(.*?)\s*```', response_text, re.DOTALL) + if json_match: + json_str = json_match.group(1) + else: + json_str = response_text + + return json.loads(json_str) +``` + +## 프롬프트 관리 + +### MongoDB 기반 동적 프롬프트 +```python +class AIArticleGeneratorWorker: + def __init__(self): + self._cached_prompt = None + self._prompt_cache_time = None + self._prompt_cache_ttl = 300 # 5분 캐시 + self._default_prompt = """...""" + + async def _get_prompt_template(self) -> str: + """MongoDB에서 프롬프트 템플릿을 가져옴 (캐시 적용)""" + import time + current_time = time.time() + + # 캐시가 유효하면 캐시된 프롬프트 반환 + if (self._cached_prompt and self._prompt_cache_time and + current_time - self._prompt_cache_time < self._prompt_cache_ttl): + return self._cached_prompt + + try: + prompts_collection = self.db.prompts + custom_prompt = await prompts_collection.find_one({"service": "article_generator"}) + + if custom_prompt and custom_prompt.get("content"): + self._cached_prompt = custom_prompt["content"] + logger.info("Using custom prompt from database") + else: + self._cached_prompt = self._default_prompt + logger.info("Using default prompt") + + self._prompt_cache_time = current_time + return self._cached_prompt + + except Exception as e: + logger.warning(f"Error fetching prompt from database: {e}, using default") + return self._default_prompt +``` + +### 프롬프트 템플릿 형식 +```python +prompt_template = """Write a comprehensive article based on the following news information. + +Keyword: {keyword} + +News Information: +Title: {title} +Summary: {summary} +Link: {link} +{search_text} + +Please write in the following JSON format: +{{ + "title": "Article title", + "summary": "One-line summary", + "subtopics": [ + {{ + "title": "Subtopic 1", + "content": ["Paragraph 1", "Paragraph 2", ...] + }} + ], + "categories": ["Category1", "Category2"], + "entities": {{ + "people": [{{"name": "Name", "context": ["role", "company"]}}], + "organizations": [{{"name": "Name", "context": ["industry", "type"]}}] + }} +}} + +Requirements: +- Structure with 2-5 subtopics +- Professional and objective tone +- Write in English +""" +``` + +## OpenAI API 통합 (참고) + +### 클라이언트 초기화 +```python +from openai import AsyncOpenAI + +class OpenAIService: + def __init__(self): + self.api_key = os.getenv("OPENAI_API_KEY") + self.client = AsyncOpenAI(api_key=self.api_key) + + async def generate(self, prompt: str) -> str: + response = await self.client.chat.completions.create( + model="gpt-4o", + messages=[{"role": "user", "content": prompt}], + max_tokens=4096 + ) + return response.choices[0].message.content +``` + +## 에러 처리 및 재시도 + +### 재시도 패턴 +```python +import asyncio +from typing import Optional + +async def _call_with_retry( + self, + func, + max_retries: int = 3, + initial_delay: float = 1.0 +) -> Optional[Any]: + """지수 백오프 재시도""" + delay = initial_delay + + for attempt in range(max_retries): + try: + return await func() + except Exception as e: + if attempt == max_retries - 1: + logger.error(f"All {max_retries} attempts failed: {e}") + raise + + logger.warning(f"Attempt {attempt + 1} failed: {e}, retrying in {delay}s") + await asyncio.sleep(delay) + delay *= 2 # 지수 백오프 +``` + +## 환경 변수 + +```bash +# .env 파일 +CLAUDE_API_KEY=sk-ant-... +OPENAI_API_KEY=sk-... + +# docker-compose.yml +environment: + - CLAUDE_API_KEY=${CLAUDE_API_KEY} + - OPENAI_API_KEY=${OPENAI_API_KEY} +``` + +## 비용 최적화 + +### 토큰 제한 +```python +response = await self.claude_client.messages.create( + model="claude-sonnet-4-20250514", + max_tokens=8192, # 출력 토큰 제한 + messages=[...] +) +``` + +### 캐싱 전략 +- MongoDB에 응답 캐시 저장 +- TTL 기반 캐시 만료 +- 동일 입력에 대한 중복 호출 방지 diff --git a/.claude/skills/api-design-standards.md b/.claude/skills/api-design-standards.md new file mode 100644 index 0000000..0ebaf09 --- /dev/null +++ b/.claude/skills/api-design-standards.md @@ -0,0 +1,269 @@ +# RESTful API 설계 규칙 (API Design Standards) + +이 프로젝트의 RESTful API 설계 패턴입니다. + +## FastAPI 기본 구조 + +### 앱 초기화 +```python +from fastapi import FastAPI, HTTPException +from pydantic import BaseModel +from typing import Optional + +app = FastAPI( + title="Biocode API", + description="바이오코드 성격 분석 및 유명인 등록 API", + version="1.0.0" +) +``` + +### Pydantic 모델 정의 +```python +class AddFamousPersonRequest(BaseModel): + year: int + month: int + day: int + name: str + description: Optional[str] = "" + entity_type: Optional[str] = "person" # "person" or "organization" + +class BiocodeResponse(BaseModel): + biocode: str + g_code: str + s_code: str + personality: dict +``` + +## 엔드포인트 패턴 + +### Health Check +```python +@app.get("/health") +async def health_check(): + """헬스체크 엔드포인트""" + return {"status": "healthy", "timestamp": datetime.now().isoformat()} +``` + +### GET - 조회 +```python +@app.get("/biocode/{year}/{month}/{day}") +async def get_biocode(year: int, month: int, day: int): + """생년월일로 바이오코드 조회""" + try: + biocode = calculate_biocode(year, month, day) + g_code = f"g{biocode[:2]}" + s_code = biocode[2:] + + if g_code not in biocode_data: + raise HTTPException(status_code=404, detail=f"G code {g_code} not found") + + personality = biocode_data[g_code].get("codes", {}).get(biocode, {}) + + return { + "biocode": biocode, + "g_code": g_code, + "s_code": s_code, + "personality": personality + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) +``` + +### POST - 생성 +```python +@app.post("/add_famous_person") +async def add_famous_person(request: AddFamousPersonRequest): + """유명인/조직 등록""" + try: + biocode = calculate_biocode(request.year, request.month, request.day) + g_code = f"g{biocode[:2]}" + + if g_code not in biocode_data: + raise HTTPException(status_code=404, detail=f"G code {g_code} not found") + + # 엔티티 타입에 따라 저장 위치 결정 + is_organization = request.entity_type == "organization" + target_field = "famousOrganizations" if is_organization else "famousPeople" + + # 데이터 추가 + new_entry = { + "name": request.name, + "code": biocode, + "description": request.description or "" + } + + if target_field not in biocode_data[g_code]["codes"][biocode]: + biocode_data[g_code]["codes"][biocode][target_field] = [] + + biocode_data[g_code]["codes"][biocode][target_field].append(new_entry) + + # JSON 파일 저장 + save_biocode_data(g_code) + + return { + "success": True, + "biocode": biocode, + "entity_type": request.entity_type, + "name": request.name + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) +``` + +## 라우터 분리 패턴 + +### 메인 앱 (main.py) +```python +from fastapi import FastAPI +from app.routers import users, articles + +app = FastAPI(title="News Engine API") + +app.include_router(users.router, prefix="/api/users", tags=["users"]) +app.include_router(articles.router, prefix="/api/articles", tags=["articles"]) + +@app.get("/health") +async def health(): + return {"status": "healthy"} +``` + +### 라우터 (routers/users.py) +```python +from fastapi import APIRouter, HTTPException, Depends +from app.models.user import UserCreate, UserResponse +from app.database import get_database + +router = APIRouter() + +@router.post("/", response_model=UserResponse) +async def create_user(user: UserCreate, db = Depends(get_database)): + """새 사용자 생성""" + existing = await db.users.find_one({"email": user.email}) + if existing: + raise HTTPException(status_code=400, detail="Email already registered") + + result = await db.users.insert_one(user.dict()) + return UserResponse(id=str(result.inserted_id), **user.dict()) + +@router.get("/{user_id}", response_model=UserResponse) +async def get_user(user_id: str, db = Depends(get_database)): + """사용자 조회""" + user = await db.users.find_one({"_id": ObjectId(user_id)}) + if not user: + raise HTTPException(status_code=404, detail="User not found") + return UserResponse(**user) +``` + +## 에러 처리 + +### HTTPException 사용 +```python +from fastapi import HTTPException + +# 404 Not Found +raise HTTPException(status_code=404, detail="Resource not found") + +# 400 Bad Request +raise HTTPException(status_code=400, detail="Invalid input data") + +# 500 Internal Server Error +raise HTTPException(status_code=500, detail=str(e)) +``` + +### 전역 예외 처리 +```python +from fastapi import Request +from fastapi.responses import JSONResponse + +@app.exception_handler(Exception) +async def global_exception_handler(request: Request, exc: Exception): + return JSONResponse( + status_code=500, + content={"detail": "Internal server error", "error": str(exc)} + ) +``` + +## 응답 형식 + +### 성공 응답 +```json +{ + "success": true, + "data": { ... }, + "message": "Operation completed successfully" +} +``` + +### 에러 응답 +```json +{ + "detail": "Error message here" +} +``` + +### 목록 응답 +```json +{ + "items": [...], + "total": 100, + "page": 1, + "page_size": 20 +} +``` + +## CORS 설정 + +```python +from fastapi.middleware.cors import CORSMiddleware + +app.add_middleware( + CORSMiddleware, + allow_origins=["http://localhost:3000", "https://yourdomain.com"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) +``` + +## API 버저닝 + +```python +# URL 기반 버저닝 +app.include_router(v1_router, prefix="/api/v1") +app.include_router(v2_router, prefix="/api/v2") + +# 또는 헤더 기반 +@app.get("/api/resource") +async def get_resource(api_version: str = Header(default="v1")): + if api_version == "v2": + return v2_response() + return v1_response() +``` + +## 인증 + +### JWT 토큰 검증 +```python +from fastapi import Depends, HTTPException +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +import jwt + +security = HTTPBearer() + +async def verify_token(credentials: HTTPAuthorizationCredentials = Depends(security)): + try: + payload = jwt.decode( + credentials.credentials, + os.getenv("JWT_SECRET_KEY"), + algorithms=["HS256"] + ) + return payload + except jwt.ExpiredSignatureError: + raise HTTPException(status_code=401, detail="Token expired") + except jwt.InvalidTokenError: + raise HTTPException(status_code=401, detail="Invalid token") + +@router.get("/protected") +async def protected_route(user = Depends(verify_token)): + return {"user": user} +``` diff --git a/.claude/skills/database-patterns.md b/.claude/skills/database-patterns.md new file mode 100644 index 0000000..649d4ef --- /dev/null +++ b/.claude/skills/database-patterns.md @@ -0,0 +1,384 @@ +# MongoDB 설계 패턴 (Database Patterns) + +이 프로젝트의 MongoDB 설계 및 사용 패턴입니다. + +## 연결 설정 + +### Motor (async driver) +```python +from motor.motor_asyncio import AsyncIOMotorClient + +class WikipediaEnrichmentWorker: + def __init__(self): + self.mongodb_url = os.getenv("MONGODB_URL", "mongodb://mongodb:27017") + self.db_name = os.getenv("DB_NAME", "ai_writer_db") + self.db = None + + async def start(self): + client = AsyncIOMotorClient(self.mongodb_url) + self.db = client[self.db_name] +``` + +### PyMongo (sync driver) +```python +from pymongo import MongoClient +from pymongo.database import Database + +client: MongoClient = None +db: Database = None + +def connect_to_mongo(): + global client, db + try: + client = MongoClient(MONGODB_URL) + db = client[DATABASE_NAME] + client.admin.command('ping') # 연결 테스트 + print(f"Successfully connected to MongoDB: {DATABASE_NAME}") + except Exception as e: + print(f"Error connecting to MongoDB: {e}") + raise e +``` + +## 컬렉션 설계 + +### 기사 컬렉션 (articles_en) +```json +{ + "_id": "ObjectId", + "news_id": "unique_id", + "title": "Article Title", + "summary": "One-line summary", + "subtopics": [ + { + "title": "Subtopic 1", + "content": ["Paragraph 1", "Paragraph 2"] + } + ], + "categories": ["Category1", "Category2"], + "entities": { + "people": [ + { + "name": "Person Name", + "context": ["role", "company"], + "birth_date": "1990-01-15", + "wikipedia_url": "https://...", + "image_urls": ["https://..."], + "verified": true + } + ], + "organizations": [ + { + "name": "Organization Name", + "context": ["industry", "type"], + "founding_date": "2004-02-04", + "wikipedia_url": "https://...", + "image_urls": ["https://..."], + "verified": true + } + ] + }, + "wikipedia_enriched": true, + "wikipedia_enriched_at": "2024-01-15T10:30:00", + "created_at": "2024-01-15T10:00:00", + "updated_at": "2024-01-15T10:30:00" +} +``` + +### 엔티티 캐시 컬렉션 (entity_people) +```json +{ + "_id": "ObjectId", + "name": "Elon Musk", + "context": ["Tesla", "SpaceX", "CEO"], + "birth_date": "1971-06-28", + "wikipedia_url": "https://en.wikipedia.org/wiki/Elon_Musk", + "image_urls": ["https://..."], + "verified": true, + "created_at": "2024-01-10T00:00:00", + "updated_at": "2024-01-15T10:30:00" +} +``` + +## 인덱스 설계 + +### 인덱스 생성 패턴 +```python +class EntityCache: + async def ensure_indexes(self): + """인덱스 생성 (이미 존재하면 무시)""" + try: + # wikipedia_url이 unique key (동명이인 구분) + try: + await self.people_collection.create_index( + "wikipedia_url", unique=True, sparse=True + ) + except Exception: + pass # 이미 존재 + + # 이름으로 검색용 (동명이인 가능) + try: + await self.people_collection.create_index("name") + except Exception: + pass + + # context 검색용 + try: + await self.people_collection.create_index("context") + except Exception: + pass + + # TTL 정책용 + try: + await self.people_collection.create_index("updated_at") + except Exception: + pass + + logger.info("Entity cache indexes ensured") + except Exception as e: + logger.warning(f"Error ensuring indexes: {e}") +``` + +## CRUD 패턴 + +### Create (삽입) +```python +async def save_person(self, data: Dict[str, Any]) -> bool: + """인물 정보 저장/갱신 (wikipedia_url 기준)""" + now = datetime.now() + + update_doc = { + "name": data.get("name"), + "context": data.get("context", []), + "birth_date": data.get("birth_date"), + "wikipedia_url": data.get("wikipedia_url"), + "image_urls": data.get("image_urls", []), + "verified": data.get("verified", False), + "updated_at": now + } + + if data.get("wikipedia_url"): + # upsert: 있으면 업데이트, 없으면 삽입 + result = await self.people_collection.update_one( + {"wikipedia_url": data["wikipedia_url"]}, + { + "$set": update_doc, + "$setOnInsert": {"created_at": now} + }, + upsert=True + ) + return result.modified_count > 0 or result.upserted_id is not None +``` + +### Read (조회) +```python +async def get_person(self, name: str, context: List[str] = None) -> Tuple[Optional[Dict], bool]: + """ + 인물 정보 조회 (context 기반 최적 매칭) + + Returns: + Tuple of (cached_data, needs_refresh) + """ + # 이름으로 모든 후보 검색 + cursor = self.people_collection.find({"name": {"$regex": f"^{name}$", "$options": "i"}}) + candidates = await cursor.to_list(length=10) + + if not candidates: + return None, True + + # context가 있으면 최적 후보 선택 + if context: + best_match = None + best_score = -1 + + for candidate in candidates: + score = self._calculate_context_match_score( + candidate.get("context", []), context + ) + if score > best_score: + best_score = score + best_match = candidate + + if best_match and best_score >= MIN_CONTEXT_MATCH: + needs_refresh = not self._is_cache_fresh(best_match) + return best_match, needs_refresh + + # context 없으면 첫 번째 후보 반환 + candidate = candidates[0] + needs_refresh = not self._is_cache_fresh(candidate) + return candidate, needs_refresh +``` + +### Update (수정) +```python +async def update_article(self, mongodb_id: str, update_data: Dict[str, Any]): + """기사 정보 업데이트""" + result = await self.collection.update_one( + {"_id": ObjectId(mongodb_id)}, + { + "$set": { + "entities.people": update_data.get("people", []), + "entities.organizations": update_data.get("organizations", []), + "wikipedia_enriched": True, + "wikipedia_enriched_at": datetime.now().isoformat() + } + } + ) + return result.modified_count > 0 +``` + +### Delete (삭제) +```python +async def delete_old_cache(self, days: int = 30): + """오래된 캐시 데이터 삭제""" + cutoff_date = datetime.now() - timedelta(days=days) + result = await self.people_collection.delete_many({ + "updated_at": {"$lt": cutoff_date} + }) + return result.deleted_count +``` + +## 캐싱 전략 + +### TTL 기반 캐시 +```python +# 캐시 유효 기간 (7일) +CACHE_TTL_DAYS = 7 + +def _is_cache_fresh(self, cached_data: Dict[str, Any]) -> bool: + """캐시 데이터가 신선한지 확인""" + if not cached_data: + return False + + updated_at = cached_data.get("updated_at") + if not updated_at: + return False + + if isinstance(updated_at, str): + updated_at = datetime.fromisoformat(updated_at) + + expiry_date = updated_at + timedelta(days=CACHE_TTL_DAYS) + return datetime.now() < expiry_date +``` + +### 갱신 정책 +```python +# 정책: +# - 7일이 지나면 갱신 시도 (삭제 아님) +# - API 호출 실패 시 기존 데이터 유지 +# - 데이터 동일 시 확인 일자만 갱신 + +async def save_person(self, new_data: Dict, existing_data: Dict = None): + """기존 데이터와 비교하여 적절히 처리""" + if existing_data and existing_data.get("verified"): + # 기존에 검증된 데이터가 있음 + if not new_data.get("birth_date") and existing_data.get("birth_date"): + # 새 데이터가 덜 완전하면 기존 데이터 유지, 시간만 갱신 + await self.people_collection.update_one( + {"wikipedia_url": existing_data["wikipedia_url"]}, + {"$set": {"updated_at": datetime.now()}} + ) + return + # 새 데이터로 갱신 + await self._upsert_person(new_data) +``` + +## GridFS (대용량 파일) + +### 오디오 파일 저장 +```python +from motor.motor_asyncio import AsyncIOMotorGridFSBucket + +class AudioStorage: + def __init__(self, db): + self.fs = AsyncIOMotorGridFSBucket(db, bucket_name="audio") + + async def save_audio(self, audio_data: bytes, filename: str) -> str: + """오디오 파일 저장""" + file_id = await self.fs.upload_from_stream( + filename, + audio_data, + metadata={"content_type": "audio/mpeg"} + ) + return str(file_id) + + async def get_audio(self, file_id: str) -> bytes: + """오디오 파일 조회""" + grid_out = await self.fs.open_download_stream(ObjectId(file_id)) + return await grid_out.read() +``` + +## 백업 정책 + +### 규칙 +- **주기**: 하루에 한 번 (daily) +- **보관 기간**: 최소 7일 +- **백업 위치**: 프로젝트 루트의 `./backups/` 디렉토리 + +### MongoDB 백업 +```bash +# 백업 실행 +BACKUP_NAME="mongodb_backup_$(date +%Y%m%d_%H%M%S)" +docker exec {프로젝트}-mongodb mongodump \ + --uri="mongodb://{user}:{password}@localhost:27017" \ + --authenticationDatabase=admin \ + --out="/tmp/$BACKUP_NAME" +docker cp {프로젝트}-mongodb:/tmp/$BACKUP_NAME ./backups/ +echo "백업 완료: ./backups/$BACKUP_NAME" +``` + +### MongoDB 복원 +```bash +docker cp ./backups/$BACKUP_NAME {프로젝트}-mongodb:/tmp/ +docker exec {프로젝트}-mongodb mongorestore \ + --uri="mongodb://{user}:{password}@localhost:27017" \ + --authenticationDatabase=admin \ + "/tmp/$BACKUP_NAME" +``` + +### 자동화 스크립트 (backup-mongodb.sh) +```bash +#!/bin/bash +PROJECT_NAME="{프로젝트명}" +BACKUP_DIR="{프로젝트경로}/backups" +BACKUP_NAME="mongodb_backup_$(date +%Y%m%d_%H%M%S)" + +# 백업 실행 +docker exec ${PROJECT_NAME}-mongodb mongodump \ + --uri="mongodb://{user}:{password}@localhost:27017" \ + --authenticationDatabase=admin \ + --out="/tmp/$BACKUP_NAME" + +docker cp ${PROJECT_NAME}-mongodb:/tmp/$BACKUP_NAME $BACKUP_DIR/ + +# 7일 이상 된 백업 삭제 +find $BACKUP_DIR -type d -name "mongodb_backup_*" -mtime +7 -exec rm -rf {} \; + +echo "$(date): Backup completed - $BACKUP_NAME" >> $BACKUP_DIR/backup.log +``` + +### cron 설정 +```bash +# crontab -e +0 2 * * * /path/to/backup-mongodb.sh # 매일 새벽 2시 +``` + +### 주의사항 +- 새 프로젝트 생성 시 반드시 백업 스크립트 설정 +- 백업 디렉토리는 .gitignore에 추가하여 커밋 제외 +- 중요 데이터는 외부 스토리지에 추가 백업 권장 + +--- + +## 환경 변수 + +```bash +# .env +MONGODB_URL=mongodb://admin:password123@mongodb:27017/ +DB_NAME=ai_writer_db +TARGET_COLLECTION=articles_en + +# docker-compose.yml +environment: + - MONGODB_URL=mongodb://${MONGO_USER}:${MONGO_PASSWORD}@mongodb:27017/ + - DB_NAME=ai_writer_db +``` diff --git a/.claude/skills/deployment-standards.md b/.claude/skills/deployment-standards.md new file mode 100644 index 0000000..db3ecda --- /dev/null +++ b/.claude/skills/deployment-standards.md @@ -0,0 +1,140 @@ +# Docker 배포 규칙 (Deployment Standards) + +Docker 배포 표준입니다. + +## Dockerfile 패턴 + +### Python 마이크로서비스 (Worker) +```dockerfile +FROM python:3.11-slim + +WORKDIR /app + +# 필요한 시스템 패키지 설치 (git은 공통 라이브러리 설치에 필요) +RUN apt-get update && apt-get install -y --no-install-recommends git && rm -rf /var/lib/apt/lists/* + +# 의존성 설치 (공통 라이브러리 포함 시) +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt && \ + pip install --no-cache-dir git+http://gitea.yakenator.io/yakenator/{공통라이브러리}.git + +# 애플리케이션 코드 복사 +COPY *.py . + +# 워커 실행 +CMD ["python", "worker.py"] +``` + +### Python API 서비스 +```dockerfile +FROM python:3.11-slim + +WORKDIR /app + +# curl은 healthcheck에 필요 +RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app/ ./app/ + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] +``` + +## docker-compose.yml 패턴 + +### 서비스 구조 +```yaml +services: + # =================== + # Infrastructure (독립 포트 사용) + # =================== + mongodb: + image: mongo:7.0 + container_name: {프로젝트}-mongodb + restart: unless-stopped + environment: + MONGO_INITDB_ROOT_USERNAME: ${MONGO_USER:-admin} + MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASSWORD:-password123} + ports: + - "{호스트포트}:27017" # 호스트 포트는 충돌 방지를 위해 변경 + volumes: + - {프로젝트}_mongodb_data:/data/db + networks: + - {프로젝트}-network + healthcheck: + test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"] + interval: 30s + timeout: 10s + retries: 3 +``` + +### Worker 서비스 패턴 +```yaml + {서비스명}: + build: + context: ./repos/{서비스명} + dockerfile: Dockerfile + container_name: {프로젝트}-{서비스명} + restart: unless-stopped + environment: + - REDIS_URL=redis://redis:6379 + - MONGODB_URL=mongodb://${MONGO_USER}:${MONGO_PASSWORD}@mongodb:27017/ + - DB_NAME={데이터베이스명} + - TARGET_COLLECTION={컬렉션명} + depends_on: + redis: + condition: service_healthy + mongodb: + condition: service_healthy + networks: + - {프로젝트}-network +``` + +## 컨테이너 네이밍 규칙 + +- 컨테이너명: `{프로젝트}-{서비스명}` +- 볼륨명: `{프로젝트}_{데이터유형}_data` +- 네트워크: `{프로젝트}-network` + +## 환경 변수 관리 + +- `.env` 파일로 민감 정보 관리 +- docker-compose.yml에서 `${VAR:-default}` 형태로 기본값 제공 +- 공통 변수: `MONGO_USER`, `MONGO_PASSWORD`, `REDIS_URL`, `JWT_SECRET_KEY` + +## 헬스체크 패턴 + +### Redis +```yaml +healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 30s + timeout: 10s + retries: 3 +``` + +### MongoDB +```yaml +healthcheck: + test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"] + interval: 30s + timeout: 10s + retries: 3 +``` + +### FastAPI 서비스 +```yaml +healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 +``` + +## 참고 리포지토리 + +- Gitea: http://gitea.yakenator.io/yakenator/ diff --git a/.claude/skills/frontend-component-patterns.md b/.claude/skills/frontend-component-patterns.md new file mode 100644 index 0000000..a432fdc --- /dev/null +++ b/.claude/skills/frontend-component-patterns.md @@ -0,0 +1,325 @@ +# React 컴포넌트 패턴 (Frontend Component Patterns) + +이 프로젝트의 React/Next.js 컴포넌트 패턴입니다. + +## shadcn/ui 기반 컴포넌트 + +### 설치 및 초기화 +```bash +# shadcn/ui 초기화 +npx shadcn@latest init + +# 컴포넌트 추가 +npx shadcn@latest add button card dialog tabs table form +``` + +### Button 컴포넌트 (CVA 패턴) +```tsx +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: "bg-destructive text-white hover:bg-destructive/90", + outline: "border bg-background shadow-xs hover:bg-accent", + secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2", + sm: "h-8 rounded-md px-3", + lg: "h-10 rounded-md px-6", + icon: "size-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +function Button({ + className, + variant, + size, + asChild = false, + ...props +}: React.ComponentProps<"button"> & + VariantProps & { + asChild?: boolean + }) { + const Comp = asChild ? Slot : "button" + + return ( + + ) +} + +export { Button, buttonVariants } +``` + +## Next.js App Router 구조 + +### 레이아웃 (layout.tsx) +```tsx +import type { Metadata } from "next"; +import { Geist, Geist_Mono } from "next/font/google"; +import "./globals.css"; +import { Providers } from "@/components/providers"; +import { Toaster } from "@/components/ui/sonner"; + +const geistSans = Geist({ + variable: "--font-geist-sans", + subsets: ["latin"], +}); + +export const metadata: Metadata = { + title: "News Engine Admin", + description: "Admin dashboard for News Pipeline management", +}; + +export default function RootLayout({ + children, +}: Readonly<{ children: React.ReactNode }>) { + return ( + + + + {children} + + + + + ); +} +``` + +### Provider 패턴 +```tsx +// components/providers.tsx +"use client" + +import { ThemeProvider } from "next-themes" + +export function Providers({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} +``` + +## 유틸리티 함수 + +### cn() 함수 (lib/utils.ts) +```tsx +import { clsx, type ClassValue } from "clsx" +import { twMerge } from "tailwind-merge" + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} +``` + +## 커스텀 훅 패턴 + +### 데이터 페칭 훅 +```tsx +// hooks/use-articles.ts +import { useState, useEffect } from "react" + +interface Article { + id: string + title: string + summary: string +} + +export function useArticles() { + const [articles, setArticles] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + async function fetchArticles() { + try { + const res = await fetch("/api/articles") + if (!res.ok) throw new Error("Failed to fetch") + const data = await res.json() + setArticles(data.items) + } catch (e) { + setError(e as Error) + } finally { + setIsLoading(false) + } + } + + fetchArticles() + }, []) + + return { articles, isLoading, error } +} +``` + +### 토글 훅 +```tsx +// hooks/use-toggle.ts +import { useState, useCallback } from "react" + +export function useToggle(initialState = false) { + const [state, setState] = useState(initialState) + + const toggle = useCallback(() => setState((s) => !s), []) + const setTrue = useCallback(() => setState(true), []) + const setFalse = useCallback(() => setState(false), []) + + return { state, toggle, setTrue, setFalse } +} +``` + +## 폼 패턴 (react-hook-form + zod) + +```tsx +"use client" + +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { z } from "zod" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form" + +const formSchema = z.object({ + title: z.string().min(1, "제목을 입력하세요"), + content: z.string().min(10, "내용은 10자 이상이어야 합니다"), +}) + +type FormValues = z.infer + +export function ArticleForm() { + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + title: "", + content: "", + }, + }) + + async function onSubmit(values: FormValues) { + try { + const res = await fetch("/api/articles", { + method: "POST", + body: JSON.stringify(values), + }) + if (!res.ok) throw new Error("Failed to create") + // 성공 처리 + } catch (error) { + console.error(error) + } + } + + return ( +
+ + ( + + 제목 + + + + + + )} + /> + + + + ) +} +``` + +## 다크모드 지원 + +### 테마 토글 버튼 +```tsx +"use client" + +import { useTheme } from "next-themes" +import { Moon, Sun } from "lucide-react" +import { Button } from "@/components/ui/button" + +export function ThemeToggle() { + const { theme, setTheme } = useTheme() + + return ( + + ) +} +``` + +## 토스트 알림 (sonner) + +```tsx +import { toast } from "sonner" + +// 성공 알림 +toast.success("저장되었습니다") + +// 에러 알림 +toast.error("오류가 발생했습니다") + +// 로딩 알림 +const toastId = toast.loading("처리 중...") +// 완료 후 +toast.success("완료!", { id: toastId }) +``` + +## 파일 구조 + +``` +src/ +├── app/ # Next.js App Router +│ ├── layout.tsx # 루트 레이아웃 +│ ├── page.tsx # 메인 페이지 +│ └── dashboard/ +│ └── page.tsx +├── components/ +│ ├── ui/ # shadcn/ui 기본 컴포넌트 +│ │ ├── button.tsx +│ │ ├── card.tsx +│ │ └── ... +│ ├── providers.tsx # Context Providers +│ └── app-sidebar.tsx # 앱 전용 컴포넌트 +├── hooks/ # 커스텀 훅 +│ └── use-articles.ts +├── lib/ # 유틸리티 +│ └── utils.ts +└── types/ # TypeScript 타입 + └── index.ts +``` diff --git a/.claude/skills/gitea-workflow.md b/.claude/skills/gitea-workflow.md new file mode 100644 index 0000000..6cebcff --- /dev/null +++ b/.claude/skills/gitea-workflow.md @@ -0,0 +1,163 @@ +# Gitea 워크플로우 (Gitea Workflow) + +이 프로젝트의 Gitea 리포지토리 관리 워크플로우입니다. + +## Gitea 서버 정보 + +- **URL**: http://gitea.yakenator.io +- **조직**: yakenator +- **사용자**: yakenator +- **비밀번호**: asdfg23we + +## 새 리포지토리 생성 + +### 1. Gitea CLI (tea) 사용 +```bash +# tea 설치 (macOS) +brew install tea + +# 로그인 +tea login add --url http://gitea.yakenator.io --user yakenator --password asdfg23we + +# 리포지토리 생성 +tea repo create --name {repo-name} --org yakenator --private=false +``` + +### 2. API 사용 +```bash +# 리포지토리 생성 +curl -X POST "http://gitea.yakenator.io/api/v1/orgs/yakenator/repos" \ + -H "Content-Type: application/json" \ + -u "yakenator:asdfg23we" \ + -d '{ + "name": "{repo-name}", + "description": "서비스 설명", + "private": false, + "auto_init": false + }' +``` + +### 3. 웹 UI 사용 +1. http://gitea.yakenator.io 접속 +2. yakenator / asdfg23we 로 로그인 +3. yakenator 조직 선택 +4. "New Repository" 클릭 +5. 리포지토리 정보 입력 후 생성 + +## 새 프로젝트 초기화 및 푸시 + +### 전체 워크플로우 +```bash +# 1. 프로젝트 디렉토리 생성 +mkdir ./repos/{new-service} +cd ./repos/{new-service} + +# 2. Git 초기화 +git init + +# 3. 파일 생성 (Dockerfile, requirements.txt, worker.py 등) + +# 4. 첫 커밋 +git add -A +git commit -m "Initial commit: {service-name} 서비스 초기 구성 + +Co-Authored-By: Claude Opus 4.5 " + +# 5. Gitea에 리포지토리 생성 (API) +curl -X POST "http://gitea.yakenator.io/api/v1/orgs/yakenator/repos" \ + -H "Content-Type: application/json" \ + -u "yakenator:asdfg23we" \ + -d '{"name": "{new-service}", "private": false}' + +# 6. Remote 추가 및 푸시 +git remote add origin http://yakenator:asdfg23we@gitea.yakenator.io/yakenator/{new-service}.git +git branch -M main +git push -u origin main +``` + +## 기존 리포지토리 클론 + +```bash +# HTTP 클론 (인증 포함) +git clone http://yakenator:asdfg23we@gitea.yakenator.io/yakenator/{repo-name}.git + +# 또는 클론 후 credential 설정 +git clone http://gitea.yakenator.io/yakenator/{repo-name}.git +cd {repo-name} +git config credential.helper store +``` + +## 리포지토리 URL 패턴 + +``` +http://gitea.yakenator.io/yakenator/{repo-name}.git +``` + +### 현재 등록된 리포지토리 +| 서비스 | URL | +|--------|-----| +| news-commons | http://gitea.yakenator.io/yakenator/news-commons.git | +| news-article-generator | http://gitea.yakenator.io/yakenator/news-article-generator.git | +| news-article-translator | http://gitea.yakenator.io/yakenator/news-article-translator.git | +| news-biocode-service | http://gitea.yakenator.io/yakenator/news-biocode-service.git | +| news-google-search | http://gitea.yakenator.io/yakenator/news-google-search.git | +| news-image-generator | http://gitea.yakenator.io/yakenator/news-image-generator.git | +| news-rss-collector | http://gitea.yakenator.io/yakenator/news-rss-collector.git | +| news-tts-generator | http://gitea.yakenator.io/yakenator/news-tts-generator.git | +| news-wikipedia-enrichment | http://gitea.yakenator.io/yakenator/news-wikipedia-enrichment.git | +| news-user-service | http://gitea.yakenator.io/yakenator/news-user-service.git | +| mcp_biocode | http://gitea.yakenator.io/yakenator/mcp_biocode.git | +| base-auth | http://gitea.yakenator.io/yakenator/base-auth.git | +| base-image | http://gitea.yakenator.io/yakenator/base-image.git | + +## 커밋 및 푸시 + +```bash +# 변경사항 확인 +git status + +# 스테이징 및 커밋 +git add -A +git commit -m "$(cat <<'EOF' +feat: 기능 설명 + +- 변경사항 1 +- 변경사항 2 + +Co-Authored-By: Claude Opus 4.5 +EOF +)" + +# 푸시 +git push +``` + +## 브랜치 전략 + +```bash +# 기본 브랜치: main +# 기능 브랜치: feature/{기능명} +# 버그 수정: fix/{버그설명} + +# 브랜치 생성 및 전환 +git checkout -b feature/new-feature + +# 작업 후 푸시 +git push -u origin feature/new-feature + +# PR 생성 (웹 UI 또는 API) +``` + +## 주의사항 + +- 모든 서비스는 `yakenator` 조직 아래에 생성 +- 리포지토리 이름은 `news-{서비스명}` 패턴 사용 +- 민감 정보(.env, credentials)는 절대 커밋하지 않음 +- .gitignore에 다음 항목 포함: + ``` + .env + __pycache__/ + *.pyc + node_modules/ + .DS_Store + ``` diff --git a/.claude/skills/infrastructure-setup.md b/.claude/skills/infrastructure-setup.md new file mode 100644 index 0000000..b149231 --- /dev/null +++ b/.claude/skills/infrastructure-setup.md @@ -0,0 +1,268 @@ +# 인프라 구축 가이드 (Infrastructure Setup) + +인프라 설정 패턴입니다. + +## MAAS (Metal as a Service) + +### 개요 +- 베어메탈 서버 프로비저닝 도구 +- Ubuntu 기반 자동 배포 +- 네트워크 자동 설정 + +### 기본 설정 +```yaml +# MAAS 설정 예시 +machines: + - hostname: k8s-master-01 + architecture: amd64 + cpu_count: 8 + memory: 32768 + storage: 500GB + tags: + - kubernetes + - master + + - hostname: k8s-worker-01 + architecture: amd64 + cpu_count: 16 + memory: 65536 + storage: 1TB + tags: + - kubernetes + - worker +``` + +### 네트워크 설정 +```yaml +# 서브넷 설정 +subnets: + - cidr: 10.10.0.0/16 + gateway_ip: 10.10.0.1 + dns_servers: + - 8.8.8.8 + - 8.8.4.4 +``` + +## Kubernetes 클러스터 + +### 클러스터 구성 +```yaml +# 마스터 노드: 3대 (HA 구성) +# 워커 노드: 3대 이상 +# etcd: 마스터 노드에 내장 +``` + +### kubeadm 초기화 +```bash +# 마스터 노드 초기화 +kubeadm init --pod-network-cidr=10.244.0.0/16 \ + --control-plane-endpoint="k8s-api.example.com:6443" \ + --upload-certs + +# 워커 노드 조인 +kubeadm join k8s-api.example.com:6443 \ + --token \ + --discovery-token-ca-cert-hash sha256: +``` + +### CNI 설치 (Flannel) +```bash +kubectl apply -f https://raw.githubusercontent.com/coreos/flannel/master/Documentation/kube-flannel.yml +``` + +## Rancher 설치 + +### Docker 기반 설치 +```bash +docker run -d --restart=unless-stopped \ + -p 80:80 -p 443:443 \ + --privileged \ + -v /opt/rancher:/var/lib/rancher \ + rancher/rancher:latest +``` + +### Helm 기반 설치 +```bash +# Rancher Helm 레포 추가 +helm repo add rancher-latest https://releases.rancher.com/server-charts/latest +helm repo update + +# 네임스페이스 생성 +kubectl create namespace cattle-system + +# cert-manager 설치 (Let's Encrypt 사용 시) +kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.13.0/cert-manager.yaml + +# Rancher 설치 +helm install rancher rancher-latest/rancher \ + --namespace cattle-system \ + --set hostname=rancher.example.com \ + --set replicas=3 \ + --set ingress.tls.source=letsEncrypt \ + --set letsEncrypt.email=admin@example.com +``` + +## Docker Compose 배포 + +### docker-compose.yml 구조 +```yaml +services: + # =================== + # Infrastructure + # =================== + mongodb: + image: mongo:7.0 + container_name: {프로젝트}-mongodb + restart: unless-stopped + environment: + MONGO_INITDB_ROOT_USERNAME: ${MONGO_USER:-admin} + MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASSWORD:-password123} + ports: + - "{호스트포트}:27017" + volumes: + - {프로젝트}_mongodb_data:/data/db + networks: + - {프로젝트}-network + healthcheck: + test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"] + interval: 30s + timeout: 10s + retries: 3 + + redis: + image: redis:7-alpine + container_name: {프로젝트}-redis + restart: unless-stopped + ports: + - "{호스트포트}:6379" + volumes: + - {프로젝트}_redis_data:/data + networks: + - {프로젝트}-network + +volumes: + {프로젝트}_mongodb_data: + {프로젝트}_redis_data: + +networks: + {프로젝트}-network: + driver: bridge +``` + +### 배포 명령어 +```bash +# 전체 서비스 시작 +docker-compose up -d + +# 특정 서비스만 재빌드 +docker-compose up -d --build {서비스명} + +# 로그 확인 +docker-compose logs -f {서비스명} + +# 서비스 상태 확인 +docker-compose ps +``` + +## 환경 변수 관리 + +### .env 파일 +```bash +# Infrastructure +MONGO_USER=admin +MONGO_PASSWORD={비밀번호} +REDIS_URL=redis://redis:6379 + +# API Keys +CLAUDE_API_KEY=sk-ant-... +OPENAI_API_KEY=sk-... +JWT_SECRET_KEY={시크릿키} + +# Database +DB_NAME={데이터베이스명} +TARGET_COLLECTION={컬렉션명} +``` + +### 환경별 설정 +```bash +# 개발 환경 +docker-compose -f docker-compose.yml -f docker-compose.dev.yml up -d + +# 프로덕션 환경 +docker-compose -f docker-compose.yml -f docker-compose.prod.yml up -d +``` + +## 백업 전략 + +### MongoDB 백업 +```bash +# 백업 +BACKUP_NAME="mongodb_backup_$(date +%Y%m%d_%H%M%S)" +docker exec {프로젝트}-mongodb mongodump \ + --uri="mongodb://{user}:{password}@localhost:27017" \ + --authenticationDatabase=admin \ + --out="/tmp/$BACKUP_NAME" +docker cp {프로젝트}-mongodb:/tmp/$BACKUP_NAME ./backups/ + +# 복원 +docker cp ./backups/$BACKUP_NAME {프로젝트}-mongodb:/tmp/ +docker exec {프로젝트}-mongodb mongorestore \ + --uri="mongodb://{user}:{password}@localhost:27017" \ + --authenticationDatabase=admin \ + "/tmp/$BACKUP_NAME" +``` + +### 볼륨 백업 +```bash +# 볼륨 백업 +docker run --rm \ + -v {프로젝트}_mongodb_data:/data \ + -v $(pwd)/backups:/backup \ + alpine tar czf /backup/mongodb_volume.tar.gz -C /data . +``` + +## 네트워크 구성 + +### 포트 매핑 규칙 +```yaml +# Infrastructure (기본 포트 + 오프셋) +MongoDB: {오프셋}+27017:27017 +Redis: {오프셋}+6379:6379 + +# Application Services +api-service: 8000:8000 +admin-frontend: 3000:3000 +``` + +### 내부 통신 +```yaml +# 컨테이너 간 통신은 서비스명 사용 +mongodb: mongodb://{user}:{password}@mongodb:27017/ +redis: redis://redis:6379 +``` + +## 모니터링 접근점 + +### 헬스체크 엔드포인트 +```bash +# MongoDB +docker exec {프로젝트}-mongodb mongosh --eval "db.adminCommand('ping')" + +# Redis +docker exec {프로젝트}-redis redis-cli ping + +# FastAPI 서비스 +curl http://localhost:{포트}/health +``` + +### 로그 수집 +```bash +# 전체 로그 +docker-compose logs -f + +# 특정 서비스 로그 +docker-compose logs -f {서비스명} + +# 최근 100줄만 +docker-compose logs --tail=100 {서비스명} +``` diff --git a/.claude/skills/korean-dev-conventions.md b/.claude/skills/korean-dev-conventions.md new file mode 100644 index 0000000..42f29e5 --- /dev/null +++ b/.claude/skills/korean-dev-conventions.md @@ -0,0 +1,161 @@ +# 한국어 개발 컨벤션 (Korean Dev Conventions) + +이 프로젝트의 한국어 개발 규칙입니다. + +## 주석 규칙 + +### Python Docstring +```python +async def get_organization_info(self, name: str, context: List[str] = None) -> OrganizationInfo: + """ + 조직/단체 정보 조회 (context 기반 후보 선택) + + Args: + name: 조직 이름 + context: 기사에서 추출한 컨텍스트 키워드 (산업, 유형 등) + + Returns: + OrganizationInfo with founding_year, wikipedia_url, and image_url if found + """ +``` + +### 섹션 구분 주석 +```python +# =================== +# Infrastructure (독립 포트 사용) +# =================== +``` + +### 인라인 주석 +```python +# 캐시에서 조회 (context 기반 매칭) +cached_data, needs_refresh = await self.entity_cache.get_person(name, context=context) + +# P154 = logo image +"property": "P154", +``` + +## 로깅 메시지 + +### 한글 + 영문 혼용 패턴 +```python +logger.info(f"Found {len(image_urls)} image(s) for '{name}' (logo preferred)") +logger.info(f"Article {news_id} enriched with Wikipedia data in {processing_time:.2f}s") +logger.warning(f"Biocode registration failed (non-critical): {e}") +``` + +### 워커 로그 패턴 +```python +logger.info("Starting Wikipedia Enrichment Worker") +logger.info(f"Processing job {job.job_id} for Wikipedia enrichment") +logger.info(f"Job {job.job_id} forwarded to image_generation") +``` + +## 에러 처리 + +### Try-Except 패턴 +```python +try: + info = await self.wikipedia_service.get_person_info(name, context=context) +except Exception as e: + logger.error(f"Error getting person info for '{name}': {e}") +``` + +### 비치명적 에러 처리 +```python +try: + stats = await self.biocode_client.register_entities(people, organizations) +except Exception as e: + # Biocode 등록 실패는 전체 파이프라인을 중단시키지 않음 + logger.warning(f"Biocode registration failed (non-critical): {e}") +``` + +## 변수/함수 네이밍 + +### Python (snake_case) +```python +# 변수 +birth_date = "1990-01-15" +founding_year = 2004 +image_urls = [] +existing_names = set() + +# 함수 +def get_existing_names(biocode_data: dict) -> set: +async def _enrich_organizations(self, entities: List[Dict[str, Any]]): +``` + +### TypeScript (camelCase) +```typescript +// 변수 +const articleCount = 100; +const isLoading = false; + +// 함수 +function getArticleById(id: string): Article +async function fetchDashboardStats(): Promise +``` + +## 상수 정의 + +```python +# 영문 상수명 + 한글 주석 +API_URL = "https://en.wikipedia.org/api/rest_v1/page/summary/{title}" +SEARCH_URL = "https://en.wikipedia.org/w/api.php" + +# 기본값 +DEFAULT_TIMEOUT = 10 # seconds +MAX_RETRIES = 3 +``` + +## 타입 힌트 + +```python +from typing import Optional, Dict, Any, List + +async def enrich_entities( + self, + people: List[Dict[str, Any]], + organizations: List[Dict[str, Any]] +) -> Dict[str, Any]: + """엔티티 목록을 Wikipedia 정보로 보강 (context 지원)""" +``` + +## 커밋 메시지 + +### 형식 +``` +: + +- +- + +Co-Authored-By: Claude Opus 4.5 +``` + +### 타입 +- `feat`: 새로운 기능 +- `fix`: 버그 수정 +- `chore`: 설정, 문서 등 잡다한 작업 +- `refactor`: 리팩토링 +- `docs`: 문서 수정 + +### 예시 +``` +feat: Pass entity_type to biocode API + +- biocode_worker: Forward entity_type (person/organization) to API +- Enables proper storage in famousPeople or famousOrganizations + +Co-Authored-By: Claude Opus 4.5 +``` + +## 파일 인코딩 + +- 모든 파일: UTF-8 +- JSON 파일: `ensure_ascii=False` 사용 + +```python +with open(file_path, 'w', encoding='utf-8') as f: + json.dump(content, f, ensure_ascii=False, indent=2) +``` diff --git a/.claude/skills/monitoring-logging.md b/.claude/skills/monitoring-logging.md new file mode 100644 index 0000000..2fc65f7 --- /dev/null +++ b/.claude/skills/monitoring-logging.md @@ -0,0 +1,361 @@ +# 모니터링 및 로깅 (Monitoring & Logging) + +이 프로젝트의 모니터링 및 로깅 패턴입니다. + +## Python 로깅 + +### 기본 설정 +```python +import logging + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) +``` + +### 로깅 패턴 +```python +# 정보성 로그 +logger.info(f"Starting Wikipedia Enrichment Worker") +logger.info(f"Processing job {job.job_id} for Wikipedia enrichment") +logger.info(f"Found {len(image_urls)} image(s) for '{name}' (logo preferred)") + +# 경고 로그 (비치명적 오류) +logger.warning(f"Biocode registration failed (non-critical): {e}") +logger.warning(f"Failed to get logo for '{title}': {e}") + +# 에러 로그 +logger.error(f"Error processing job {job.job_id}: {e}") +logger.error(f"Claude API key not configured") + +# 디버그 로그 +logger.debug(f"Selected candidate '{candidate.get('title')}' with score: {best_score}") +``` + +### 구조화된 로깅 +```python +import json + +def log_structured(level: str, message: str, **kwargs): + """구조화된 JSON 로깅""" + log_entry = { + "timestamp": datetime.now().isoformat(), + "level": level, + "message": message, + **kwargs + } + print(json.dumps(log_entry)) + +# 사용 예시 +log_structured("INFO", "Article processed", + job_id=job.job_id, + processing_time=processing_time, + people_count=len(enriched_people), + orgs_count=len(enriched_orgs) +) +``` + +## Docker 로그 + +### 로그 확인 +```bash +# 전체 로그 +docker-compose logs -f + +# 특정 서비스 로그 +docker-compose logs -f news-wikipedia-enrichment + +# 최근 100줄만 +docker-compose logs --tail=100 news-article-generator + +# 시간 범위 지정 +docker-compose logs --since 2024-01-15T10:00:00 news-wikipedia-enrichment +``` + +### 로그 드라이버 설정 +```yaml +# docker-compose.yml +services: + news-wikipedia-enrichment: + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" +``` + +## Prometheus 설정 + +### docker-compose.yml +```yaml +services: + prometheus: + image: prom/prometheus:latest + container_name: {프로젝트}-prometheus + restart: unless-stopped + ports: + - "9090:9090" + volumes: + - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml + - {프로젝트}_prometheus_data:/prometheus + networks: + - {프로젝트}-network + command: + - '--config.file=/etc/prometheus/prometheus.yml' + - '--storage.tsdb.path=/prometheus' + - '--web.enable-lifecycle' +``` + +### prometheus.yml +```yaml +global: + scrape_interval: 15s + evaluation_interval: 15s + +scrape_configs: + - job_name: 'prometheus' + static_configs: + - targets: ['localhost:9090'] + + - job_name: 'fastapi-services' + static_configs: + - targets: + - 'base-auth:8000' + - 'base-image:8000' + - 'news-user-service:8000' + metrics_path: '/metrics' + + - job_name: 'redis' + static_configs: + - targets: ['redis-exporter:9121'] + + - job_name: 'mongodb' + static_configs: + - targets: ['mongodb-exporter:9216'] +``` + +### FastAPI 메트릭 노출 +```python +from prometheus_client import Counter, Histogram, generate_latest +from fastapi import Response + +# 메트릭 정의 +REQUEST_COUNT = Counter( + 'http_requests_total', + 'Total HTTP requests', + ['method', 'endpoint', 'status'] +) + +REQUEST_LATENCY = Histogram( + 'http_request_duration_seconds', + 'HTTP request latency', + ['method', 'endpoint'] +) + +@app.get("/metrics") +async def metrics(): + return Response( + content=generate_latest(), + media_type="text/plain" + ) + +@app.middleware("http") +async def track_metrics(request: Request, call_next): + start_time = time.time() + response = await call_next(request) + duration = time.time() - start_time + + REQUEST_COUNT.labels( + method=request.method, + endpoint=request.url.path, + status=response.status_code + ).inc() + + REQUEST_LATENCY.labels( + method=request.method, + endpoint=request.url.path + ).observe(duration) + + return response +``` + +## Grafana 설정 + +### docker-compose.yml +```yaml +services: + grafana: + image: grafana/grafana:latest + container_name: {프로젝트}-grafana + restart: unless-stopped + ports: + - "3000:3000" + volumes: + - {프로젝트}_grafana_data:/var/lib/grafana + - ./grafana/provisioning:/etc/grafana/provisioning + environment: + - GF_SECURITY_ADMIN_USER=admin + - GF_SECURITY_ADMIN_PASSWORD=admin123 + - GF_USERS_ALLOW_SIGN_UP=false + networks: + - {프로젝트}-network +``` + +### 데이터소스 프로비저닝 +```yaml +# grafana/provisioning/datasources/datasources.yml +apiVersion: 1 + +datasources: + - name: Prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true + editable: false +``` + +### 대시보드 예시 (JSON) +```json +{ + "dashboard": { + "title": "News Pipeline Monitoring", + "panels": [ + { + "title": "Request Rate", + "type": "graph", + "targets": [ + { + "expr": "rate(http_requests_total[5m])", + "legendFormat": "{{method}} {{endpoint}}" + } + ] + }, + { + "title": "Request Latency (p95)", + "type": "graph", + "targets": [ + { + "expr": "histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m]))", + "legendFormat": "{{endpoint}}" + } + ] + } + ] + } +} +``` + +## 헬스체크 + +### FastAPI 헬스체크 엔드포인트 +```python +@app.get("/health") +async def health_check(): + """헬스체크 엔드포인트""" + checks = { + "status": "healthy", + "timestamp": datetime.now().isoformat(), + "checks": {} + } + + # MongoDB 체크 + try: + await db.command("ping") + checks["checks"]["mongodb"] = "healthy" + except Exception as e: + checks["checks"]["mongodb"] = f"unhealthy: {e}" + checks["status"] = "unhealthy" + + # Redis 체크 + try: + await redis.ping() + checks["checks"]["redis"] = "healthy" + except Exception as e: + checks["checks"]["redis"] = f"unhealthy: {e}" + checks["status"] = "unhealthy" + + status_code = 200 if checks["status"] == "healthy" else 503 + return JSONResponse(content=checks, status_code=status_code) +``` + +### Docker 헬스체크 +```yaml +healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s +``` + +## 워커 하트비트 + +### Redis 기반 하트비트 +```python +class QueueManager: + async def start_heartbeat(self, worker_name: str): + """워커 하트비트 시작""" + async def heartbeat_loop(): + while True: + try: + await self.redis.setex( + f"worker:heartbeat:{worker_name}", + 60, # 60초 TTL + datetime.now().isoformat() + ) + await asyncio.sleep(30) # 30초마다 갱신 + except Exception as e: + logger.error(f"Heartbeat error: {e}") + + asyncio.create_task(heartbeat_loop()) + + async def get_active_workers(self) -> List[str]: + """활성 워커 목록 조회""" + keys = await self.redis.keys("worker:heartbeat:*") + return [key.decode().split(":")[-1] for key in keys] +``` + +## 알림 설정 (Alertmanager) + +### alertmanager.yml +```yaml +global: + slack_api_url: 'https://hooks.slack.com/services/xxx' + +route: + receiver: 'slack-notifications' + group_wait: 30s + group_interval: 5m + repeat_interval: 4h + +receivers: + - name: 'slack-notifications' + slack_configs: + - channel: '#alerts' + send_resolved: true + title: '{{ .GroupLabels.alertname }}' + text: '{{ range .Alerts }}{{ .Annotations.description }}{{ end }}' +``` + +### 알림 규칙 +```yaml +# prometheus/rules/alerts.yml +groups: + - name: service-alerts + rules: + - alert: HighErrorRate + expr: rate(http_requests_total{status=~"5.."}[5m]) > 0.1 + for: 5m + labels: + severity: critical + annotations: + description: "High error rate detected" + + - alert: WorkerDown + expr: absent(up{job="fastapi-services"}) + for: 1m + labels: + severity: warning + annotations: + description: "Worker service is down" +``` diff --git a/.claude/skills/prj-archetypes.md b/.claude/skills/prj-archetypes.md new file mode 100644 index 0000000..e613ead --- /dev/null +++ b/.claude/skills/prj-archetypes.md @@ -0,0 +1,179 @@ +# Project Archetypes (프로젝트 아키타입) + +`/prj` 에이전트가 프로젝트 유형을 판단할 때 참조하는 아키타입 정의입니다. +사용자의 설명에 가장 맞는 아키타입을 선택하고, 해당 참조 프로젝트를 분석하세요. + +--- + +## fullstack-webapp +웹 애플리케이션 (프론트엔드 + 백엔드 + DB) + +- **키워드**: 대시보드, 관리자, 서비스, 앱, 플랫폼, CRUD +- **참조 프로젝트**: site20 (Biocode Chat), site22 (Trading), black-ink (소설 작성) +- **Stack**: Next.js 16 + FastAPI + MongoDB 7.0 + Redis 7 +- **init 템플릿**: `fullstack` +- **구조**: + ``` + {name}/ + ├── frontend/ # Next.js App Router + Tailwind CSS 4 + ├── backend/ # FastAPI + Motor(async MongoDB) + │ └── app/ + │ ├── main.py + │ ├── database.py + │ ├── config.py + │ ├── routers/ + │ ├── models/ + │ └── services/ + ├── docker-compose.yml # MongoDB + Redis + backend + frontend + ├── .env + └── CLAUDE.md + ``` +- **적용 스킬**: project-stack, api-design-standards, frontend-component-patterns, database-patterns, deployment-standards + +--- + +## backend-api +API 서버 또는 마이크로서비스 (프론트엔드 없음) + +- **키워드**: API, 서버, 서비스, 마이크로서비스, 백엔드, 데이터 처리 +- **참조 프로젝트**: site12 (i18n), site13 (파일), site14 (FCM), site15 (이미지), site16 (인증) +- **Stack**: FastAPI + MongoDB 7.0 + Redis 7 +- **init 템플릿**: `backend` +- **구조**: + ``` + {name}/ + ├── app/ + │ ├── main.py + │ ├── database.py + │ ├── config.py + │ ├── routers/ + │ ├── models/ + │ └── services/ + ├── Dockerfile + ├── requirements.txt + ├── docker-compose.yml + ├── .env + └── CLAUDE.md + ``` +- **적용 스킬**: api-design-standards, database-patterns, deployment-standards +- **특이사항**: site12~16 패턴 - 단일 책임, 독립 배포 가능 + +--- + +## ai-service +AI/ML 모델을 활용하는 서비스 + +- **키워드**: AI, 분석, 생성, Claude, GPT, ML, 추론, 예측, 파이프라인 +- **참조 프로젝트**: site23 (Hedge System), pipeline (뉴스 파이프라인), black-ink (Claude 도구 실행) +- **Stack**: FastAPI + Claude/OpenAI API + MongoDB + Redis +- **init 템플릿**: `backend` +- **구조**: + ``` + {name}/ + ├── app/ + │ ├── main.py + │ ├── services/ + │ │ ├── claude_service.py # AI API 클라이언트 + │ │ ├── prompt_builder.py # 프롬프트 관리 + │ │ └── orchestrator.py # 파이프라인 조합 + │ ├── routers/ + │ └── models/ + ├── prompts/ # 프롬프트 템플릿 (.md) + ├── Dockerfile + └── docker-compose.yml + ``` +- **적용 스킬**: ai-api-integration, api-design-standards, database-patterns +- **특이사항**: AI API 키 관리, 프롬프트 버전 관리, 토큰 사용량 모니터링 필요 + +--- + +## microservice-pipeline +여러 서비스가 순차/병렬로 처리하는 파이프라인 + +- **키워드**: 파이프라인, 워커, 큐, 배치, ETL, 수집, 처리 +- **참조 프로젝트**: pipeline (7개 마이크로서비스), site19 (뉴스 엔진) +- **Stack**: Python workers + Redis 큐 + MongoDB + Docker Compose +- **init 템플릿**: `fullstack` (커스텀 수정) +- **구조**: + ``` + {name}/ + ├── services/ + │ ├── collector/ # 데이터 수집 + │ ├── processor/ # 데이터 처리 + │ ├── analyzer/ # 분석 + │ └── api/ # 결과 조회 API + ├── commons/ # 공유 유틸리티 + ├── docker-compose.yml # 전체 서비스 오케스트레이션 + ├── .env + └── CLAUDE.md + ``` +- **적용 스킬**: deployment-standards, database-patterns, monitoring-logging +- **특이사항**: 서비스 간 Redis 큐 통신, 하트비트 패턴, 독립 스케일링 + +--- + +## frontend-only +프론트엔드만 필요한 프로젝트 (정적 사이트, 클라이언트 앱) + +- **키워드**: UI, 프론트엔드, 웹사이트, 랜딩, 테마, 빌더 +- **참조 프로젝트**: site03 (Dynamic Theme), site04 (UI Builder), site06 (News Theme) +- **Stack**: Next.js 16 + React 19 + Tailwind CSS 4 +- **init 템플릿**: `frontend` +- **구조**: + ``` + {name}/ + ├── src/ + │ ├── app/ # Next.js App Router + │ ├── components/ # 재사용 컴포넌트 + │ ├── hooks/ # 커스텀 훅 + │ ├── lib/ # 유틸리티 + │ └── types/ # TypeScript 타입 + ├── public/ + ├── Dockerfile + ├── package.json + └── CLAUDE.md + ``` +- **적용 스킬**: frontend-component-patterns, project-stack +- **특이사항**: 외부 API 연동 시 환경변수로 URL 관리 + +--- + +## content-platform +콘텐츠 생성/관리 플랫폼 (AI 기반 창작, CMS) + +- **키워드**: 소설, 글쓰기, 콘텐츠, CMS, 에디터, 스토리, 시나리오 +- **참조 프로젝트**: black-ink (대화형 소설), site17/inkchain (소설 생성), site18 (소설+이미지) +- **Stack**: Next.js 16 + FastAPI + Claude API + MongoDB + Redis +- **init 템플릿**: `fullstack` +- **구조**: + ``` + {name}/ + ├── frontend/ + │ └── src/ + │ ├── components/ + │ │ ├── ChatWindow.tsx # 대화형 UI + │ │ ├── MessageList.tsx + │ │ └── StageProgress.tsx # 단계 표시 + │ └── hooks/ + │ └── useChat.ts # SSE 스트리밍 + ├── backend/ + │ └── app/ + │ └── services/ + │ ├── claude_service.py # Claude SSE 스트리밍 + │ ├── chat_orchestrator.py # 도구 실행 루프 + │ ├── context_manager.py # 컨텍스트 CRUD + │ ├── stage_router.py # 단계 자동 판단 + │ └── prompt_builder.py # 프롬프트 조립 + └── docker-compose.yml + ``` +- **적용 스킬**: ai-api-integration, frontend-component-patterns, database-patterns +- **특이사항**: SSE 스트리밍, Claude 도구 실행 루프, 단계 기반 워크플로우 (black-ink 패턴) + +--- + +## 아키타입 선택 가이드 + +1. 사용자 설명에서 **키워드** 매칭 +2. 여러 아키타입이 겹치면 **블렌딩** 가능 (예: AI + fullstack = ai-service 패턴의 백엔드 + fullstack의 프론트엔드) +3. 불확실하면 사용자에게 선택지를 제시 +4. "like siteXX" 표현이 있으면 해당 프로젝트의 아키타입을 우선 적용 diff --git a/.claude/skills/project-stack.md b/.claude/skills/project-stack.md new file mode 100644 index 0000000..f9ddde5 --- /dev/null +++ b/.claude/skills/project-stack.md @@ -0,0 +1,117 @@ +# 프로젝트 기술 스택 (Project Stack) + +이 프로젝트의 기본 기술 스택과 구조입니다. + +## Frontend (news-engine-admin) + +### 기술 스택 +- **Framework**: Next.js 16.x (App Router) +- **Language**: TypeScript 5.x +- **React**: 19.x +- **Styling**: Tailwind CSS 4.x +- **UI Components**: Radix UI + shadcn/ui +- **Form**: react-hook-form + zod +- **Icons**: lucide-react +- **Theme**: next-themes (다크모드 지원) + +### 디렉토리 구조 +``` +src/ +├── app/ # Next.js App Router 페이지 +│ ├── layout.tsx # 루트 레이아웃 +│ ├── page.tsx # 메인 페이지 +│ └── dashboard/ # 대시보드 페이지들 +├── components/ # React 컴포넌트 +│ ├── ui/ # shadcn/ui 기본 컴포넌트 +│ ├── dashboard/ # 대시보드 전용 컴포넌트 +│ └── providers/ # Context Provider +├── hooks/ # 커스텀 훅 +├── lib/ # 유틸리티 함수 +│ └── utils.ts # cn() 등 공통 유틸 +└── types/ # TypeScript 타입 정의 +``` + +### 설치 명령어 +```bash +# 프로젝트 생성 +npx create-next-app@latest --typescript --tailwind --app + +# shadcn/ui 초기화 +npx shadcn@latest init + +# 컴포넌트 추가 +npx shadcn@latest add button card dialog tabs +``` + +## Backend (FastAPI 마이크로서비스) + +### 기술 스택 +- **Framework**: FastAPI +- **Language**: Python 3.11 +- **Database**: MongoDB (motor - async driver) +- **Queue**: Redis (aioredis) +- **Validation**: Pydantic v2 +- **HTTP Client**: aiohttp + +### 마이크로서비스 구조 +``` +service/ +├── Dockerfile +├── requirements.txt +├── worker.py # 메인 워커 (큐 처리) +├── *_service.py # 비즈니스 로직 +└── *_client.py # 외부 서비스 클라이언트 +``` + +### API 서비스 구조 +``` +service/ +├── Dockerfile +├── requirements.txt +└── app/ + ├── __init__.py + ├── main.py # FastAPI 앱 엔트리포인트 + ├── database.py # DB 연결 관리 + ├── models/ # Pydantic 모델 + └── routers/ # API 라우터 +``` + +## 공통 라이브러리 (news-commons) + +### 제공 기능 +- `QueueManager`: Redis 큐 관리 (enqueue, dequeue, heartbeat) +- `PipelineJob`: 파이프라인 작업 데이터 모델 +- `PersonEntity`, `OrganizationEntity`: 엔티티 모델 +- 로깅, 설정 유틸리티 + +### 사용 예시 +```python +from news_commons import PipelineJob, QueueManager + +queue_manager = QueueManager(redis_url="redis://redis:6379") +await queue_manager.connect() + +job = await queue_manager.dequeue('wikipedia_enrichment', timeout=5) +``` + +## 인프라 + +### 컨테이너 +- **MongoDB**: 7.0 +- **Redis**: 7-alpine +- **Docker Compose**: 서비스 오케스트레이션 + +### 외부 서비스 +- **Gitea**: 코드 저장소 (http://gitea.yakenator.io/) +- **OpenAI API**: GPT 모델 사용 +- **Claude API**: Claude 모델 사용 + +## 참고 리포지토리 + +| 서비스 | 설명 | URL | +|--------|------|-----| +| news-commons | 공통 라이브러리 | gitea.yakenator.io/sapiens/news-commons | +| news-article-generator | 기사 생성 | gitea.yakenator.io/sapiens/news-article-generator | +| news-wikipedia-enrichment | 위키피디아 보강 | gitea.yakenator.io/sapiens/news-wikipedia-enrichment | +| news-image-generator | 이미지 생성 | gitea.yakenator.io/sapiens/news-image-generator | +| mcp_biocode | 바이오코드 API | gitea.yakenator.io/sapiens/mcp_biocode | diff --git a/.claude/skills/testing-standards.md b/.claude/skills/testing-standards.md new file mode 100644 index 0000000..4080009 --- /dev/null +++ b/.claude/skills/testing-standards.md @@ -0,0 +1,360 @@ +# 테스트 작성 표준 (Testing Standards) + +이 프로젝트의 테스트 작성 패턴입니다. + +## Python (pytest) + +### 설치 +```bash +pip install pytest pytest-asyncio pytest-cov +``` + +### 디렉토리 구조 +``` +service/ +├── worker.py +├── service.py +├── tests/ +│ ├── __init__.py +│ ├── conftest.py # 공통 fixture +│ ├── test_worker.py +│ └── test_service.py +└── pytest.ini +``` + +### pytest.ini 설정 +```ini +[pytest] +asyncio_mode = auto +testpaths = tests +python_files = test_*.py +python_functions = test_* +``` + +### Fixture 패턴 (conftest.py) +```python +import pytest +import asyncio +from motor.motor_asyncio import AsyncIOMotorClient + +@pytest.fixture(scope="session") +def event_loop(): + """세션 범위의 이벤트 루프""" + loop = asyncio.get_event_loop_policy().new_event_loop() + yield loop + loop.close() + +@pytest.fixture +async def mongodb(): + """테스트용 MongoDB 연결""" + client = AsyncIOMotorClient("mongodb://localhost:27017") + db = client["test_db"] + yield db + # 테스트 후 정리 + await client.drop_database("test_db") + client.close() + +@pytest.fixture +def sample_article(): + """테스트용 기사 데이터""" + return { + "title": "Test Article", + "summary": "Test summary", + "entities": { + "people": [{"name": "Elon Musk", "context": ["Tesla", "CEO"]}], + "organizations": [{"name": "Tesla", "context": ["EV", "automotive"]}] + } + } +``` + +### 단위 테스트 예시 +```python +import pytest +from service import calculate_biocode + +def test_calculate_biocode_basic(): + """바이오코드 계산 기본 테스트""" + result = calculate_biocode(1990, 5, 15) + assert result is not None + assert len(result) == 4 # g코드 2자리 + s코드 2자리 + +def test_calculate_biocode_edge_cases(): + """경계값 테스트""" + # 연초 + result = calculate_biocode(1990, 1, 1) + assert result.endswith("60") # 대설 + + # 연말 + result = calculate_biocode(1990, 12, 31) + assert result is not None +``` + +### 비동기 테스트 예시 +```python +import pytest +from wikipedia_service import WikipediaService + +@pytest.mark.asyncio +async def test_get_person_info(): + """인물 정보 조회 테스트""" + service = WikipediaService() + + try: + info = await service.get_person_info( + "Elon Musk", + context=["Tesla", "SpaceX"] + ) + + assert info is not None + assert info.name == "Elon Musk" + assert info.wikipedia_url is not None + finally: + await service.close() + +@pytest.mark.asyncio +async def test_get_organization_info_with_logo(): + """조직 로고 우선 조회 테스트""" + service = WikipediaService() + + try: + info = await service.get_organization_info( + "Apple Inc.", + context=["technology", "iPhone"] + ) + + assert info is not None + assert info.image_urls # 로고 이미지가 있어야 함 + finally: + await service.close() +``` + +### Mock 사용 예시 +```python +from unittest.mock import AsyncMock, patch +import pytest + +@pytest.mark.asyncio +async def test_worker_process_job(): + """워커 작업 처리 테스트 (외부 API 모킹)""" + with patch('worker.WikipediaService') as mock_service: + mock_instance = AsyncMock() + mock_instance.get_person_info.return_value = PersonInfo( + name="Test Person", + birth_date="1990-01-15", + verified=True + ) + mock_service.return_value = mock_instance + + worker = WikipediaEnrichmentWorker() + # ... 테스트 수행 +``` + +### 테스트 실행 +```bash +# 전체 테스트 +pytest + +# 커버리지 포함 +pytest --cov=. --cov-report=html + +# 특정 테스트만 +pytest tests/test_service.py -v + +# 특정 함수만 +pytest tests/test_service.py::test_calculate_biocode_basic -v +``` + +## JavaScript/TypeScript (Jest) + +### 설치 +```bash +npm install --save-dev jest @types/jest ts-jest +``` + +### jest.config.js +```javascript +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: ['/src'], + testMatch: ['**/*.test.ts', '**/*.spec.ts'], + collectCoverageFrom: [ + 'src/**/*.{ts,tsx}', + '!src/**/*.d.ts', + ], +} +``` + +### 단위 테스트 예시 +```typescript +// utils.test.ts +import { cn, formatDate } from './utils' + +describe('cn utility', () => { + it('should merge class names', () => { + const result = cn('foo', 'bar') + expect(result).toBe('foo bar') + }) + + it('should handle conditional classes', () => { + const result = cn('foo', false && 'bar', 'baz') + expect(result).toBe('foo baz') + }) +}) + +describe('formatDate', () => { + it('should format date correctly', () => { + const date = new Date('2024-01-15') + expect(formatDate(date)).toBe('2024-01-15') + }) +}) +``` + +### React 컴포넌트 테스트 +```typescript +// Button.test.tsx +import { render, screen, fireEvent } from '@testing-library/react' +import { Button } from './Button' + +describe('Button', () => { + it('renders correctly', () => { + render() + expect(screen.getByText('Click me')).toBeInTheDocument() + }) + + it('calls onClick handler', () => { + const handleClick = jest.fn() + render() + fireEvent.click(screen.getByText('Click me')) + expect(handleClick).toHaveBeenCalledTimes(1) + }) + + it('applies variant styles', () => { + render() + const button = screen.getByText('Delete') + expect(button).toHaveClass('bg-destructive') + }) +}) +``` + +### API 테스트 (MSW Mock) +```typescript +// api.test.ts +import { rest } from 'msw' +import { setupServer } from 'msw/node' +import { fetchArticles } from './api' + +const server = setupServer( + rest.get('/api/articles', (req, res, ctx) => { + return res( + ctx.json({ + items: [{ id: '1', title: 'Test Article' }], + total: 1 + }) + ) + }) +) + +beforeAll(() => server.listen()) +afterEach(() => server.resetHandlers()) +afterAll(() => server.close()) + +describe('fetchArticles', () => { + it('fetches articles successfully', async () => { + const articles = await fetchArticles() + expect(articles.items).toHaveLength(1) + expect(articles.items[0].title).toBe('Test Article') + }) +}) +``` + +### 테스트 실행 +```bash +# 전체 테스트 +npm test + +# 감시 모드 +npm test -- --watch + +# 커버리지 +npm test -- --coverage + +# 특정 파일만 +npm test -- Button.test.tsx +``` + +## E2E 테스트 (Playwright) + +### 설치 +```bash +npm install --save-dev @playwright/test +npx playwright install +``` + +### playwright.config.ts +```typescript +import { defineConfig } from '@playwright/test' + +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + use: { + baseURL: 'http://localhost:3000', + trace: 'on-first-retry', + }, +}) +``` + +### E2E 테스트 예시 +```typescript +// e2e/dashboard.spec.ts +import { test, expect } from '@playwright/test' + +test.describe('Dashboard', () => { + test('should display article list', async ({ page }) => { + await page.goto('/dashboard') + await expect(page.getByRole('heading', { name: 'Articles' })).toBeVisible() + await expect(page.getByRole('table')).toBeVisible() + }) + + test('should filter articles by keyword', async ({ page }) => { + await page.goto('/dashboard') + await page.fill('[placeholder="Search..."]', 'technology') + await page.click('button:has-text("Search")') + await expect(page.locator('table tbody tr')).toHaveCount(5) + }) +}) +``` + +## CI/CD 통합 + +### GitHub Actions +```yaml +# .github/workflows/test.yml +name: Tests + +on: [push, pull_request] + +jobs: + python-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 + with: + python-version: '3.11' + - run: pip install -r requirements.txt + - run: pytest --cov + + js-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: '20' + - run: npm ci + - run: npm test -- --coverage +``` diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..22787c5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.env +__pycache__/ +*.pyc +node_modules/ +.next/ +.DS_Store +backups/ +*.log diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..74d1d4a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,20 @@ +# CLAUDE.md + +## Project: todos2 +- **Template**: fullstack +- **Created**: 2026-02-10 + +## Commands +```bash +# 서비스 시작 +docker-compose up -d + +# 서비스 로그 +docker-compose logs -f + +# 서비스 중지 +docker-compose down +``` + +## Architecture +프로젝트 구조 및 아키텍처를 여기에 기술하세요. diff --git a/README.md b/README.md new file mode 100644 index 0000000..8fedadc --- /dev/null +++ b/README.md @@ -0,0 +1,39 @@ +# todos2 + +## Overview +프로젝트 설명을 여기에 작성하세요. + +## Tech Stack +- **Template**: fullstack +- **Stack**: Next.js + FastAPI + MongoDB + Redis +- **Created**: 2026-02-10 + +## Getting Started + +```bash +# 서비스 시작 +docker-compose up -d + +# 서비스 로그 +docker-compose logs -f + +# 서비스 중지 +docker-compose down +``` + +## Project Structure +``` +todos2/ +├── backend/ # FastAPI 백엔드 +│ ├── app/ +│ │ └── main.py # 엔트리포인트 +│ ├── Dockerfile +│ └── requirements.txt +├── frontend/ # Next.js 프론트엔드 +├── docker-compose.yml +├── .env +└── CLAUDE.md +``` + +## Git +- **Gitea**: http://gitea.yakenator.io/yakenator/todos2 diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..35f21ba --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,8 @@ +FROM python:3.11-slim +WORKDIR /app +RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/* +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY app/ ./app/ +EXPOSE 8000 +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..f8c47a1 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,17 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from datetime import datetime + +app = FastAPI(title="API", version="1.0.0") + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +@app.get("/health") +async def health_check(): + return {"status": "healthy", "timestamp": datetime.now().isoformat()} diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..cfc3b01 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,6 @@ +fastapi>=0.104.0 +uvicorn[standard]>=0.24.0 +motor>=3.3.0 +pydantic>=2.5.0 +aioredis>=2.0.0 +python-dotenv>=1.0.0 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5112ee8 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,83 @@ +services: + # =================== + # Infrastructure + # =================== + mongodb: + image: mongo:7.0 + container_name: ${PROJECT_NAME}-mongodb + restart: unless-stopped + environment: + MONGO_INITDB_ROOT_USERNAME: ${MONGO_USER:-admin} + MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASSWORD:-password123} + ports: + - "${MONGO_PORT:-27017}:27017" + volumes: + - mongodb_data:/data/db + networks: + - app-network + healthcheck: + test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"] + interval: 30s + timeout: 10s + retries: 3 + + redis: + image: redis:7-alpine + container_name: ${PROJECT_NAME}-redis + restart: unless-stopped + ports: + - "${REDIS_PORT:-6379}:6379" + volumes: + - redis_data:/data + networks: + - app-network + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 30s + timeout: 10s + retries: 3 + + # =================== + # Backend + # =================== + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: ${PROJECT_NAME}-backend + restart: unless-stopped + ports: + - "${BACKEND_PORT:-8000}:8000" + environment: + - MONGODB_URL=mongodb://${MONGO_USER:-admin}:${MONGO_PASSWORD:-password123}@mongodb:27017/ + - DB_NAME=${DB_NAME:-app_db} + - REDIS_URL=redis://redis:6379 + depends_on: + mongodb: + condition: service_healthy + redis: + condition: service_healthy + networks: + - app-network + + # =================== + # Frontend + # =================== + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + container_name: ${PROJECT_NAME}-frontend + restart: unless-stopped + ports: + - "${FRONTEND_PORT:-3000}:3000" + networks: + - app-network + +volumes: + mongodb_data: + redis_data: + +networks: + app-network: + driver: bridge