ch-bootstrap: persona pipeline + Design-First + 안전-최대 권한
- 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
This commit is contained in:
316
scripts/code_review.py
Executable file
316
scripts/code_review.py
Executable file
@@ -0,0 +1,316 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Tasteby 페르소나 기반 코드 리뷰
|
||||
4개 페르소나(프론트엔드, 백엔드, 보안, 아키텍처)가 git diff를 리뷰하고 최종 권고를 출력.
|
||||
|
||||
사용법:
|
||||
# 커밋되지 않은 변경 리뷰
|
||||
python3 scripts/code_review.py
|
||||
|
||||
# 최근 N개 커밋 리뷰
|
||||
python3 scripts/code_review.py --commits 3
|
||||
|
||||
# 특정 영역만 리뷰 (frontend / backend / all)
|
||||
python3 scripts/code_review.py --scope frontend
|
||||
python3 scripts/code_review.py --scope backend
|
||||
|
||||
# 특정 커밋 범위 리뷰
|
||||
python3 scripts/code_review.py --range abc1234..def5678
|
||||
"""
|
||||
import subprocess, json, os, sys, argparse
|
||||
from pathlib import Path
|
||||
|
||||
# .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"
|
||||
|
||||
# --- 페르소나 정의 ---
|
||||
|
||||
PERSONAS = {
|
||||
"frontend": [
|
||||
{
|
||||
"name": "프론트엔드 UX 전문가",
|
||||
"emoji": "\U0001f3a8",
|
||||
"prompt": """너는 Next.js/React 전문 프론트엔드 엔지니어야.
|
||||
이 코드 변경에서 UX, 성능, 접근성 문제를 찾아내.
|
||||
특히:
|
||||
- 불필요한 리렌더링이나 useEffect 남용은 없는지
|
||||
- 로딩/에러 상태 처리가 빠져있는지
|
||||
- 모바일 반응형이 깨질 수 있는 레이아웃 변경은 없는지
|
||||
- Tailwind CSS 클래스 사용이 적절한지
|
||||
- 네이버 지도 API 관련 변경이 올바른지
|
||||
한국어로 3줄 이내로 핵심만 답변."""
|
||||
},
|
||||
{
|
||||
"name": "타입스크립트 품질 검사관",
|
||||
"emoji": "\U0001f9d0",
|
||||
"prompt": """너는 TypeScript 타입 안전성과 코드 품질만 보는 엄격한 리뷰어야.
|
||||
이 코드 변경에서 타입 안전성과 코드 품질 문제를 찾아내.
|
||||
특히:
|
||||
- any 타입 사용이나 타입 단언(as) 남용은 없는지
|
||||
- API 응답 타입이 제대로 정의되어 있는지
|
||||
- null/undefined 처리가 빠져있는지
|
||||
- 컴포넌트 props 인터페이스가 명확한지
|
||||
한국어로 3줄 이내로 핵심만 답변."""
|
||||
},
|
||||
],
|
||||
"backend": [
|
||||
{
|
||||
"name": "백엔드 API 설계자",
|
||||
"emoji": "\u2699\ufe0f",
|
||||
"prompt": """너는 Spring Boot + MyBatis 전문 백엔드 엔지니어야.
|
||||
이 코드 변경에서 API 설계, 데이터 접근, 성능 문제를 찾아내.
|
||||
특히:
|
||||
- MyBatis SQL에 N+1 쿼리나 비효율적인 조인은 없는지
|
||||
- Oracle DB 특성(인덱스, CLOB 처리, ROWNUM)을 고려했는지
|
||||
- REST API 설계가 일관적인지 (네이밍, HTTP 메서드, 상태 코드)
|
||||
- Redis 캐싱 전략이 적절한지 (TTL, 키 설계, 무효화)
|
||||
- ClobTypeHandler와 @JsonRawValue 사용이 올바른지
|
||||
한국어로 3줄 이내로 핵심만 답변."""
|
||||
},
|
||||
{
|
||||
"name": "보안 감사관",
|
||||
"emoji": "\U0001f6e1\ufe0f",
|
||||
"prompt": """너는 OWASP Top 10을 기준으로 보안 취약점만 찾는 보안 전문가야.
|
||||
이 코드 변경에서 보안 위험 요소를 찾아내.
|
||||
특히:
|
||||
- SQL Injection 가능성 (MyBatis ${} 사용 여부)
|
||||
- XSS 취약점 (사용자 입력이 그대로 렌더링되는지)
|
||||
- 민감 정보 노출 (API 키, DB 비밀번호, 에러 스택트레이스)
|
||||
- CORS 설정이 너무 개방적이지 않은지
|
||||
- JWT/인증 토큰 처리가 안전한지
|
||||
- 입력값 검증이 빠져있는지
|
||||
한국어로 3줄 이내로 핵심만 답변."""
|
||||
},
|
||||
],
|
||||
"common": [
|
||||
{
|
||||
"name": "아키텍처 리뷰어",
|
||||
"emoji": "\U0001f3d7\ufe0f",
|
||||
"prompt": """너는 전체 시스템 아키텍처를 보는 테크 리드야.
|
||||
이 코드 변경이 전체 아키텍처에 미치는 영향을 평가해.
|
||||
특히:
|
||||
- 프론트엔드-백엔드 간 API 계약이 맞는지
|
||||
- 관심사 분리가 제대로 되어 있는지 (컨트롤러/서비스/매퍼)
|
||||
- 기존 코드 패턴과 일관성이 있는지
|
||||
- 확장성이나 유지보수성을 해치는 변경은 없는지
|
||||
한국어로 3줄 이내로 핵심만 답변."""
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
PROJECT_CONTEXT = """
|
||||
[프로젝트 컨텍스트 — Tasteby]
|
||||
- YouTube 맛집 영상에서 식당 정보를 추출하여 지도에 표시하는 서비스
|
||||
- 스택: Spring Boot 3.3.5 + Java 21 + MyBatis + Oracle 23ai + Redis + Next.js 16 + TypeScript + Tailwind CSS
|
||||
- 프론트엔드: Next.js App Router, TypeScript, Tailwind CSS, 네이버 지도 API
|
||||
- 백엔드: Spring Boot REST API, MyBatis XML 매퍼, Oracle ADB, Redis 캐싱
|
||||
- 주요 패턴: Controller(thin) -> Service(비즈니스 로직) -> Mapper(MyBatis XML)
|
||||
- 도메인: Lombok @Data/@Builder, Jackson SNAKE_CASE, CLOB은 ClobTypeHandler
|
||||
- UUID 생성: IdGenerator.newId() (32자 대문자 hex)
|
||||
- 인증: JWT 토큰 (AuthUtil.requireAdmin())
|
||||
|
||||
[리뷰 시 참고]
|
||||
- MyBatis에서 #{} 는 안전(PreparedStatement), ${} 는 SQL Injection 위험
|
||||
- Oracle CLOB 필드는 ClobTypeHandler + @JsonRawValue 조합으로 처리
|
||||
- Oracle 대문자 컬럼명 → JsonUtil.lowerKeys()로 변환
|
||||
- resultType="map" 쿼리 시 서비스 레이어에서 JsonUtil.lowerKeys() 필수
|
||||
- UserInfo.isAdmin은 @JsonProperty("is_admin") 필요 (JavaBeans boolean 네이밍)
|
||||
- CORS 설정: WebConfig.java에서 관리, 새 HTTP 메서드 추가 시 allowedMethods 확인
|
||||
"""
|
||||
|
||||
|
||||
def get_diff(args):
|
||||
"""변경사항 diff 가져오기"""
|
||||
if args.range:
|
||||
cmd = ["git", "diff", args.range]
|
||||
elif args.commits:
|
||||
cmd = ["git", "diff", f"HEAD~{args.commits}", "HEAD"]
|
||||
else:
|
||||
diff = subprocess.run(["git", "diff", "HEAD"], capture_output=True, text=True).stdout
|
||||
if not diff.strip():
|
||||
diff = subprocess.run(["git", "diff", "HEAD~1", "HEAD"], capture_output=True, text=True).stdout
|
||||
return diff
|
||||
|
||||
return subprocess.run(cmd, capture_output=True, text=True).stdout
|
||||
|
||||
|
||||
def filter_diff_by_scope(diff, scope):
|
||||
"""scope에 따라 diff 필터링"""
|
||||
if scope == "all":
|
||||
return diff
|
||||
|
||||
lines = diff.split("\n")
|
||||
filtered = []
|
||||
include = False
|
||||
|
||||
for line in lines:
|
||||
if line.startswith("diff --git"):
|
||||
if scope == "frontend":
|
||||
include = "frontend/" in line
|
||||
elif scope == "backend":
|
||||
include = "backend-java/" in line
|
||||
if include:
|
||||
filtered.append(line)
|
||||
|
||||
return "\n".join(filtered)
|
||||
|
||||
|
||||
def get_changed_files(args):
|
||||
"""변경된 파일 목록"""
|
||||
if args.range:
|
||||
cmd = ["git", "diff", "--name-only", args.range]
|
||||
elif args.commits:
|
||||
cmd = ["git", "diff", "--name-only", f"HEAD~{args.commits}", "HEAD"]
|
||||
else:
|
||||
result = subprocess.run(["git", "diff", "--name-only", "HEAD"], capture_output=True, text=True).stdout.strip()
|
||||
if not result:
|
||||
result = subprocess.run(["git", "diff", "--name-only", "HEAD~1", "HEAD"], capture_output=True, text=True).stdout.strip()
|
||||
return result
|
||||
|
||||
return subprocess.run(cmd, capture_output=True, text=True).stdout.strip()
|
||||
|
||||
|
||||
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": 500,
|
||||
}).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=30) as resp:
|
||||
data = json.loads(resp.read())
|
||||
return data["choices"][0]["message"]["content"]
|
||||
except Exception as e:
|
||||
return f"[API 호출 실패] {e}"
|
||||
|
||||
|
||||
def select_personas(scope, files):
|
||||
"""변경 파일에 따라 적절한 페르소나 선택"""
|
||||
personas = list(PERSONAS["common"])
|
||||
|
||||
has_frontend = any(f.startswith("frontend/") for f in files.split("\n")) if files else False
|
||||
has_backend = any(f.startswith("backend-java/") for f in files.split("\n")) if files else False
|
||||
|
||||
if scope == "frontend" or (scope == "all" and has_frontend):
|
||||
personas = PERSONAS["frontend"] + personas
|
||||
if scope == "backend" or (scope == "all" and has_backend):
|
||||
personas = PERSONAS["backend"] + personas
|
||||
if scope == "all" and not has_frontend and not has_backend:
|
||||
personas = PERSONAS["frontend"] + PERSONAS["backend"] + personas
|
||||
|
||||
return personas
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Tasteby 페르소나 기반 코드 리뷰")
|
||||
parser.add_argument("--commits", "-n", type=int, help="최근 N개 커밋 리뷰")
|
||||
parser.add_argument("--range", "-r", type=str, help="커밋 범위 (예: abc..def)")
|
||||
parser.add_argument("--scope", "-s", choices=["frontend", "backend", "all"], default="all",
|
||||
help="리뷰 범위 (frontend/backend/all)")
|
||||
parser.add_argument("--model", "-m", type=str, help="사용할 모델 (기본: anthropic/claude-haiku-4-5)")
|
||||
args = parser.parse_args()
|
||||
|
||||
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)
|
||||
|
||||
diff = get_diff(args)
|
||||
if not diff.strip():
|
||||
print("변경사항이 없습니다.")
|
||||
sys.exit(0)
|
||||
|
||||
diff = filter_diff_by_scope(diff, args.scope)
|
||||
if not diff.strip():
|
||||
print(f"'{args.scope}' 범위에 해당하는 변경사항이 없습니다.")
|
||||
sys.exit(0)
|
||||
|
||||
files = get_changed_files(args)
|
||||
personas = select_personas(args.scope, files)
|
||||
|
||||
max_diff = 8000
|
||||
truncated = len(diff) > max_diff
|
||||
diff_for_review = diff[:max_diff]
|
||||
|
||||
print("=" * 60)
|
||||
print(f" Tasteby 코드 리뷰 ({args.scope})")
|
||||
print("=" * 60)
|
||||
print(f"\n 모델: {MODEL}")
|
||||
print(f" 변경 파일:\n")
|
||||
for f in files.split("\n"):
|
||||
if f.strip():
|
||||
if f.startswith("frontend/"):
|
||||
tag = "[FE]"
|
||||
elif f.startswith("backend-java/"):
|
||||
tag = "[BE]"
|
||||
else:
|
||||
tag = "[--]"
|
||||
print(f" {tag} {f}")
|
||||
if truncated:
|
||||
print(f"\n (diff가 {max_diff}자로 잘림, 원본 {len(diff)}자)")
|
||||
print()
|
||||
|
||||
opinions = []
|
||||
for p in personas:
|
||||
print(f" {p['emoji']} [{p['name']}] 리뷰 중...")
|
||||
user_msg = f"{PROJECT_CONTEXT}\n\n아래 코드 변경을 리뷰해줘:\n\n```diff\n{diff_for_review}\n```"
|
||||
result = call_llm(p["prompt"], user_msg)
|
||||
opinions.append({"name": p["name"], "emoji": p["emoji"], "opinion": result})
|
||||
print(f" {result}\n")
|
||||
|
||||
print("=" * 60)
|
||||
summary_prompt = """너는 프론트엔드/백엔드 리뷰어들의 의견을 종합하는 테크 리드야.
|
||||
각 의견을 보고 최종 권고를 내려줘:
|
||||
- PASS: 문제 없음, 배포 가능
|
||||
- WARN: 사소한 이슈 있지만 배포 가능, 후속 작업 필요
|
||||
- FAIL: 수정 필요, 배포 전 반드시 고쳐야 함
|
||||
한국어로 최종 판정 1줄 + 이유/액션 아이템 2-3줄로 답변."""
|
||||
|
||||
opinions_text = "\n\n".join(
|
||||
f"[{o['name']}]\n{o['opinion']}" for o in opinions
|
||||
)
|
||||
|
||||
print(f"\n [최종 권고]")
|
||||
final = call_llm(
|
||||
summary_prompt,
|
||||
f"{PROJECT_CONTEXT}\n\n변경사항 요약:\n{diff_for_review[:3000]}\n\n리뷰 의견:\n{opinions_text}"
|
||||
)
|
||||
print(f" {final}")
|
||||
print("\n" + "=" * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
21
scripts/enqueue.sh
Executable file
21
scripts/enqueue.sh
Executable file
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env bash
|
||||
# 새 작업을 파이프라인 큐에 투입 = 01-Planner/신규 Redmine 이슈 생성.
|
||||
# 사용법: ./scripts/enqueue.sh "제목" ["요구사항"]
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
set -a; . ./.env; set +a
|
||||
SUBJECT="${1:?사용법: enqueue.sh \"제목\" [\"요구\"]}"; BODY="${2:-}"
|
||||
PLANNER=$(curl -s -H "X-Redmine-API-Key: $REDMINE_API_KEY" \
|
||||
"$REDMINE_URL/projects/$REDMINE_PROJECT/issue_categories.json" \
|
||||
| python3 -c "import sys,json;[print(c['id']) for c in json.load(sys.stdin)['issue_categories'] if c['name']=='01-Planner']")
|
||||
DESC=$(printf '## [AI] Planner\n\n(요구사항)\n%s\n\n---\nWorking dir: %s' "$BODY" "$(pwd)")
|
||||
python3 - "$REDMINE_URL" "$REDMINE_API_KEY" "$REDMINE_PROJECT" "$SUBJECT" "$DESC" "$PLANNER" <<'PY'
|
||||
import sys,json,urllib.request
|
||||
base,key,proj,subject,desc,cat=sys.argv[1:7]
|
||||
p={"issue":{"project_id":proj,"tracker_id":2,"subject":subject,"description":desc,
|
||||
"category_id":int(cat),"status_id":1}}
|
||||
r=urllib.request.Request(base+"/issues.json",data=json.dumps(p).encode(),
|
||||
headers={"X-Redmine-API-Key":key,"Content-Type":"application/json"},method="POST")
|
||||
i=json.load(urllib.request.urlopen(r))["issue"]
|
||||
print(f"enqueued #{i['id']}: {i['subject']} -> 01-Planner/신규")
|
||||
PY
|
||||
586
scripts/persona_review.py
Executable file
586
scripts/persona_review.py
Executable file
@@ -0,0 +1,586 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user