Weekly VOC analyst bot
- ์ํคํ ์ฒ
- ์ฌ์ ์๊ตฌ์ฌํญ
- ๋น ๋ฅธ ์์
- ์ฌ์ฉ ๊ฐ๋ฅํ ๋ช ๋ น์ด
- ํ๋ก์ ํธ ๊ตฌ์กฐ
- ๋ฌธ์
- ์๋ํฌ์ธํธ
- ์ค์
- VOC ์ฃผ๊ฐ ๋ชจ๋ํฐ๋ง ์ค๊ณ
- ์์ ๋ก์ง ์ถ๊ฐํ๊ธฐ
- Slack ๋ด ์ค์
- Slack ํธ๋ค๋ฌ ์ถ๊ฐํ๊ธฐ
- ๋ผ์ด์ ์ค
Litestar๋ก ๊ตฌ์ถ๋๊ณ Lambda Web Adapter๋ฅผ ์ฌ์ฉํ์ฌ AWS Lambda์ ๋ฐฐํฌ๋๋ ์๋ฒ๋ฆฌ์ค ์ ํ๋ฆฌ์ผ์ด์ ์ ๋๋ค.
๊ตฌ์ฑ ์์:
-
Web Function: Function URL์ ํตํด HTTP ์์ฒญ ์ฒ๋ฆฌ
-
Job Function: EventBridge Scheduler๋ก ์์ฝ๋ ์์ ์คํ
-
Slack Background Function: ์ฅ์๊ฐ Slack ์์ ์ ๋น๋๊ธฐ๋ก ์ฒ๋ฆฌ
-
Single Container Image: ๋ชจ๋ ํจ์๊ฐ ๋์ผํ Docker ์ด๋ฏธ์ง ๊ณต์
- uv - Python ํจํค์ง ๊ด๋ฆฌ์
- just - ์ปค๋งจ๋ ๋ฌ๋
- Docker - ์ปจํ ์ด๋ ์ด๋ฏธ์ง ๋น๋์ฉ
- ์ ์ ํ ์๊ฒฉ ์ฆ๋ช ์ด ๊ตฌ์ฑ๋ AWS CLI
uv sync --frozenjust servehttp://localhost:8080 ์์ ์ฑ์ ์ ๊ทผํ ์ ์์ต๋๋ค.
just create-roles# ์ปจํ
์ด๋ ์ด๋ฏธ์ง ๋น๋
just build
# AWS์ ๋ฐฐํฌ
just deploy
# ๋ฐฐํฌ๋ URL ํ์ธ
just url| ๋ช ๋ น์ด | ์ค๋ช |
|---|---|
just serve |
๋ก์ปฌ ๊ฐ๋ฐ ์๋ฒ ์คํ |
just build [tag] |
์ปจํ ์ด๋ ์ด๋ฏธ์ง ๋น๋ |
just deploy [tag] |
ECR์ ํธ์ํ๊ณ ์คํ ๋ฐฐํฌ |
just url |
๋ฐฐํฌ๋ Function URL ํ์ธ |
just invoke-job |
์์ฝ๋ ์์ ์๋ ์คํ |
just deployed-version |
๋ฐฐํฌ๋ ์ด๋ฏธ์ง ์ ๋ณด ํ์ |
just lock |
์์กด์ฑ lockfile ์ ๋ฐ์ดํธ |
just create-roles |
ํ์ํ IAM ์ญํ ์์ฑ (์ฒซ ๋ฐฐํฌ ์ 1ํ) |
just sync-env |
.env์ LAMBDA_* ๋ณ์๋ฅผ CloudFormation์ ๋๊ธฐํ |
์ฃผ๊ฐ VOC ๋ฉ์์ง๋ฅผ ๋ก์ปฌ์์ ๊ฐ์ ์คํํ์ฌ Slack ์ฑ๋๋ก ํ ์คํธ ์ ์กํฉ๋๋ค.
set -a
source .env
set +a
uv run python - <<'PY'
import asyncio
import os
from voc_analyst.jobs.voc_weekly import (
build_weekly_voc_report,
send_slack_notification,
_post_followups,
)
async def main():
channel = os.environ.get("VOC_SLACK_CHANNEL") or os.environ.get("LAMBDA_VOC_SLACK_CHANNEL")
if not channel:
raise SystemExit("VOC_SLACK_CHANNEL or LAMBDA_VOC_SLACK_CHANNEL is required")
report = await build_weekly_voc_report(force_run=True)
if report.get("status") != "ok":
print(report)
return
if report.get("changes", 0) == 0:
print({"status": "ok", "changes": 0})
return
thread_ts = await send_slack_notification(channel, report.get("blocks", []))
if thread_ts:
await _post_followups(
channel=channel,
thread_ts=thread_ts,
prev=report["prev"],
last=report["last"],
changes=report["changes_list"],
)
print({"status": "ok", "changes": report.get("changes", 0)})
asyncio.run(main())
PYํ์ ํ๊ฒฝ๋ณ์๋ .env์ ์ค์ ํฉ๋๋ค:
SLACK_BOT_TOKENVOC_SLACK_CHANNEL(๋๋LAMBDA_VOC_SLACK_CHANNEL)
voc_analyst/
โโโ src/voc_analyst/
โ โโโ __init__.py
โ โโโ app.py # Litestar ์ ํ๋ฆฌ์ผ์ด์
โ โโโ jobs/
โ โโโ __init__.py
โ โโโ runner.py # ์์ฝ ์์
ํธ๋ค๋ฌ
โ โโโ slack/
โ โโโ __init__.py
โ โโโ app.py # Slack Bolt ์ฑ ์ค์
โ โโโ handlers.py # ์ปค๋งจ๋ & ์ด๋ฒคํธ ํธ๋ค๋ฌ
โ โโโ background.py # ๋ฐฑ๊ทธ๋ผ์ด๋ ์์
ํ๋ก์ธ์
โโโ scripts/
โ โโโ create_roles.sh # IAM ์ญํ ์์ฑ ์คํฌ๋ฆฝํธ
โ โโโ sync_env.py # ํ๊ฒฝ๋ณ์ ๋๊ธฐํ ์คํฌ๋ฆฝํธ
โ โโโ ... # ๋น๋/๋ฐฐํฌ ์คํฌ๋ฆฝํธ
โโโ dot_env.example # ํ๊ฒฝ๋ณ์ ์์ ํ์ผ (cp dot_env.example .env)
โโโ docs/
โ โโโ SLACK_GUIDE.md # Slack ์๋ฆผ ํฌ๋งท ๋ฐ ์คํจ ๋์ ๊ฐ์ด๋
โโโ template.yaml # CloudFormation ์คํ
โโโ Dockerfile
โโโ pyproject.toml
โโโ justfile
- Slack ๋ด ๊ฐ์ด๋ - ๋ฉ์์ง ํฌ๋งท ์์, ์คํจ ๋์ ๋งค๋ด์ผ, ํธ๋ฌ๋ธ์ํ
| ์๋ํฌ์ธํธ | ๋ฉ์๋ | ์ค๋ช |
|---|---|---|
/ |
GET | ํฌ์ค ์ฒดํฌ |
/healthz |
GET | ๋ก๋ ๋ฐธ๋ฐ์ ํฌ์ค ์ฒดํฌ |
| /events | POST | EventBridge ์ด๋ฒคํธ ํธ๋ค๋ฌ (job ํจ์ ์ ์ฉ) |
| /slack/events | POST | Slack ์นํ
์๋ํฌ์ธํธ (์ปค๋งจ๋, ์ด๋ฒคํธ, ์ธํฐ๋์
) |
| /slack/background | POST | ๋ฐฑ๊ทธ๋ผ์ด๋ ์์
ํ๋ก์ธ์ (๋ด๋ถ ์ฌ์ฉ) |
| ๋ณ์ | ์ค๋ช | ๊ธฐ๋ณธ๊ฐ |
|---|---|---|
AWS_PROFILE |
AWS CLI ํ๋กํ | default |
AWS_REGION |
AWS ๋ฆฌ์ | ap-northeast-2 |
STACK_NAME |
CloudFormation ์คํ ์ด๋ฆ | voc_analyst |
| ํ๋ผ๋ฏธํฐ | ์ค๋ช |
|---|---|
ImageUri |
ECR ์ด๋ฏธ์ง ์ฐธ์กฐ |
WebFunctionRoleArn |
Web ํจ์์ฉ IAM ์ญํ |
| JobFunctionRoleArn | Job ํจ์์ฉ IAM ์ญํ |
| SchedulerRoleArn | EventBridge Scheduler์ฉ IAM ์ญํ |
| ScheduleExpression | ์์ฝ ์์
์ฉ Cron ํํ์ |
| SlackBotToken | Slack Bot User OAuth Token (xoxb-...) |
| SlackSigningSecret | ์์ฒญ ๊ฒ์ฆ์ฉ Slack Signing Secret |
| SlackBgFunctionRoleArn | Slack ๋ฐฑ๊ทธ๋ผ์ด๋ ํจ์์ฉ IAM ์ญํ |
์ฑ ๊ฐ๋ฐ ์ค ์๋ก์ด ํ๊ฒฝ๋ณ์(์: API ํค, ๋ฐ์ดํฐ๋ฒ ์ด์ค URL)๋ฅผ ์ถ๊ฐํ๋ ค๋ฉด:
1. .env ํ์ผ์ LAMBDA_ prefix๋ก ๋ณ์ ์ถ๊ฐ
# .env
LAMBDA_DATABASE_URL=postgresql://user:pass@host:5432/db
LAMBDA_OPENAI_API_KEY=sk-...
LAMBDA_FEATURE_FLAG=true2. ๋๊ธฐํ ์คํ
just sync-env์ด ๋ช ๋ น์ ์๋์ผ๋ก:
template.yaml์ CloudFormation ํ๋ผ๋ฏธํฐ ์ถ๊ฐ (์:DatabaseUrl)- Lambda ํจ์๋ค์ ํ๊ฒฝ๋ณ์์ ์ฐธ์กฐ ์ถ๊ฐ (์:
DATABASE_URL: !Ref DatabaseUrl) deploy_stack.sh์ ํ๋ผ๋ฏธํฐ ์ ๋ฌ ๋ก์ง ์ถ๊ฐ
3. ๋ฐฐํฌ
just build && just deploy๋ณํ ๊ท์น:
| .env ๋ณ์ | CFN ํ๋ผ๋ฏธํฐ | Lambda ํ๊ฒฝ๋ณ์ |
|---|---|---|
LAMBDA_DATABASE_URL |
DatabaseUrl |
DATABASE_URL |
LAMBDA_OPENAI_API_KEY |
OpenaiApiKey |
OPENAI_API_KEY |
- ํ
์ด๋ธ:
wanted-data.wanted_ml.zendesk_voc_classified - ์ง๊ณ: ์ฃผ์ฐจ๋ณ(category1/2/3) VOC ์ด๋๊ณผ ๋ถ์ ๊ฑด์
- ๋ถ์ ์ ์:
overall_emotion = '๋ถ์ '
- CRITICAL: (์ฆ๊ฐโฅ30% ๋๋ ๋ถ์ ๋น์จ+20%p) & ๋น๊ต์ฃผ ๋๋ ๊ธฐ์ค์ฃผ VOCโฅ20
- MONITOR: (์ฆ๊ฐโฅ20% ๋๋ ๋ถ์ ๋น์จ+10%p) & ๋น๊ต์ฃผ ๋๋ ๊ธฐ์ค์ฃผ VOCโฅ10
- IMPROVED: ๊ฐ์โฅ20% & ๋น๊ต์ฃผ ๋๋ ๊ธฐ์ค์ฃผ VOCโฅ10
- STABLE: ๊ทธ ์ธ
- ์์์ผ ์ค์ผ์ค ์คํ โ ์์ฝ ๋ฉ์์ง ์ ์ก
- ๋ฉ์ ์์ฒญ ์ ๋์ผํ ์์ฝ ๋ฉ์์ง ์ ์ก
- CRITICAL/MONITOR ํญ๋ชฉ์ ์ค๋ ๋๋ก ํ์ ๋ถ์ ๋ฉ์์ง ์ ์ก (LaaS ํ๋ฆฌ์ )
- ์๋ํฌ์ธํธ:
/api/preset/v2/chat/completions - ํ๋ฆฌ์
ํด์:
90571f07e6b60e047620162ecc29b423dba8280aba60dba503aac082082ad0c4 - ์ ๋ ฅ: ์ฃผ์ฐจ๋ณ ์ํ(๋น๊ต ์ฃผ / ๊ธฐ์ค ์ฃผ) + ๋ณํ ์์ฝ
- ์ถ๋ ฅ: ์์ฝ / ๋ํ ์์ / ์์ธ ๊ฐ์ค / ํ์ ์กฐ์น
src/voc_analyst/jobs/runner.py ํ์ผ์ ์์ ํฉ๋๋ค:
async def run_scheduled_job(event: dict[str, Any]) -> dict[str, Any]:
# ์์ฝ ์์
๋ก์ง์ ์ฌ๊ธฐ์ ์์ฑ
# ์์:
# - ์ธ๋ถ API์์ ๋ฐ์ดํฐ ๊ฐ์ ธ์ค๊ธฐ
# - ๋ฐ์ดํฐ ์ฒ๋ฆฌ ๋ฐ ๋ณํ
# - ์๋ฆผ ์ ์ก
# - ๋ฐ์ดํฐ๋ฒ ์ด์ค ๋ ์ฝ๋ ์
๋ฐ์ดํธ
return {"status": "ok"}- Slack API Apps๋ก ์ด๋
- Create New App โ From scratch ํด๋ฆญ
- ์ฑ ์ด๋ฆ ์ ๋ ฅ ๋ฐ ์ํฌ์คํ์ด์ค ์ ํ
OAuth & Permissions๋ก ์ด๋ํ์ฌ Bot Token Scopes ์ถ๊ฐ:
| Scope | ์ค๋ช |
|---|---|
chat:write |
๋ฉ์์ง ์ ์ก |
commands |
์ฌ๋์ ์ปค๋งจ๋ ์ถ๊ฐ |
app_mentions:read |
@๋ฉ์ ์์ |
im:history |
DM ๋ฉ์์ง ์ฝ๊ธฐ |
im:write |
DM ์ ์ก |
- Event Subscriptions๋ก ์ด๋
- Enable Events๋ฅผ On์ผ๋ก ํ ๊ธ
- Request URL ์ค์ :
{Function URL}slack/events - ๋ด ์ด๋ฒคํธ ๊ตฌ๋
:
app_mentionmessage.im
Slash Commands โ Create New Command๋ก ์ด๋:
| ์ปค๋งจ๋ | Request URL | ์ค๋ช |
|---|---|---|
/hello |
{Function URL}slack/events |
์ธ์ฌ ์ปค๋งจ๋ ์์ |
/longtask |
{Function URL}slack/events |
๋ฐฑ๊ทธ๋ผ์ด๋ ์์ ์์ |
- Install App์ผ๋ก ์ด๋
- Install to Workspace ํด๋ฆญ
- Bot User OAuth Token ๋ณต์ฌ (
xoxb-๋ก ์์)
- Basic Information์ผ๋ก ์ด๋
- Signing Secret ๋ณต์ฌ
ํ๊ฒฝ๋ณ์๋ก Slack ํ ํฐ์ ์ค์ ํ ํ ๋ฐฐํฌํฉ๋๋ค:
# ํ๊ฒฝ๋ณ์ ์ค์ ํ ๋ฐฐํฌ
export SLACK_BOT_TOKEN=xoxb-your-token
export SLACK_SIGNING_SECRET=your-signing-secret
just deploy๋๋ ํ ์ค๋ก:
SLACK_BOT_TOKEN=xoxb-... SLACK_SIGNING_SECRET=... just deploysrc/voc_analyst/slack/handlers.py ํ์ผ์ ์์ ํฉ๋๋ค:
@slack_app.command("/mycommand")
def handle_my_command(ack: Ack, command: dict, say: Say) -> None:
ack() # 3์ด ์ด๋ด์ ์๋ต
say(f"์๋
ํ์ธ์ <@{command['user_id']}>!")@slack_app.event("app_mention")
def handle_mention(event: dict, say: Say) -> None:
say(f"๋ฉ์
ํ์
จ๋ค์: {event.get('text')}")3์ด๋ฅผ ์ด๊ณผํ๋ ์์ ์ ๋ฐฑ๊ทธ๋ผ์ด๋ ์ฒ๋ฆฌ๋ฅผ ์ฌ์ฉํฉ๋๋ค:
@slack_app.command("/slow-task")
def handle_slow_task(ack: Ack, command: dict) -> None:
ack("์ฒ๋ฆฌ ์ค... :hourglass:") # ์ฆ์ ์๋ต
# ๋ฐฑ๊ทธ๋ผ์ด๋ Lambda๋ก ์คํ๋ก๋
invoke_background(
task_type="my_task",
payload={
"user_id": command["user_id"],
"channel_id": command["channel_id"],
"response_url": command["response_url"],
},
)๊ทธ๋ฐ ๋ค์ background.py์ ํธ๋ค๋ฌ๋ฅผ ์ถ๊ฐํฉ๋๋ค:
async def _handle_my_task(payload: dict) -> dict[str, Any]:
# ์ฅ์๊ฐ ์คํ ๋ก์ง (์ต๋ 2๋ถ)
result = await some_slow_operation()
# response_url์ ํตํด ์๋ต ์ ์ก
async with httpx.AsyncClient() as client:
await client.post(payload["response_url"], json={"text": result})
return {"status": "ok"}MIT