Skip to content

wanteddev/voc-analyst

Folders and files

NameName
Last commit message
Last commit date

Latest commit

ย 

History

9 Commits
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 

Repository files navigation

VOC Analyst

Weekly VOC analyst bot

๋ชฉ์ฐจ

์•„ํ‚คํ…์ฒ˜

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

๋น ๋ฅธ ์‹œ์ž‘

1. ์˜์กด์„ฑ ์„ค์น˜

uv sync --frozen

2. ๋กœ์ปฌ ์‹คํ–‰

just serve

http://localhost:8080 ์—์„œ ์•ฑ์— ์ ‘๊ทผํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

3. IAM ์—ญํ•  ์ƒ์„ฑ (์ฒซ ๋ฐฐํฌ ์‹œ 1ํšŒ)

just create-roles

4. ๋นŒ๋“œ ๋ฐ ๋ฐฐํฌ

# ์ปจํ…Œ์ด๋„ˆ ์ด๋ฏธ์ง€ ๋นŒ๋“œ
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_TOKEN
  • VOC_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

CloudFormation ํŒŒ๋ผ๋ฏธํ„ฐ

ํŒŒ๋ผ๋ฏธํ„ฐ ์„ค๋ช…
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 ์—ญํ•  |

Lambda ํ™˜๊ฒฝ๋ณ€์ˆ˜ ์ถ”๊ฐ€ํ•˜๊ธฐ

์•ฑ ๊ฐœ๋ฐœ ์ค‘ ์ƒˆ๋กœ์šด ํ™˜๊ฒฝ๋ณ€์ˆ˜(์˜ˆ: API ํ‚ค, ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค URL)๋ฅผ ์ถ”๊ฐ€ํ•˜๋ ค๋ฉด:

1. .env ํŒŒ์ผ์— LAMBDA_ prefix๋กœ ๋ณ€์ˆ˜ ์ถ”๊ฐ€

# .env
LAMBDA_DATABASE_URL=postgresql://user:pass@host:5432/db
LAMBDA_OPENAI_API_KEY=sk-...
LAMBDA_FEATURE_FLAG=true

2. ๋™๊ธฐํ™” ์‹คํ–‰

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

VOC ์ฃผ๊ฐ„ ๋ชจ๋‹ˆํ„ฐ๋ง ์„ค๊ณ„

๋ฐ์ดํ„ฐ ์†Œ์Šค (BigQuery)

  • ํ…Œ์ด๋ธ”: 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: ๊ทธ ์™ธ

Slack ์•Œ๋ฆผ ํ๋ฆ„

  1. ์›”์š”์ผ ์Šค์ผ€์ค„ ์‹คํ–‰ โ†’ ์š”์•ฝ ๋ฉ”์‹œ์ง€ ์ „์†ก
  2. ๋ฉ˜์…˜ ์š”์ฒญ ์‹œ ๋™์ผํ•œ ์š”์•ฝ ๋ฉ”์‹œ์ง€ ์ „์†ก
  3. CRITICAL/MONITOR ํ•ญ๋ชฉ์€ ์Šค๋ ˆ๋“œ๋กœ ํ›„์† ๋ถ„์„ ๋ฉ”์‹œ์ง€ ์ „์†ก (LaaS ํ”„๋ฆฌ์…‹)

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 ๋ด‡ ์„ค์ •

1. Slack ์•ฑ ์ƒ์„ฑ

  1. Slack API Apps๋กœ ์ด๋™
  2. Create New App โ†’ From scratch ํด๋ฆญ
  3. ์•ฑ ์ด๋ฆ„ ์ž…๋ ฅ ๋ฐ ์›Œํฌ์ŠคํŽ˜์ด์Šค ์„ ํƒ

2. ๋ด‡ ๊ถŒํ•œ ์„ค์ •

OAuth & Permissions๋กœ ์ด๋™ํ•˜์—ฌ Bot Token Scopes ์ถ”๊ฐ€:

Scope ์„ค๋ช…
chat:write ๋ฉ”์‹œ์ง€ ์ „์†ก
commands ์Šฌ๋ž˜์‹œ ์ปค๋งจ๋“œ ์ถ”๊ฐ€
app_mentions:read @๋ฉ˜์…˜ ์ˆ˜์‹ 
im:history DM ๋ฉ”์‹œ์ง€ ์ฝ๊ธฐ
im:write DM ์ „์†ก

3. ์ด๋ฒคํŠธ ํ™œ์„ฑํ™”

  1. Event Subscriptions๋กœ ์ด๋™
  2. Enable Events๋ฅผ On์œผ๋กœ ํ† ๊ธ€
  3. Request URL ์„ค์ •: {Function URL}slack/events
  4. ๋ด‡ ์ด๋ฒคํŠธ ๊ตฌ๋…:
    • app_mention
    • message.im

4. ์Šฌ๋ž˜์‹œ ์ปค๋งจ๋“œ ์ถ”๊ฐ€

Slash Commands โ†’ Create New Command๋กœ ์ด๋™:

์ปค๋งจ๋“œ Request URL ์„ค๋ช…
/hello {Function URL}slack/events ์ธ์‚ฌ ์ปค๋งจ๋“œ ์˜ˆ์‹œ
/longtask {Function URL}slack/events ๋ฐฑ๊ทธ๋ผ์šด๋“œ ์ž‘์—… ์˜ˆ์‹œ

5. ์›Œํฌ์ŠคํŽ˜์ด์Šค์— ์„ค์น˜

  1. Install App์œผ๋กœ ์ด๋™
  2. Install to Workspace ํด๋ฆญ
  3. Bot User OAuth Token ๋ณต์‚ฌ (xoxb-๋กœ ์‹œ์ž‘)

6. Signing Secret ํ™•์ธ

  1. Basic Information์œผ๋กœ ์ด๋™
  2. Signing Secret ๋ณต์‚ฌ

7. Slack ์ž๊ฒฉ ์ฆ๋ช…์œผ๋กœ ๋ฐฐํฌ

ํ™˜๊ฒฝ๋ณ€์ˆ˜๋กœ 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 deploy

Slack ํ•ธ๋“ค๋Ÿฌ ์ถ”๊ฐ€ํ•˜๊ธฐ

์Šฌ๋ž˜์‹œ ์ปค๋งจ๋“œ

src/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

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors