Skip to content

Commit 86931e4

Browse files
Merge pull request #108 from AUSG/feat/subin-like-auto-reply
김수빈님의 말투를 따라하는 자동 reply 기능
2 parents 6b45d57 + 1bf5374 commit 86931e4

5 files changed

Lines changed: 214 additions & 5 deletions

File tree

src/anna.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
abandon_bigchat,
1010
announce_new_channel_created,
1111
mention_response,
12+
subin_like_response,
1213
)
1314

1415
init_logger()
@@ -40,6 +41,12 @@ def handle_channel_created_event(ack, event, say, client):
4041
announce_new_channel_created(event=event, say=say, client=client)
4142

4243

44+
@app.event("message")
45+
def handle_message_event(ack, event, say, client):
46+
ack()
47+
subin_like_response(event=event, say=say, client=client)
48+
49+
4350
if __name__ == "__main__":
4451
PORT = 8080
4552
logging.getLogger(__name__).info("Anna wakes up at room %d", PORT)
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
# fun-anna-house 자동 답글: 한국어 페르소나 프롬프트가 길어 줄길이(E501) 검사를 예외 처리한다.
2+
# ruff: noqa: E501
3+
import base64
4+
import logging
5+
import re
6+
7+
logger = logging.getLogger(__name__)
8+
9+
# 자동 답글 대상 채널 (하드코딩): fun-anna-house(C03SZTDEDK3), fun-free-talk(CQJ8HQWUV)
10+
AUTO_REPLY_CHANNEL_IDS = {"C03SZTDEDK3", "CQJ8HQWUV"}
11+
12+
# 김수빈 유저의 말투를 흉내 내어, 지정 채널의 새 글에 답글을 단다. (검색 없이 순수 생성)
13+
KIMSUBIN_PERSONA_PROMPT = """너는 AUSG 커뮤니티 멤버 '김수빈'의 말투로 답글을 다는 봇이야.
14+
채널에 새로 올라온 글(첨부 이미지가 있으면 이미지 포함)의 내용을 정확히 파악한 뒤, 김수빈이라면 어떻게 반응할지 그 말투로 답글을 단다.
15+
16+
김수빈 말투 특징:
17+
- 기본 해요체지만 드라이하고 담백하다. 호들갑·감탄사("와! 축하드려요!") 남발 금지.
18+
- 위트는 과장 없이 슬쩍. 단정보다 관찰·추측조: "~인 것 같아요", "~아닐까요", "~느낌".
19+
- 말끝에 ".."로 여운을 두거나, 괄호로 슬쩍 딴지/부연을 함: "(요즘은 다 이 정도 하는 것인가)".
20+
- 영어 기술용어·표현을 자연스럽게 섞어 쓴다.
21+
- 슬랙 이모지를 문장 끝에 한 개 정도, 종류는 다양하게(:eyes: :sadpepe: :meow_heartsqueeze: :thinking_face: :open_mouth: :aws:). 한 답글에 하나만, 남발 금지.
22+
- 보통 1~2문장. 할 말이 있으면 짧은 문단까지는 OK, 그 이상 장황하게 늘이지 말 것.
23+
24+
김수빈 실제 발화 예시:
25+
- "웹은 React가 표준인 것 같아요 :thinking_face:"
26+
- "이런거 일단 두고 갑니다"
27+
- "궁지에 몰려있던 저를 위한 착한 프로젝트 발견"
28+
- "컨설팅 회사 홈페이지가 넘 현란합니다 (요즘은 다 이 정도 하는 것인가)"
29+
- "아 제가 딱 이 발표 듣다가 집으로 가버림.. :sadpepe:"
30+
- "GitHub PR에서 바로 npm 설치된다길래 기대했는데 별거 아니었네요.. 그래도 어느정도는 편리할 느낌"
31+
- "한국에서는 이력서가 꼭 한 장이 아니어도 괜찮은 것 같고, 양식도 크게 중요하지 않은 느낌 :open_mouth:"
32+
- "아직은 다들 비즈니스 임팩트에 관심이 없어서 큰 연관이 없는데, 그게 곧 중요해질 때가 오지 않을까 싶어요 (maybe ~2027)"
33+
- "비용을 위해 안정성을 낮췄다가 장애 발생하면 내 책임입니다"
34+
- "then you are not vibing :meow_praise:"
35+
- "와 저도 가도 되나요"
36+
- "일단 인사이트는 겟"
37+
38+
규칙:
39+
- 원본 글에 자연스럽게 반응/코멘트한다 (요약 X, 멤버처럼).
40+
- "제가 김수빈인데" 같은 메타/사칭 발언 금지 — 그냥 그 말투로 답할 것.
41+
- 모르는 사실을 지어내거나 근거 없이 단정하지 말 것. 모르면 가볍게 궁금해하는 정도로.
42+
- 한국어로, 과하지 않게."""
43+
44+
45+
class SubinLikeResponse:
46+
"""지정 채널(fun-anna-house, fun-free-talk)의 root 글(스레드 답글 제외)에 김수빈 말투로 답글."""
47+
48+
# 답글 길이 상한 (maxlen 조심): 짧은 문단 수준으로 제한
49+
REPLY_MAX_TOKENS = 220
50+
# 한 번에 LLM 에 넘길 최대 이미지 수
51+
MAX_IMAGES = 4
52+
53+
def __init__(self, event, slack_client, qa_client, anna_id):
54+
self.event = event
55+
self.slack_client = slack_client
56+
self.qa_client = qa_client
57+
self.anna_id = anna_id
58+
59+
self.channel = event.get("channel")
60+
self.ts = event.get("ts")
61+
self.text = event.get("text") or ""
62+
self.user = event.get("user")
63+
64+
def run(self):
65+
if not self._should_handle():
66+
return
67+
68+
content = re.sub(r"<@[A-Z0-9]+>", "", self.text).strip()
69+
images = self._extract_images()
70+
answer = self.qa_client.generate(
71+
content=content,
72+
system_prompt=KIMSUBIN_PERSONA_PROMPT,
73+
max_tokens=self.REPLY_MAX_TOKENS,
74+
images=images or None,
75+
)
76+
if not answer:
77+
logger.info("[subin] generation failed/empty, skip")
78+
return
79+
80+
logger.info(
81+
"[subin] post=%r images=%d | reply=%r", content[:80], len(images), answer
82+
)
83+
self.slack_client.send_message(msg=answer, ts=self.ts)
84+
85+
def _image_files(self):
86+
return [
87+
f
88+
for f in (self.event.get("files") or [])
89+
if (f.get("mimetype") or "").startswith("image/")
90+
][: self.MAX_IMAGES]
91+
92+
def _extract_images(self):
93+
"""첨부 이미지를 봇 토큰으로 받아 base64 data URL 리스트로."""
94+
out = []
95+
for f in self._image_files():
96+
url = f.get("url_private_download") or f.get("url_private")
97+
if not url:
98+
continue
99+
data = self.slack_client.download_file(url)
100+
if not data:
101+
continue
102+
mimetype = f.get("mimetype") or "image/png"
103+
b64 = base64.b64encode(data).decode("ascii")
104+
out.append(f"data:{mimetype};base64,{b64}")
105+
return out
106+
107+
def _should_handle(self) -> bool:
108+
# 지정된 자동 답글 채널만 (fun-anna-house, fun-free-talk)
109+
if self.channel not in AUTO_REPLY_CHANNEL_IDS:
110+
return False
111+
# 봇 메시지 무시 — 무한루프·잡음 방지
112+
if self.event.get("bot_id"):
113+
return False
114+
# subtype 메시지 무시하되, 이미지 첨부(file_share)는 허용
115+
subtype = self.event.get("subtype")
116+
if subtype and subtype != "file_share":
117+
return False
118+
# ANNA 자기 글 무시
119+
if self.user and self.user == self.anna_id:
120+
return False
121+
# root 글만 — 스레드 답글이면 무시 (ANNA 자기 답글 루프도 자동 차단)
122+
thread_ts = self.event.get("thread_ts")
123+
if thread_ts and thread_ts != self.ts:
124+
return False
125+
# ANNA 멘션은 q) 핸들러에 양보 (중복 응답 방지)
126+
if self.anna_id and f"<@{self.anna_id}>" in self.text:
127+
return False
128+
# 텍스트도 이미지도 없으면 무시
129+
if not self.text.strip() and not self._image_files():
130+
return False
131+
return True

src/handler/controller.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from handler.bigchat.mention_response import MentionResponse
99
from handler.bigchat.help_response import HelpResponse
1010
from handler.bigchat.question_response import QuestionResponse
11+
from handler.bigchat.subin_like_response import SubinLikeResponse
1112
from handler.decorator import catch_global_error, loading_emoji_while_processing
1213
from implementation.google_spreadsheet_client import GoogleSpreadsheetClient
1314
from implementation.qa_client import QAClient
@@ -96,9 +97,12 @@ def mention_response(event, say, client):
9697
create_bigchat_sheet = CreateBigchatSheet(
9798
event, SlackClient(say, client), GoogleSpreadsheetClient()
9899
)
99-
question_response = QuestionResponse(event, SlackClient(say, client), _get_qa_client())
100+
question_response = QuestionResponse(
101+
event, SlackClient(say, client), _get_qa_client()
102+
)
100103
MentionResponse(
101-
[question_response, shuffle_response, create_bigchat_sheet, help_response], simple_response
104+
[question_response, shuffle_response, create_bigchat_sheet, help_response],
105+
simple_response,
102106
).run()
103107

104108

@@ -149,3 +153,15 @@ def mention_response(event, say, client):
149153
@catch_global_error()
150154
def announce_new_channel_created(event, say, client):
151155
AnnounceNewChannelCreated(event, SlackClient(say, client)).run()
156+
157+
158+
# message event: 지정 채널(fun-anna-house, fun-free-talk)의 새 글(스레드 제외)에
159+
# 김수빈 말투로 자동 답글 (대상 채널은 SubinLikeResponse 에 하드코딩)
160+
@catch_global_error()
161+
def subin_like_response(event, say, client):
162+
SubinLikeResponse(
163+
event,
164+
SlackClient(say, client),
165+
_get_qa_client(),
166+
anna_id=envs.ANNA_ID,
167+
).run()

src/implementation/qa_client.py

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import logging
2-
from typing import Optional
2+
from typing import List, Optional
33

44
import requests
55

@@ -42,14 +42,49 @@ def chat(self, question: str, system_prompt: Optional[str] = None) -> Optional[s
4242
logger.error(f"QA server response parsing failed: {e}")
4343
return None
4444

45+
def generate(
46+
self,
47+
content: str,
48+
system_prompt: Optional[str] = None,
49+
max_tokens: int = 512,
50+
images: Optional[List[str]] = None,
51+
) -> Optional[str]:
52+
"""검색(RAG) 없이 순수 LLM 생성. 페르소나 기반 답글 등에 사용.
53+
54+
images: 이미지 data URL 리스트 (예: "data:image/png;base64,...") — 있으면 비전 입력.
55+
"""
56+
payload = {"content": content, "max_tokens": max_tokens}
57+
if system_prompt:
58+
payload["system_prompt"] = system_prompt
59+
if images:
60+
payload["images"] = images
61+
62+
headers = {"X-API-Key": self.api_key}
63+
64+
try:
65+
response = requests.post(
66+
f"{self.qa_server_base_url}/api/v1/generate",
67+
json=payload,
68+
headers=headers,
69+
timeout=self.timeout,
70+
)
71+
response.raise_for_status()
72+
return response.json()["answer"]
73+
except requests.exceptions.RequestException as e:
74+
logger.error(f"QA server generate failed: {e}")
75+
return None
76+
except (KeyError, ValueError) as e:
77+
logger.error(f"QA server generate parsing failed: {e}")
78+
return None
79+
4580
def is_healthy(self) -> bool:
4681
headers = {"X-API-Key": self.api_key}
4782

4883
try:
4984
response = requests.get(
5085
f"{self.qa_server_base_url}/api/v1/chat/health",
5186
headers=headers,
52-
timeout=5
87+
timeout=5,
5388
)
5489
if response.status_code == 200:
5590
data = response.json()

src/implementation/slack_client.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
1+
import logging
12
from typing import List, Optional
23

4+
import requests
35
from pydantic import BaseModel
46
from slack_bolt import Say
57
from slack_sdk import WebClient
68
from slack_sdk.errors import SlackApiError
79

10+
logger = logging.getLogger(__name__)
11+
812

913
class Message(BaseModel):
1014
ts: str
@@ -27,6 +31,20 @@ def __init__(self, say: Say, web_client: WebClient):
2731
def send_message(self, msg: str, ts: str):
2832
self.say(msg, thread_ts=ts)
2933

34+
def download_file(self, url: str) -> Optional[bytes]:
35+
"""Slack url_private 파일을 봇 토큰으로 다운로드 (이미지 등). 실패 시 None."""
36+
try:
37+
resp = requests.get(
38+
url,
39+
headers={"Authorization": f"Bearer {self.web_client.token}"},
40+
timeout=20,
41+
)
42+
resp.raise_for_status()
43+
return resp.content
44+
except requests.exceptions.RequestException as e:
45+
logger.warning(f"Failed to download Slack file: {e}")
46+
return None
47+
3048
def send_message_to_freetalk(self, msg: str):
3149
self.say(msg, channel="CQJ8HQWUV")
3250

@@ -73,7 +91,9 @@ def get_replies(
7391

7492
for attempt in range(3):
7593
try:
76-
resp = self.web_client.conversations_replies(ts=thread_ts, channel=channel)
94+
resp = self.web_client.conversations_replies(
95+
ts=thread_ts, channel=channel
96+
)
7797
break
7898
except OSError:
7999
if attempt >= 2:

0 commit comments

Comments
 (0)