- Redmine 8단계 페르소나 파이프라인 (.claude/agents, workflows) - Design-First docs 골격 (docs/design, docs/adr, docs/pipeline) - 안전-최대 권한 정책 (.claude/settings.json) - Tasteby 고유 규칙 보존 (CLAUDE.md 병합) - scripts/enqueue.sh: Redmine 큐 투입 Refs: tasteby bootstrap
587 lines
22 KiB
Python
Executable File
587 lines
22 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
Tasteby 페르소나 기반 UX/기능 리뷰
|
|
|
|
사용자 페르소나 관점에서 앱 기능을 리뷰하고 개선점을 도출.
|
|
결과는 reviews/ 디렉토리에 타임스탬프로 저장.
|
|
|
|
사용법:
|
|
# 전체 페르소나 리뷰
|
|
python3 scripts/persona_review.py
|
|
|
|
# 특정 페르소나만
|
|
python3 scripts/persona_review.py --persona foodie
|
|
python3 scripts/persona_review.py --persona tourist
|
|
python3 scripts/persona_review.py --persona beginner
|
|
python3 scripts/persona_review.py --persona mobile
|
|
|
|
# 특정 영역만 리뷰
|
|
python3 scripts/persona_review.py --focus home
|
|
python3 scripts/persona_review.py --focus map
|
|
python3 scripts/persona_review.py --focus detail
|
|
python3 scripts/persona_review.py --focus admin
|
|
|
|
# 모델 변경
|
|
python3 scripts/persona_review.py --model anthropic/claude-sonnet-4
|
|
|
|
# 결과만 보기 (API 호출 없이 마지막 리뷰)
|
|
python3 scripts/persona_review.py --latest
|
|
"""
|
|
import json, os, sys, argparse, glob
|
|
from pathlib import Path
|
|
from datetime import datetime, timezone, timedelta
|
|
|
|
# .env 로드
|
|
def load_dotenv():
|
|
env_path = Path(__file__).resolve().parent.parent / ".env"
|
|
if env_path.exists():
|
|
with open(env_path) as f:
|
|
for line in f:
|
|
line = line.strip()
|
|
if not line or line.startswith("#"):
|
|
continue
|
|
if "=" in line:
|
|
key, _, val = line.partition("=")
|
|
os.environ.setdefault(key.strip(), val.strip())
|
|
|
|
load_dotenv()
|
|
|
|
OPENROUTER_API_KEY = os.environ.get("OPENROUTER_API_KEY")
|
|
MODEL = os.environ.get("REVIEW_MODEL", "anthropic/claude-haiku-4-5")
|
|
API_URL = "https://openrouter.ai/api/v1/chat/completions"
|
|
|
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
|
REVIEWS_DIR = PROJECT_ROOT / "reviews"
|
|
|
|
KST = timezone(timedelta(hours=9))
|
|
|
|
# --- 페르소나 정의 ---
|
|
|
|
PERSONAS = {
|
|
"foodie": {
|
|
"name": "맛집 탐방러",
|
|
"emoji": "🍜",
|
|
"desc": "유튜브 맛집 영상을 자주 보고, 주말마다 맛집 탐방을 다니는 30대 직장인",
|
|
"prompt": """너는 유튜브 먹방/맛집 채널을 매일 보는 30대 직장인이야.
|
|
주말마다 새로운 맛집을 찾아가는 게 취미고, 성시경, 김사원세끼 같은 채널을 구독해.
|
|
지도 앱으로 맛집 위치를 확인하고, 리뷰를 남기는 걸 좋아해.
|
|
|
|
이 앱의 현재 상태를 보고, 너 같은 사용자 입장에서 리뷰해줘:
|
|
1. 유튜버별로 맛집을 찾고 → 지도에서 위치 확인 → 상세 정보를 보는 흐름이 자연스러운가?
|
|
2. 식당 필터링(장르, 지역, 유튜버)이 편리한가?
|
|
3. 식당 상세 정보(평가, 메뉴, 가격대, 예약 링크)가 충분한가?
|
|
4. 즐겨찾기, 리뷰, 메모 기능이 실용적인가?
|
|
5. 구체적인 개선 제안 (우선순위 높은 것 3~5개)
|
|
|
|
한국어로 답변. 각 개선 제안은 [P0/P1/P2] 우선순위를 붙여줘."""
|
|
},
|
|
"tourist": {
|
|
"name": "여행자",
|
|
"emoji": "✈️",
|
|
"desc": "서울 여행 계획 중인 지방/해외 거주자. 유명 유튜버가 간 맛집을 찾고 싶음",
|
|
"prompt": """너는 서울 여행을 계획하고 있는 부산 거주 20대야.
|
|
유튜브에서 본 맛집들을 미리 정리해서 여행 코스를 짜고 싶어.
|
|
"이 동네에 갔을 때 뭘 먹을지" 지역별로 정리하는 게 중요해.
|
|
|
|
이 앱의 현재 상태를 보고, 너 같은 사용자 입장에서 리뷰해줘:
|
|
1. 특정 지역(예: 강남, 홍대)의 맛집을 한눈에 볼 수 있는가?
|
|
2. 지도에서 동선을 짜기 편한가? (근처 맛집 묶어보기)
|
|
3. 즐겨찾기로 "가볼 곳 리스트"를 만들기 편한가?
|
|
4. 예약 정보(테이블링, 캐치테이블 링크)가 잘 연결되어 있는가?
|
|
5. 구체적인 개선 제안 (우선순위 높은 것 3~5개)
|
|
|
|
한국어로 답변. 각 개선 제안은 [P0/P1/P2] 우선순위를 붙여줘."""
|
|
},
|
|
"beginner": {
|
|
"name": "앱 첫 방문자",
|
|
"emoji": "🆕",
|
|
"desc": "앱에 처음 들어온 사람. 이 앱이 뭔지, 어떻게 쓰는지 모름",
|
|
"prompt": """너는 친구가 보내준 링크를 타고 이 앱에 처음 들어온 사람이야.
|
|
이 앱이 뭘 하는 건지, 어떻게 쓰는 건지 전혀 모르는 상태야.
|
|
|
|
이 앱의 현재 상태를 보고, 첫 방문자 입장에서 리뷰해줘:
|
|
1. 첫 화면을 보고 3초 안에 "이 앱이 뭔지" 이해할 수 있는가?
|
|
2. 어디를 눌러야 하는지 직관적으로 알 수 있는가?
|
|
3. 회원가입/로그인 없이도 앱을 체험해볼 수 있는가?
|
|
4. 처음 사용자에게 혼란스럽거나 막히는 지점은?
|
|
5. 구체적인 개선 제안 (우선순위 높은 것 3~5개)
|
|
|
|
한국어로 답변. 각 개선 제안은 [P0/P1/P2] 우선순위를 붙여줘."""
|
|
},
|
|
"mobile": {
|
|
"name": "모바일 사용자",
|
|
"emoji": "📱",
|
|
"desc": "스마트폰으로만 앱을 사용. 지하철에서 맛집 검색하거나 현장에서 확인",
|
|
"prompt": """너는 항상 스마트폰으로만 이 앱을 쓰는 사용자야.
|
|
지하철에서 주말 맛집을 검색하거나, 현장에서 "내 주변 맛집"을 찾아볼 때 사용해.
|
|
한 손 조작이 중요하고, 지도 + 리스트를 빠르게 전환해야 해.
|
|
|
|
이 앱의 현재 프론트엔드 코드를 보고, 모바일 사용자 입장에서 리뷰해줘:
|
|
1. 모바일에서 터치 영역이 충분한가? (최소 44x44px)
|
|
2. 지도와 리스트 전환이 매끄러운가?
|
|
3. "내 주변" 기능이 현장에서 유용한가?
|
|
4. 바텀시트, 필터 등 모바일 UI가 네이티브 앱처럼 자연스러운가?
|
|
5. 구체적인 개선 제안 (우선순위 높은 것 3~5개)
|
|
|
|
한국어로 답변. 각 개선 제안은 [P0/P1/P2] 우선순위를 붙여줘."""
|
|
},
|
|
"power": {
|
|
"name": "파워 유저",
|
|
"emoji": "⚡",
|
|
"desc": "매주 맛집 탐방 후 리뷰/메모를 남기는 헤비 유저. 즐겨찾기 50개 이상",
|
|
"prompt": """너는 이 앱을 3개월째 매주 쓰는 파워 유저야.
|
|
즐겨찾기 50개 이상, 리뷰도 20개 넘게 남겼어. 영상관리 페이지도 잘 알아.
|
|
|
|
이 앱의 현재 상태를 보고, 파워 유저 입장에서 리뷰해줘:
|
|
1. 즐겨찾기가 많아졌을 때 관리가 편한가? (검색, 정렬, 폴더)
|
|
2. 내가 남긴 리뷰/메모를 다시 찾아보기 편한가?
|
|
3. 새로 추가된 식당이나 영상을 빠르게 확인할 수 있는가?
|
|
4. 반복 사용하면서 느끼는 불편함은?
|
|
5. 구체적인 개선 제안 (우선순위 높은 것 3~5개)
|
|
|
|
한국어로 답변. 각 개선 제안은 [P0/P1/P2] 우선순위를 붙여줘."""
|
|
},
|
|
"frontend_dev": {
|
|
"name": "프론트엔드 개발자",
|
|
"emoji": "💻",
|
|
"desc": "Next.js/React/TypeScript 전문 프론트엔드 개발자. 코드 품질과 성능에 민감",
|
|
"prompt": """너는 Next.js + TypeScript 경력 5년차 프론트엔드 개발자야.
|
|
코드 품질, 성능 최적화, 컴포넌트 설계에 까다로운 편이야.
|
|
|
|
이 앱의 프론트엔드 코드를 보고, 개발자 관점에서 리뷰해줘:
|
|
1. 컴포넌트 구조가 적절한가? (크기, 책임 분리, 재사용성)
|
|
2. 상태 관리가 효율적인가? (불필요한 리렌더링, prop drilling)
|
|
3. API 호출 패턴이 적절한가? (로딩/에러 처리, 캐싱, 중복 요청)
|
|
4. 성능 문제가 있는가? (큰 컴포넌트, 메모이제이션 부족, 번들 크기)
|
|
5. 구체적인 리팩토링/개선 제안 (우선순위 높은 것 3~5개)
|
|
|
|
한국어로 답변. 각 개선 제안은 [P0/P1/P2] 우선순위를 붙여줘."""
|
|
},
|
|
"backend_dev": {
|
|
"name": "백엔드 개발자",
|
|
"emoji": "⚙️",
|
|
"desc": "Spring Boot/MyBatis/Oracle 전문 백엔드 개발자. API 설계와 DB 성능에 민감",
|
|
"prompt": """너는 Spring Boot + MyBatis + Oracle DB 경력 7년차 백엔드 개발자야.
|
|
REST API 설계, DB 쿼리 최적화, 캐싱 전략에 까다로운 편이야.
|
|
|
|
이 앱의 백엔드 코드를 보고, 개발자 관점에서 리뷰해줘:
|
|
1. Controller/Service/Mapper 계층 분리가 적절한가?
|
|
2. MyBatis 쿼리에 성능 문제가 있는가? (N+1, 불필요한 JOIN, 인덱스 활용)
|
|
3. 에러 처리/예외 전략이 일관적인가?
|
|
4. Redis 캐싱 전략이 적절한가? (키 설계, TTL, 무효화)
|
|
5. 구체적인 리팩토링/개선 제안 (우선순위 높은 것 3~5개)
|
|
|
|
한국어로 답변. 각 개선 제안은 [P0/P1/P2] 우선순위를 붙여줘."""
|
|
},
|
|
"devops": {
|
|
"name": "DevOps 엔지니어",
|
|
"emoji": "🚀",
|
|
"desc": "CI/CD, 컨테이너, 모니터링 전문. 운영 안정성과 배포 자동화에 민감",
|
|
"prompt": """너는 Kubernetes + Docker + CI/CD 전문 DevOps 엔지니어야.
|
|
운영 안정성, 배포 자동화, 모니터링, 로깅에 까다로운 편이야.
|
|
|
|
이 앱의 인프라/배포 관련 코드를 보고, DevOps 관점에서 리뷰해줘:
|
|
1. 배포 프로세스가 안전하고 자동화되어 있는가?
|
|
2. 환경별 설정(dev/prod) 분리가 적절한가?
|
|
3. 로깅/모니터링이 장애 대응에 충분한가?
|
|
4. 컨테이너 이미지 빌드가 최적화되어 있는가? (레이어 캐싱, 이미지 크기)
|
|
5. 구체적인 개선 제안 (우선순위 높은 것 3~5개)
|
|
|
|
한국어로 답변. 각 개선 제안은 [P0/P1/P2] 우선순위를 붙여줘."""
|
|
},
|
|
}
|
|
|
|
# --- 앱 컨텍스트 수집 ---
|
|
|
|
FOCUS_PATHS = {
|
|
"home": [
|
|
"frontend/src/app/page.tsx",
|
|
"frontend/src/app/layout.tsx",
|
|
],
|
|
"map": [
|
|
"frontend/src/components/MapView.tsx",
|
|
"frontend/src/components/FilterSheet.tsx",
|
|
"frontend/src/components/RestaurantList.tsx",
|
|
],
|
|
"detail": [
|
|
"frontend/src/components/RestaurantDetail.tsx",
|
|
"frontend/src/components/ReviewSection.tsx",
|
|
"frontend/src/components/MemoSection.tsx",
|
|
],
|
|
"admin": [
|
|
"frontend/src/app/admin/page.tsx",
|
|
],
|
|
"api": [
|
|
"frontend/src/lib/api.ts",
|
|
],
|
|
"backend": [
|
|
"backend-java/src/main/java/com/tasteby/controller/RestaurantController.java",
|
|
"backend-java/src/main/java/com/tasteby/controller/VideoController.java",
|
|
"backend-java/src/main/java/com/tasteby/service/RestaurantService.java",
|
|
"backend-java/src/main/java/com/tasteby/service/VideoService.java",
|
|
"backend-java/src/main/java/com/tasteby/service/CacheService.java",
|
|
"backend-java/src/main/resources/mybatis/mapper/RestaurantMapper.xml",
|
|
],
|
|
"infra": [
|
|
"ecosystem.config.js",
|
|
"deploy.sh",
|
|
"backend-java/Dockerfile",
|
|
"frontend/Dockerfile",
|
|
"k8s/backend-deployment.yaml",
|
|
"k8s/frontend-deployment.yaml",
|
|
],
|
|
}
|
|
|
|
|
|
def collect_app_context(focus, persona_keys=None):
|
|
"""프론트엔드/백엔드 파일 읽어서 앱 상태 컨텍스트 생성"""
|
|
if focus == "all":
|
|
paths = []
|
|
for key in ["home", "map", "detail", "api"]:
|
|
paths.extend(FOCUS_PATHS[key])
|
|
# 개발자 페르소나면 백엔드/인프라도 포함
|
|
if persona_keys:
|
|
dev_personas = {"frontend_dev", "backend_dev", "devops"}
|
|
if dev_personas & set(persona_keys):
|
|
paths.extend(FOCUS_PATHS.get("backend", []))
|
|
paths.extend(FOCUS_PATHS.get("infra", []))
|
|
paths = list(dict.fromkeys(paths))
|
|
else:
|
|
paths = FOCUS_PATHS.get(focus, [])
|
|
|
|
context_parts = []
|
|
for rel_path in paths:
|
|
full_path = PROJECT_ROOT / rel_path
|
|
if full_path.exists():
|
|
try:
|
|
content = full_path.read_text()
|
|
if len(content) > 4000:
|
|
content = content[:4000] + "\n... (truncated)"
|
|
context_parts.append(f"=== {rel_path} ===\n{content}")
|
|
except Exception:
|
|
pass
|
|
|
|
# 디자인 가이드 포함
|
|
for doc in ["frontend/docs/design-concepts.md", "frontend/docs/brand-guide.md"]:
|
|
doc_path = PROJECT_ROOT / doc
|
|
if doc_path.exists():
|
|
try:
|
|
dg = doc_path.read_text()
|
|
if len(dg) > 2000:
|
|
dg = dg[:2000] + "\n... (truncated)"
|
|
context_parts.append(f"=== {doc} ===\n{dg}")
|
|
except Exception:
|
|
pass
|
|
|
|
return "\n\n".join(context_parts)
|
|
|
|
|
|
# --- LLM 호출 ---
|
|
|
|
def call_llm(system_prompt, user_content):
|
|
"""OpenRouter API 호출"""
|
|
import urllib.request
|
|
|
|
body = json.dumps({
|
|
"model": MODEL,
|
|
"messages": [
|
|
{"role": "system", "content": system_prompt},
|
|
{"role": "user", "content": user_content}
|
|
],
|
|
"max_tokens": 2000,
|
|
}).encode()
|
|
|
|
req = urllib.request.Request(API_URL, data=body, headers={
|
|
"Authorization": f"Bearer {OPENROUTER_API_KEY}",
|
|
"Content-Type": "application/json",
|
|
})
|
|
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=60) as resp:
|
|
data = json.loads(resp.read())
|
|
return data["choices"][0]["message"]["content"]
|
|
except Exception as e:
|
|
return f"[API 호출 실패] {e}"
|
|
|
|
|
|
# --- 개발자 토론 ---
|
|
|
|
DEV_PERSONA = {
|
|
"name": "풀스택 개발자",
|
|
"emoji": "🧑💻",
|
|
"prompt": """너는 이 프로젝트의 풀스택 개발자야.
|
|
Spring Boot + MyBatis + Oracle 백엔드와 Next.js + TypeScript 프론트엔드를 모두 다룰 수 있어.
|
|
현재 코드베이스를 잘 알고 있고, 무엇이 쉽고 어려운지 정확히 판단할 수 있어.
|
|
|
|
사용자 페르소나들이 아래와 같은 개선 제안을 했어.
|
|
각 제안에 대해 개발자 입장에서 대화하듯 응답해줘:
|
|
|
|
응답 형식 (각 제안마다):
|
|
- ✅ "좋은 생각이에요! 이건 OO 파일 수정하면 금방 됩니다" (쉬움, 1일 이내)
|
|
- ⚠️ "가능은 한데, OO 때문에 좀 복잡해요. N일 정도 걸릴 듯" (보통)
|
|
- 🔴 "이건 현재 구조에서 어려워요. OO를 먼저 바꿔야 합니다" (어려움/큰 작업)
|
|
- 💡 "그것보다 이렇게 하면 더 좋을 것 같아요" (대안 제시)
|
|
|
|
각 제안에 예상 작업량(시간/일)도 함께 알려줘.
|
|
한국어로, 실제 동료 개발자에게 말하듯 자연스럽게 답변."""
|
|
}
|
|
|
|
|
|
def dev_discussion(user_opinions, app_context):
|
|
"""사용자 페르소나 의견을 개발자가 검토하고 토론"""
|
|
opinions_text = "\n\n".join(
|
|
f"[{o['name']} {o['emoji']}]\n{o['opinion']}" for o in user_opinions
|
|
)
|
|
|
|
user_msg = f"""아래는 앱의 현재 코드 상태야:
|
|
|
|
{app_context[:5000]}
|
|
|
|
---
|
|
|
|
아래는 사용자 페르소나들의 개선 제안이야. 각 제안에 대해 실현 가능성을 판단해줘:
|
|
|
|
{opinions_text}"""
|
|
|
|
return call_llm(DEV_PERSONA["prompt"], user_msg)
|
|
|
|
|
|
# --- 종합 판정 ---
|
|
|
|
def synthesize(user_opinions, dev_response, app_context):
|
|
"""사용자 의견 + 개발자 판단을 종합해 최종 태스크 리스트 생성"""
|
|
system_prompt = """너는 PM(프로덕트 매니저)이야.
|
|
사용자 페르소나들의 요구사항과 개발자의 실현 가능성 판단을 종합해서
|
|
최종 실행 태스크 리스트를 만들어줘.
|
|
|
|
규칙:
|
|
- 개발자가 "쉽다"고 한 것 중 여러 페르소나가 원한 것 → [P0] (바로 하자)
|
|
- 개발자가 "가능하다"고 한 것 중 임팩트 큰 것 → [P1] (다음 스프린트)
|
|
- 개발자가 "어렵다"고 한 것 중 꼭 필요한 것 → [P2] (로드맵에 넣자)
|
|
- 개발자가 대안을 제시한 경우 대안으로 태스크 작성
|
|
- 각 태스크에 예상 작업량, 수정 대상 파일 포함
|
|
- 최대 15개 태스크
|
|
- 한국어로 작성"""
|
|
|
|
opinions_text = "\n\n".join(
|
|
f"[{o['name']} {o['emoji']}]\n{o['opinion']}" for o in user_opinions
|
|
)
|
|
|
|
user_msg = f"""사용자 의견:
|
|
{opinions_text}
|
|
|
|
---
|
|
|
|
개발자 검토:
|
|
{dev_response}"""
|
|
|
|
return call_llm(system_prompt, user_msg)
|
|
|
|
|
|
# --- 결과 저장 ---
|
|
|
|
def save_result(opinions, dev_response, summary, focus, model):
|
|
"""리뷰 결과를 reviews/ 디렉토리에 저장"""
|
|
REVIEWS_DIR.mkdir(exist_ok=True)
|
|
|
|
now = datetime.now(KST)
|
|
filename = f"persona-review-{now.strftime('%Y%m%d-%H%M')}.md"
|
|
filepath = REVIEWS_DIR / filename
|
|
|
|
lines = []
|
|
lines.append(f"# Tasteby 페르소나 UX 리뷰 — {now.strftime('%Y-%m-%d %H:%M')} KST")
|
|
lines.append("")
|
|
lines.append(f"- **모델**: {model}")
|
|
lines.append(f"- **리뷰 범위**: {focus}")
|
|
lines.append(f"- **페르소나**: {', '.join(o['name'] for o in opinions)}")
|
|
lines.append("")
|
|
lines.append("---")
|
|
lines.append("")
|
|
|
|
# 1단계: 사용자 리뷰
|
|
lines.append("# 1단계: 사용자 페르소나 리뷰")
|
|
lines.append("")
|
|
for o in opinions:
|
|
lines.append(f"## {o['emoji']} {o['name']}")
|
|
lines.append(f"> {PERSONAS[o['key']]['desc']}")
|
|
lines.append("")
|
|
lines.append(o["opinion"])
|
|
lines.append("")
|
|
lines.append("---")
|
|
lines.append("")
|
|
|
|
# 2단계: 개발자 토론
|
|
lines.append("# 2단계: 🧑💻 개발자 실현 가능성 검토")
|
|
lines.append("")
|
|
lines.append(dev_response)
|
|
lines.append("")
|
|
lines.append("---")
|
|
lines.append("")
|
|
|
|
# 3단계: 종합
|
|
lines.append("# 3단계: 📋 최종 태스크 리스트")
|
|
lines.append("")
|
|
lines.append(summary)
|
|
lines.append("")
|
|
|
|
filepath.write_text("\n".join(lines))
|
|
return filepath
|
|
|
|
|
|
def show_latest():
|
|
"""가장 최근 리뷰 파일 출력"""
|
|
if not REVIEWS_DIR.exists():
|
|
print("리뷰 기록이 없습니다.")
|
|
return
|
|
|
|
files = sorted(REVIEWS_DIR.glob("persona-review-*.md"))
|
|
if not files:
|
|
print("리뷰 기록이 없습니다.")
|
|
return
|
|
|
|
latest = files[-1]
|
|
print(f"최근 리뷰: {latest}\n")
|
|
print(latest.read_text())
|
|
|
|
|
|
# --- 메인 ---
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="Tasteby 페르소나 기반 UX 리뷰")
|
|
parser.add_argument("--persona", "-p", type=str,
|
|
help=f"특정 페르소나만 ({', '.join(PERSONAS.keys())})")
|
|
parser.add_argument("--focus", "-f", type=str, default="all",
|
|
choices=["all", "home", "map", "detail", "admin", "api", "backend", "infra"],
|
|
help="리뷰 대상 영역")
|
|
parser.add_argument("--model", "-m", type=str, help="사용할 모델")
|
|
parser.add_argument("--latest", "-l", action="store_true",
|
|
help="마지막 리뷰 결과 보기")
|
|
parser.add_argument("--list", action="store_true",
|
|
help="리뷰 기록 목록 보기")
|
|
args = parser.parse_args()
|
|
|
|
if args.latest:
|
|
show_latest()
|
|
return
|
|
|
|
if args.list:
|
|
if not REVIEWS_DIR.exists():
|
|
print("리뷰 기록이 없습니다.")
|
|
return
|
|
files = sorted(REVIEWS_DIR.glob("persona-review-*.md"))
|
|
if not files:
|
|
print("리뷰 기록이 없습니다.")
|
|
return
|
|
print(f"리뷰 기록 ({len(files)}건):\n")
|
|
for f in files:
|
|
print(f" {f.name}")
|
|
return
|
|
|
|
if args.model:
|
|
global MODEL
|
|
MODEL = args.model
|
|
|
|
if not OPENROUTER_API_KEY:
|
|
print("ERROR: OPENROUTER_API_KEY가 필요합니다")
|
|
print(" .env 파일에 OPENROUTER_API_KEY=sk-or-... 추가하거나")
|
|
print(" export OPENROUTER_API_KEY=sk-or-...")
|
|
sys.exit(1)
|
|
|
|
# 사용자 페르소나 분류 (개발자 페르소나는 제외 — 자동으로 토론에 참여)
|
|
USER_PERSONAS = {k: v for k, v in PERSONAS.items()
|
|
if k not in ("frontend_dev", "backend_dev", "devops")}
|
|
|
|
# 페르소나 선택
|
|
if args.persona:
|
|
if args.persona not in USER_PERSONAS:
|
|
print(f"ERROR: 알 수 없는 페르소나 '{args.persona}'")
|
|
print(f" 가능한 값: {', '.join(USER_PERSONAS.keys())}")
|
|
sys.exit(1)
|
|
selected = {args.persona: USER_PERSONAS[args.persona]}
|
|
else:
|
|
selected = USER_PERSONAS
|
|
|
|
# 앱 컨텍스트 수집 (개발자 토론을 위해 백엔드도 포함)
|
|
all_keys = list(selected.keys()) + ["backend_dev"]
|
|
print("=" * 60)
|
|
print(f" Tasteby 페르소나 UX 리뷰")
|
|
print("=" * 60)
|
|
print(f"\n 모델: {MODEL}")
|
|
print(f" 범위: {args.focus}")
|
|
print(f" 사용자 페르소나: {', '.join(p['name'] for p in selected.values())}")
|
|
print(f" 개발자: {DEV_PERSONA['emoji']} {DEV_PERSONA['name']} (자동 참여)")
|
|
print(f"\n 앱 컨텍스트 수집 중...")
|
|
|
|
app_context = collect_app_context(args.focus, all_keys)
|
|
print(f" 컨텍스트 크기: {len(app_context):,}자")
|
|
|
|
# ── 1단계: 사용자 페르소나 리뷰 ──
|
|
print(f"\n{'=' * 60}")
|
|
print(f" 1단계: 사용자 페르소나 리뷰")
|
|
print(f"{'=' * 60}\n")
|
|
|
|
opinions = []
|
|
for key, persona in selected.items():
|
|
print(f" {persona['emoji']} [{persona['name']}] 리뷰 중...")
|
|
user_msg = f"""아래는 유튜브 맛집 지도 서비스 'Tasteby'의 코드야.
|
|
유튜버들이 소개한 식당을 지도에 표시하고, 필터링/검색/즐겨찾기/리뷰를 제공하는 앱이야.
|
|
이 코드를 보고 앱의 현재 상태를 파악한 뒤 리뷰해줘.
|
|
|
|
{app_context}"""
|
|
result = call_llm(persona["prompt"], user_msg)
|
|
opinions.append({
|
|
"key": key,
|
|
"name": persona["name"],
|
|
"emoji": persona["emoji"],
|
|
"opinion": result,
|
|
})
|
|
print(f" 완료 ✓")
|
|
|
|
print(f"\n{'─' * 50}")
|
|
print(f" {persona['emoji']} {persona['name']}:")
|
|
print(f"{'─' * 50}")
|
|
for line in result.split("\n"):
|
|
print(f" {line}")
|
|
print()
|
|
|
|
# ── 2단계: 개발자 토론 ──
|
|
print(f"{'=' * 60}")
|
|
print(f" 2단계: {DEV_PERSONA['emoji']} 개발자 실현 가능성 검토")
|
|
print(f"{'=' * 60}\n")
|
|
|
|
print(f" {DEV_PERSONA['emoji']} [{DEV_PERSONA['name']}] 각 제안 검토 중...")
|
|
dev_response = dev_discussion(opinions, app_context)
|
|
print(f" 완료 ✓")
|
|
|
|
print(f"\n{'─' * 50}")
|
|
print(f" {DEV_PERSONA['emoji']} {DEV_PERSONA['name']}:")
|
|
print(f"{'─' * 50}")
|
|
for line in dev_response.split("\n"):
|
|
print(f" {line}")
|
|
print()
|
|
|
|
# ── 3단계: PM 종합 ──
|
|
print(f"{'=' * 60}")
|
|
print(f" 3단계: 📋 PM 종합 — 최종 태스크 리스트")
|
|
print(f"{'=' * 60}\n")
|
|
|
|
print(f" 📋 사용자 의견 + 개발자 판단 종합 중...")
|
|
summary = synthesize(opinions, dev_response, app_context)
|
|
|
|
print(f"\n{'─' * 50}")
|
|
print(f" 📋 최종 태스크 리스트:")
|
|
print(f"{'─' * 50}")
|
|
for line in summary.split("\n"):
|
|
print(f" {line}")
|
|
print()
|
|
|
|
# 저장
|
|
filepath = save_result(opinions, dev_response, summary, args.focus, MODEL)
|
|
print("=" * 60)
|
|
print(f" 💾 결과 저장: {filepath}")
|
|
print("=" * 60)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|