챗봇 고도화
RAG 챗봇 고도화: DB Log, 관리자 페이지, 피드백, STT 적용
이전에 챗봇 대화 로그를 JSONL 파일로 저장했었는데 서버에 올리기 위해 관리하기 편한 MariaDB로 변경하게 되었다.
로그 저장 방식을 Django 모델 기반 DB 저장으로 변경
1. 로그 저장 방식 변경: JSONL 파일 → MariaDB DB
기존 방식의 문제
기존에는 save_chat_jsonl() 함수로 로그를 파일에 저장했다.
사용자 질문, AI 답변, 에러를 각각 JSON 형태로 만들어 logs/chat_questions.jsonl 파일에 한 줄씩 추가하는 방식이었다.
간단하게 저장하기에는 좋았지만 아래와 같은 문제가 있다.
- 특정 세션의 대화 흐름을 보려면
session_key로 직접 검색해야 함 - 질문과 답변을 보려면 같은
turn_id를 찾아야 함 - 오류 로그만 따로 보기 어려움
- 사용자가 불만족한 답변을 따로 모을 수 없음
- 관리자 페이지에서 검색, 필터링, 상세 조회를 할 수 없음
ChatLog 모델 추가
기존 파일 로그를 대체하기 위해 ChatLog 모델을 만들었다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class ChatLog(models.Model):
ROLE_CHOICES = [
("USER", "사용자"),
("AI", "AI"),
("ERROR", "오류"),
]
log_session_id = models.CharField(max_length=100, null=True, blank=True, db_index=True)
turn_id = models.CharField(max_length=100, db_index=True)
role = models.CharField(max_length=10, choices=ROLE_CHOICES)
message = models.TextField()
intent = models.CharField(max_length=50, null=True, blank=True)
current_hero = models.CharField(max_length=50, null=True, blank=True)
target_enemy = models.CharField(max_length=50, null=True, blank=True)
metadata = models.JSONField(default=dict, blank=True)
is_unsatisfied = models.BooleanField(default=False, db_index=True)
feedback_reason = models.TextField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
기본 구조는 기존 JSONL 로그와 비슷하다.
role은 USER, AI, ERROR로 나누고, 같은 요청에서 발생한 사용자 질문과 AI 답변은 같은 turn_id를 공유한다.
같은 사용자의 대화 흐름은 log_session_id로 묶는다.
여기에 새로 추가한 필드가 있다.
1
2
is_unsatisfied = models.BooleanField(default=False, db_index=True)
feedback_reason = models.TextField(null=True, blank=True)
이 두 필드는 사용자가 AI 답변에 불만족 피드백을 남겼을 때 사용한다.
단순히 “답변이 마음에 안 든다”만 저장하는 게 아니라
사용자가 직접 적은 이유까지 같이 저장해서 나중에 어떤 답변이 왜 별로였는지 확인할 수 있게 했다.
프록시 모델 추가
관리자 페이지에서 로그를 더 편하게 보기 위해 프록시 모델도 추가했다.
1
2
3
4
5
6
7
8
9
10
11
12
class ErrorChatLog(ChatLog):
objects = ErrorLogManager()
class Meta:
proxy = True
class UnsatisfiedChatLog(ChatLog):
objects = UnsatisfiedAnswerManager()
class Meta:
proxy = True
프록시 모델은 새로운 테이블을 만드는 게 아니다.
실제 데이터는 그대로 ChatLog 테이블에 저장하고, Django Admin에서 다른 화면처럼 보이게 만드는 방식이다.
그래서 전체 로그는 ChatLog에서 보고, 오류만 보고 싶으면 ErrorChatLog,
사용자가 불만족한 답변만 보고 싶으면 UnsatisfiedChatLog에서 확인할 수 있다.
기존에는 파일 하나에서 전부 찾아야 했다면, 이제는 관리자 페이지에서 목적별로 바로 들어가서 볼 수 있게 했다.
2. Django Admin 관리자 페이지 구성
로그를 DB에 저장해도 관리자 페이지에서 보기 불편하면 의미가 없다.
그래서 admin.py를 새로 작성해서 로그를 보기 편하게 구성했다.
역할 뱃지 표시
먼저 USER, AI, ERROR를 색상 뱃지로 구분했다.
1
2
3
4
5
ROLE_BADGE_STYLES = {
"USER": ("#e0e7ff", "#3730a3", "사용자"),
"AI": ("#dcfce7", "#166534", "AI 응답"),
"ERROR": ("#fee2e2", "#991b1b", "❌ 오류"),
}
관리자 페이지에서 로그를 볼 때 텍스트만 있으면 한눈에 구분하기 어렵다.
그래서 사용자 질문인지, AI 답변인지, 오류인지 색으로 바로 구분되게 했다.
세션 / 턴 링크 추가
log_session_id와 turn_id는 클릭 가능한 링크로 만들었다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@admin.display(description="세션", ordering="log_session_id")
def log_session_link(self, obj):
if not obj.log_session_id:
return "-"
url = (
reverse("admin:chat_chatlog_changelist")
+ "?" + urlencode({"log_session_id": obj.log_session_id})
)
return format_html(
'<a href="{}" style="font-weight:600;">{}</a>',
url,
obj.log_session_id[:10],
)
log_session_id를 클릭하면 같은 세션의 대화만 필터링해서 볼 수 있고,
turn_id를 클릭하면 같은 요청에서 발생한 USER/AI 로그를 바로 볼 수 있다.
AI 답변 상세에서 연관 질문 표시
AI 답변만 보면 이 답변이 왜 나왔는지 바로 알기 어렵다.
그래서 AI 답변 상세 화면에서 같은 turn_id를 가진 사용자 질문을 같이 보여주도록 했다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@admin.display(description="연관 질문")
def related_question(self, obj):
if obj.role != "AI":
return "-"
user_log = (
ChatLog.objects
.filter(turn_id=obj.turn_id, role="USER")
.order_by("created_at")
.first()
)
if not user_log:
return "-"
return format_html(
'<div style="white-space:pre-wrap; word-break:break-word; '
'background:#eff6ff; border:1px solid #bfdbfe; padding:14px; '
'border-radius:8px; line-height:1.6; font-size:14px;">{}</div>',
user_log.message,
)
이렇게 하면 AI 답변 상세 페이지에서 사용자 질문과 AI 답변을 한 화면에서 비교할 수 있다.
실제로 답변 품질을 확인할 때 이 부분이 제일 중요하다.
AI 답변만 보면 괜찮아 보여도, 원래 질문과 비교하면 질문 의도를 놓친 경우가 보이기 때문이다.
오류 로그 / 불만족 답변 분리
ErrorChatLogAdmin은 role="ERROR"인 로그만 보여준다.
챗봇 응답 중 Python 예외가 발생했거나, LangGraph 내부에서 에러 상태가 반환된 경우를 따로 모아서 볼 수 있다.
UnsatisfiedChatLogAdmin은 사용자가 불만족으로 표시한 AI 답변만 보여준다.
여기서는 질문, AI 답변, 불만족 이유를 한 화면에서 확인할 수 있게 했다.
1
2
3
4
5
6
7
8
9
10
11
@admin.register(UnsatisfiedChatLog)
class UnsatisfiedChatLogAdmin(ChatLogDisplayMixin, admin.ModelAdmin):
list_display = (
"created_at",
"log_session_link",
"turn_link",
"current_hero",
"target_enemy",
"message_preview",
"feedback_reason_preview",
)
전체 로그를 보는 화면, 오류만 보는 화면, 불만족 답변만 보는 화면이 분리돼서 문제를 찾기가 훨씬 쉬워졌다.
3. AI 답변 불만족 피드백 기능 추가
사용자가 질문한 의도대로 대답을 하지 않을 수 있다.
그래서 AI 답변 아래에 “이 답변이 만족스럽지 않았나요?” 버튼을 추가했다.
chat.html 피드백 UI
AI 답변이 출력되면 아래에 피드백 버튼이 붙는다.
버튼을 누르면 textarea가 열리고, 사용자가 불만족 이유를 적어서 제출할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function buildFeedbackUI(turnId) {
const toggle = document.createElement('button');
toggle.textContent = '🙁 이 답변이 만족스럽지 않았나요?';
submitBtn.addEventListener('click', async () => {
const reason = textarea.value.trim();
await fetch('/chat/api/feedback/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCsrfToken(),
},
body: JSON.stringify({
turn_id: turnId,
reason,
}),
});
});
}
사용자가 불만족을 누른 AI 답변이 어떤 답변인지 알아야 하므로, 서버에서 AI 응답을 줄 때 turn_id도 같이 내려준다.
1
2
3
4
return JsonResponse({
**result,
"turn_id": turn_id,
})
프론트에서는 이 turn_id를 피드백 요청에 같이 보내고,
서버에서는 해당 turn_id를 가진 AI 로그를 찾아 is_unsatisfied=True로 바꾼다.
4. 음성 입력(STT) 추가
이번에 마이크 버튼도 추가했다.
사용자가 직접 타이핑하지 않아도 음성으로 질문할 수 있게 하기 위한 기능이다.
flow(front)
흐름은 단순하다.
마이크 버튼 클릭 → 녹음 시작 → 다시 클릭하면 녹음 종료 → 서버로 오디오 전송 → Gemini로 STT 처리 → 변환된 텍스트를 입력창에 넣기
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
async function startRecording() {
micStream = await navigator.mediaDevices.getUserMedia({ audio: true });
mediaRecorder = new MediaRecorder(micStream, { mimeType });
audioChunks = [];
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
audioChunks.push(event.data);
}
};
mediaRecorder.onstop = onRecordingStopped;
mediaRecorder.start();
}
녹음이 끝나면 오디오 파일을 FormData로 만들어 서버에 보낸다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
async function onRecordingStopped() {
const blob = new Blob(audioChunks, { type: mimeType });
const formData = new FormData();
formData.append('audio', blob, `recording.${ext}`);
const res = await fetch('/chat/api/stt/', {
method: 'POST',
headers: {
'X-CSRFToken': getCsrfToken(),
},
body: formData,
});
const data = await res.json();
input.value = data.text;
}
서버에서는 Gemini 멀티모달 입력을 활용해서 음성을 텍스트로 변환한다.
배포 후 생긴 문제
로컬에서는 잘 동작했다.
그런데 서버에 올리고 나니까 마이크가 동작하지 않았다.
이유는 브라우저의 보안 정책 때문이었다.
getUserMedia()는 HTTPS 환경에서만 정상적으로 동작한다.
로컬에서는 예외적으로 허용되지만, 실제 배포 서버에서는 HTTP 환경이면 마이크 권한을 사용할 수 없다.
그래서 기능 구현 자체는 끝났지만, 현재는 HTTPS 설정 전이라 실제 서비스에서는 바로 사용하지 못하는 상태다.
나중에 HTTPS를 적용하면 이 기능은 그대로 사용할 수 있다.
5. CSRF 보호 적용 및 에러 메시지 처리
기존에는 @csrf_exempt를 사용해서 CSRF 검사를 제외했었다.
처음에는 빠르게 테스트하기 위해서 제외했지만, 기능이 늘어나면서 피드백 제출, STT 업로드처럼 POST 요청이 많아졌다.
그래서 CSRF 보호를 다시 활성화했다.
chat.html에서 CSRF 토큰 전달
HTML에 CSRF 토큰을 넣고,
1
<meta name="csrf-token" content="" />
JavaScript에서 요청을 보낼 때 헤더에 추가한다.
1
2
3
4
5
function getCsrfToken() {
return document
.querySelector('meta[name="csrf-token"]')
?.getAttribute('content') || '';
}
이후 fetch() 요청마다 X-CSRFToken을 함께 보낸다.
1
2
3
4
5
6
7
8
await fetch('/chat/api/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCsrfToken(),
},
body: JSON.stringify(payload),
});
사용자에게 내부 오류 메시지 비노출
기존에는 예외가 발생하면 str(e)가 그대로 사용자에게 내려갈 수 있었다.
개발 중에는 원인을 바로 볼 수 있어서 편하지만, 실제 사용자에게는 내부 경로나 예외 메시지를 그대로 보여줄 필요가 없다.
그래서 사용자에게는 일반적인 안내 문구만 보여주고, 자세한 traceback은 DB 로그의 metadata에 저장하도록 바꿨다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
except Exception as e:
logger.exception("chat_api 오류")
save_chat_log(
role="ERROR",
message=str(e),
metadata={
"traceback": traceback.format_exc(),
},
)
return JsonResponse(
{"error": "응답 처리 중 문제가 발생했습니다. 잠시 후 다시 시도해주세요."},
status=500,
)
이렇게 하면 사용자는 불필요한 내부 오류를 보지 않고, 개발자는 Admin에서 traceback을 확인할 수 있다.
6. chatbot_graph.py 로직 개선
이번 PR에서 DB와 UI만 바꾼 게 아니라, 챗봇 답변 로직도 같이 수정했다.
베타 테스트를 하면서 “답변이 이상하게 나오는 케이스”가 몇 가지 있었기 때문이다.
6-1. 오프토픽(off_topic) 처리
기존에는 “안녕하세요”, “오늘 날씨 어때?”처럼 오버워치와 무관한 질문도 LLM이 즉석에서 답변했다.
문제는 LLM이 매번 다른 문구로 답하거나, 오버워치 챗봇인데도 다른 주제에 대해 그럴듯하게 답할 수 있다는 점이었다.
그래서 오버워치와 무관한 질문은 off_topic으로 분류하고, 고정 문구만 반환하도록 했다.
1
2
3
4
5
OFF_TOPIC_ANSWER = (
"저는 오버워치2 게임 코칭만 도와드릴 수 있어요. "
"상대 영웅 대처법, 팀 조합, 맵 운영, 개인 플레이 개선처럼 "
"오버워치2 게임 상황과 관련된 질문을 해주세요."
)
실제 응답은 LLM을 호출하지 않고 고정 노드에서 처리한다.
1
2
3
4
5
6
7
8
def off_topic_response_node(state):
return {
"answer": OFF_TOPIC_ANSWER,
"recommendation_type": "off_topic",
"recommended_heroes": [],
"choice_buttons": [],
"suggested_questions": [],
}
LLM 프롬프트에도 기준을 추가했다.
1
2
3
메시지가 오버워치2 게임과 전혀 무관하면 intent를 "off_topic"으로 해라.
메시지가 오버워치와 조금이라도 관련이 있다면 off_topic으로 분류하지 마라.
애매하면 off_topic이 아니라 "general"로 분류해라.
여기서 중요한 건 “애매하면 general”이다.
조금이라도 오버워치 상황과 관련된 질문인데 너무 엄격하게 걸러버리면 정상 질문까지 오프토픽으로 빠질 수 있기 때문이다.
6-2. 빈 메시지일 때 LLM 호출 스킵
역할 선택 버튼을 누르면 프론트에서 message: ''로 요청이 들어온다.
이건 실제 사용자 질문이 아니라 “탱커로 볼게요”, “딜러로 볼게요” 같은 선택 신호다.
그런데 이전에는 이 빈 메시지를 그대로 LLM에 넘겼고,
LLM이 이걸 오버워치와 무관한 빈 입력으로 판단해서 off_topic으로 분류하는 문제가 있었다.
그래서 llm_parse_context_node()에서 메시지가 비어 있으면 LLM을 호출하지 않도록 했다.
1
2
3
4
5
def llm_parse_context_node(state):
message = state.get("message", "")
if not message.strip():
return {}
빈 메시지는 LLM이 판단할 대상이 아니기 때문에, 규칙 기반 흐름에서 기존 pending_question을 이어받아 처리하게 했다.
6-3. 역할 미확정 시 되묻는 범위 확장
이전에는 “상대 영웅을 지목했는데 내 역할을 모를 때”만 역할을 물어봤다.
예를 들면 “파라 카운터 알려줘”라고 했을 때, 사용자가 탱커인지 딜러인지 힐러인지 모르니까 역할을 물어보는 식이었다.
그런데 실제 질문은 더 다양했다.
예를 들어 사용자가 이렇게 물을 수 있다.
상대 라인 돌진 때문에 우리팀이 자꾸 죽어. 가까이 가지 말라고 해도 계속 가까이 가는데 어떻게 해야 해?
이 질문은 특정 영웅을 플레이 중이라고 말하지 않았다.
이 상태에서 바로 답하면 탱커 기준인지, 딜러 기준인지, 힐러 기준인지 애매하다.
그래서 역할을 모르는 상태에서 개인화된 답변이 필요한 intent라면 역할을 먼저 물어보도록 확장했다.
1
2
3
4
5
6
7
ROLE_CLARIFICATION_INTENTS = {
"performance_improve",
"stay",
"swap",
"general",
"counter",
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def should_ask_role_filter(state):
role_filter = state.get("role_filter")
if role_filter:
return False
intent = state.get("intent")
target_enemy = state.get("target_enemy")
if intent == "counter" and target_enemy:
return True
if not state.get("current_hero") and intent in ROLE_CLARIFICATION_INTENTS:
return True
return False
이제는 상대 영웅이 명확하지 않아도, 사용자의 현재 역할을 모르면 먼저 물어본다.
1
2
3
4
if target_enemy:
answer = f"{target_enemy}를 카운터하는 영웅을 어떤 역할 기준으로 볼까요?"
else:
answer = "지금 어떤 역할(탱커/딜러/힐러)로 플레이 중이신가요?"
이렇게 해야 챗봇이 모르는 상태에서 억지로 답하지 않고, 필요한 정보를 먼저 확인할 수 있다.
6-4. role_filter 잔존 버그 수정
역할 버튼을 한 번 누르면 그 값이 세션에 남아 다음 질문까지 영향을 주는 문제가 있었다.
예를 들어 이전 질문에서 “탱커”를 선택했는데, 다음 질문은 전혀 다른 상황이어도 계속 탱커 기준으로 답하는 식이었다.
그래서 세션에 남아 있는 role_filter를 자동으로 재사용하지 않도록 바꿨다.
1
2
3
4
explicit_role_filter = state.get("role_filter") or role_filter_from_text(message)
# 세션에 남아있는 role_filter를 새 질문에 자동 재사용하지 않는다.
role_filter = explicit_role_filter
역할 필터는 사용자가 이번 턴에 버튼이나 텍스트로 명시했을 때만 적용한다.
이렇게 하지 않으면 이전 선택이 다음 새 질문까지 새어 들어가서, 현재 역할을 모르는 상황에서도 되묻지 않고 바로 답하는 문제가 생긴다.
6-5. 영웅 이름이 적인지 내 영웅인지 구분
또 하나 수정한 부분은 영웅 이름 구분이다.
문장에 영웅 이름이 등장했다고 해서 무조건 사용자가 플레이 중인 영웅으로 보면 안 된다.
예를 들어 이런 문장이 있다.
상대 리퍼 때문에 우리팀 힐러가 계속 잘려
여기서 리퍼는 사용자의 영웅이 아니라 상대 영웅이다.
그런데 단순히 영웅 이름만 보고 current_hero로 잡으면 답변 기준이 완전히 틀어진다.
그래서 hero_mentioned_as_current_hero() 함수를 추가했다.
1
2
3
4
5
6
7
8
9
10
11
12
13
def hero_mentioned_as_current_hero(hero, text):
normalized = normalize_hero_name(hero)
for name in names:
escaped = re.escape(name)
if re.search(rf"(?:난|나는|나|저는|제가|내가)\s*{escaped}", text):
return True
if re.search(rf"{escaped}\s*(?:로|으로)\s*(?:플레이|하고|하는|할|가|갈|쓰|쓸)", text):
return True
return False
“난 애쉬로”, “겐지로 할게”, “파라 하고싶어”처럼 사용자가 직접 플레이한다고 말한 경우만 current_hero로 인정한다.
반대로 “상대 리퍼”, “둠피 때문에”, “파라 카운터”처럼 적으로 언급된 영웅은 target_enemy로 처리한다.
6-6. 오프토픽 라우팅 추가
LangGraph 흐름에도 off_topic_response 노드를 추가했다.
1
2
3
4
5
6
7
8
def route_after_context_merge(state):
if state.get("intent") == "off_topic":
return "off_topic_response"
if should_ask_role_filter(state):
return "clarify_role_filter"
return "build_retrieval_queries"
오프토픽으로 판단되면 RAG 검색도 하지 않고, 전략 판단도 하지 않고, 바로 고정 응답으로 빠진다.
이렇게 해서 불필요한 검색과 LLM 호출을 줄이고, 챗봇의 역할도 명확하게 유지했다.
7. 응답 잘림 문제 수정
LLM 응답이 길어질 때 JSON이 중간에 잘리는 문제가 있었다.
특히 generate_answer_node에서는 LLM에게 JSON 형태로 답을 요구하는데,
출력 토큰이 부족하면 "answer" 값이 끝까지 닫히지 않고 잘릴 수 있다.
그래서 rag_class.py의 LLM 설정에 max_output_tokens를 추가했다.
1
2
3
4
5
6
self._llm = ChatGoogleGenerativeAI(
model=self.llm_model,
google_api_key=api_key,
temperature=self.temperature,
max_output_tokens=1024,
)
그리고 JSON 파싱이 실패했을 때도 완전히 실패로 처리하지 않고,
"answer" 필드가 중간에 잘린 경우 최대한 복구하는 폴백 로직을 추가했다.
1
2
3
4
5
6
7
8
9
truncated_match = re.search(
r'"answer"\s*:\s*"((?:[^"\\]|\\.)*)\\?$',
raw_text,
re.DOTALL,
)
if truncated_match:
raw_answer = truncated_match.group(1)
logger.warning("[FALLBACK] answer 필드가 중간에 잘림(max_output_tokens 의심)")
물론 가장 좋은 건 JSON이 안 잘리게 하는 것이지만, 실제 LLM 응답은 언제든 깨질 수 있기 때문에 폴백 처리를 넣어두는 게 안전하다.
마무리
이번 업데이트에서 바뀐 내용을 정리하면 다음과 같다.
| 항목 | 변경 전 | 변경 후 |
|---|---|---|
| 로그 저장 | JSONL 파일 | MariaDB DB 저장 |
| 로그 조회 | 파일 직접 열기, grep 검색 | Django Admin에서 검색/필터링 |
| 오류 확인 | 전체 로그에서 직접 찾기 | ErrorChatLog로 오류만 분리 |
| 불만족 답변 수집 | 없음 | 피드백 버튼 + UnsatisfiedChatLog |
| AI 답변 분석 | 질문/답변을 직접 turn_id로 찾아야 함 | Admin 상세에서 연관 질문 같이 표시 |
| STT | 없음 | Gemini 멀티모달 기반 음성 입력 구현 |
| CSRF | 일부 API에서 제외 | CSRF 토큰 적용 |
| 내부 오류 메시지 | 사용자에게 노출 가능 | 사용자에게는 일반 문구, 상세 오류는 DB 저장 |
| 오프토픽 처리 | LLM이 즉석 답변 | 고정 안내 문구 반환 |
| 역할 질문 | 상대 지목 시에만 역할 확인 | current_hero 미확정이면 역할 먼저 확인 |
| role_filter | 이전 선택이 세션에 남을 수 있음 | 이번 턴에 명시한 경우만 적용 |
| 응답 잘림 | JSON 파싱 실패 가능 | max_output_tokens + 폴백 처리 |
이번 작업의 핵심은 챗봇을 “일단 답하는 기능”에서 “운영하면서 개선할 수 있는 구조”로 바꾼 것이다.
RAG 챗봇은 한 번 만들고 끝나는 게 아니라, 실제 사용자가 어떤 질문을 하는지 보고 계속 수정해야 한다.
그래서 로그를 DB에 남기고, 관리자 페이지에서 확인하고, 사용자가 불만족한 답변을 따로 모을 수 있게 만든 게 중요했다.
답변 로직도 마찬가지다.
오버워치와 무관한 질문은 고정 문구로 처리하고,
역할을 모르면 바로 답하지 않고 되묻고, 이전 role_filter가 다음 질문에 섞이지 않게 막았다.
이런 작은 안전장치들이 쌓여야 실제 사용 중에 이상한 답변이 줄어든다.
아직 STT는 HTTPS 설정 전이라 실제 배포 환경에서는 바로 쓰지 못하지만, 기능 자체는 구현해둔 상태다.
이후 HTTPS를 적용하면 음성 입력까지 사용할 수 있을 것이다.