Initial commit: 프로젝트 초기 구성
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
47
.claude/skills/README.md
Normal file
47
.claude/skills/README.md
Normal file
@ -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/)
|
||||
213
.claude/skills/ai-api-integration.md
Normal file
213
.claude/skills/ai-api-integration.md
Normal file
@ -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 기반 캐시 만료
|
||||
- 동일 입력에 대한 중복 호출 방지
|
||||
269
.claude/skills/api-design-standards.md
Normal file
269
.claude/skills/api-design-standards.md
Normal file
@ -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}
|
||||
```
|
||||
384
.claude/skills/database-patterns.md
Normal file
384
.claude/skills/database-patterns.md
Normal file
@ -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
|
||||
```
|
||||
140
.claude/skills/deployment-standards.md
Normal file
140
.claude/skills/deployment-standards.md
Normal file
@ -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/
|
||||
325
.claude/skills/frontend-component-patterns.md
Normal file
325
.claude/skills/frontend-component-patterns.md
Normal file
@ -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<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<html lang="en">
|
||||
<body className={`${geistSans.variable} antialiased`}>
|
||||
<Providers>
|
||||
{children}
|
||||
<Toaster />
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Provider 패턴
|
||||
```tsx
|
||||
// components/providers.tsx
|
||||
"use client"
|
||||
|
||||
import { ThemeProvider } from "next-themes"
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## 유틸리티 함수
|
||||
|
||||
### 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<Article[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<Error | null>(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<typeof formSchema>
|
||||
|
||||
export function ArticleForm() {
|
||||
const form = useForm<FormValues>({
|
||||
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 (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>제목</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button type="submit">저장</Button>
|
||||
</form>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## 다크모드 지원
|
||||
|
||||
### 테마 토글 버튼
|
||||
```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 (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
||||
>
|
||||
<Sun className="h-5 w-5 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||
<Moon className="absolute h-5 w-5 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## 토스트 알림 (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
|
||||
```
|
||||
163
.claude/skills/gitea-workflow.md
Normal file
163
.claude/skills/gitea-workflow.md
Normal file
@ -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 <noreply@anthropic.com>"
|
||||
|
||||
# 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 <noreply@anthropic.com>
|
||||
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
|
||||
```
|
||||
268
.claude/skills/infrastructure-setup.md
Normal file
268
.claude/skills/infrastructure-setup.md
Normal file
@ -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 <token> \
|
||||
--discovery-token-ca-cert-hash sha256:<hash>
|
||||
```
|
||||
|
||||
### 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 {서비스명}
|
||||
```
|
||||
161
.claude/skills/korean-dev-conventions.md
Normal file
161
.claude/skills/korean-dev-conventions.md
Normal file
@ -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<Stats>
|
||||
```
|
||||
|
||||
## 상수 정의
|
||||
|
||||
```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 지원)"""
|
||||
```
|
||||
|
||||
## 커밋 메시지
|
||||
|
||||
### 형식
|
||||
```
|
||||
<type>: <description>
|
||||
|
||||
- <detail 1>
|
||||
- <detail 2>
|
||||
|
||||
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
|
||||
```
|
||||
|
||||
### 타입
|
||||
- `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 <noreply@anthropic.com>
|
||||
```
|
||||
|
||||
## 파일 인코딩
|
||||
|
||||
- 모든 파일: 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)
|
||||
```
|
||||
361
.claude/skills/monitoring-logging.md
Normal file
361
.claude/skills/monitoring-logging.md
Normal file
@ -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"
|
||||
```
|
||||
179
.claude/skills/prj-archetypes.md
Normal file
179
.claude/skills/prj-archetypes.md
Normal file
@ -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" 표현이 있으면 해당 프로젝트의 아키타입을 우선 적용
|
||||
117
.claude/skills/project-stack.md
Normal file
117
.claude/skills/project-stack.md
Normal file
@ -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 |
|
||||
360
.claude/skills/testing-standards.md
Normal file
360
.claude/skills/testing-standards.md
Normal file
@ -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: ['<rootDir>/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(<Button>Click me</Button>)
|
||||
expect(screen.getByText('Click me')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onClick handler', () => {
|
||||
const handleClick = jest.fn()
|
||||
render(<Button onClick={handleClick}>Click me</Button>)
|
||||
fireEvent.click(screen.getByText('Click me'))
|
||||
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('applies variant styles', () => {
|
||||
render(<Button variant="destructive">Delete</Button>)
|
||||
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
|
||||
```
|
||||
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
.env
|
||||
__pycache__/
|
||||
*.pyc
|
||||
node_modules/
|
||||
.next/
|
||||
.DS_Store
|
||||
backups/
|
||||
*.log
|
||||
20
CLAUDE.md
Normal file
20
CLAUDE.md
Normal file
@ -0,0 +1,20 @@
|
||||
# CLAUDE.md
|
||||
|
||||
## Project: web-inspector
|
||||
- **Template**: fullstack
|
||||
- **Created**: 2026-02-12
|
||||
|
||||
## Commands
|
||||
```bash
|
||||
# 서비스 시작
|
||||
docker-compose up -d
|
||||
|
||||
# 서비스 로그
|
||||
docker-compose logs -f
|
||||
|
||||
# 서비스 중지
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
## Architecture
|
||||
프로젝트 구조 및 아키텍처를 여기에 기술하세요.
|
||||
39
README.md
Normal file
39
README.md
Normal file
@ -0,0 +1,39 @@
|
||||
# web-inspector
|
||||
|
||||
## Overview
|
||||
프로젝트 설명을 여기에 작성하세요.
|
||||
|
||||
## Tech Stack
|
||||
- **Template**: fullstack
|
||||
- **Stack**: Next.js + FastAPI + MongoDB + Redis
|
||||
- **Created**: 2026-02-12
|
||||
|
||||
## Getting Started
|
||||
|
||||
```bash
|
||||
# 서비스 시작
|
||||
docker-compose up -d
|
||||
|
||||
# 서비스 로그
|
||||
docker-compose logs -f
|
||||
|
||||
# 서비스 중지
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
```
|
||||
web-inspector/
|
||||
├── backend/ # FastAPI 백엔드
|
||||
│ ├── app/
|
||||
│ │ └── main.py # 엔트리포인트
|
||||
│ ├── Dockerfile
|
||||
│ └── requirements.txt
|
||||
├── frontend/ # Next.js 프론트엔드
|
||||
├── docker-compose.yml
|
||||
├── .env
|
||||
└── CLAUDE.md
|
||||
```
|
||||
|
||||
## Git
|
||||
- **Gitea**: http://gitea.yakenator.io/yakenator/web-inspector
|
||||
8
backend/Dockerfile
Normal file
8
backend/Dockerfile
Normal file
@ -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"]
|
||||
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
17
backend/app/main.py
Normal file
17
backend/app/main.py
Normal file
@ -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()}
|
||||
6
backend/requirements.txt
Normal file
6
backend/requirements.txt
Normal file
@ -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
|
||||
83
docker-compose.yml
Normal file
83
docker-compose.yml
Normal file
@ -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
|
||||
Reference in New Issue
Block a user