Post

대화 내용 저장하기(json)

대화 내용 저장하기(json)

RAG 챗봇 개선: stay 의도 구분 & 대화 로깅

배포 후 테스트를 하던 도중 문제를 발견했다.


“상대 딜러가 솔저랑 소전인데 난 파라를 하고싶어 어떻게 해야지 이기면서 즐기는 게임을 할 수 있을까?”

“난 애쉬로 계속 하고 싶은데 둠피 때문에 살기가 힘들어 어떻게 해야해?”

둘 다 사용자가 지금 영웅을 유지하겠다는 의사를 밝힌 질문이다.

그런데 챗봇은 첫 번째 질문에서 파라 대신 다른 딜러를 추천했고

두 번째 질문에서는 둠피스트를 직접 언급하지 않거나 일반적인 생존 팁만 늘어놓는 답변을 내놓았다.


공통적으로 stay 의도(파라를 하고싶어, 애쉬로 계속 하고 싶은데)와 counter 맥락(솔저랑 소전, 둠피때문에)이 한 문장에 섞여 있었고

챗봇은 stay를 무시하고 counter나 general로 분류했다.

이 문제를 수정하면서 한 가지를 함께 작업했다.

같은 문제가 다시 생겼을 때 더 빨리 발견하기 위한 대화 로깅이다.

베타 테스트처럼 직접 보고 있을 때는 눈으로 잡을 수 있지만,

실제 서비스 중에는 로그가 없으면 어떤 질문에서 답변이 틀렸는지 알 수가 없다.

이번 글에서는 이 두 가지를 어떻게 해결했는지 정리한다.






1. stay 의도 구분 개선

문제: stay + counter가 섞인 문장을 처리하지 못함

베타 테스트에서 반복적으로 나타난 패턴이 있었다.


케이스 1

“상대 딜러가 솔저랑 소전인데 난 파라를 하고싶어 어떻게 해야지 이기면서 즐기는 게임을 할 수 있을까?”

→ 챗봇 답변: 파라 대신 다른 딜러 영웅 추천


케이스 2

“난 애쉬로 계속 하고 싶은데 둠피때문에 살기가 힘들어 어떻게 해야해?”

→ 챗봇 답변: 둠피스트를 직접 언급하지 않거나 일반적인 생존 팁만 나열


두 케이스의 구조는 같다.

사용자가 영웅을 유지하겠다는 의사(파라를 하고싶어, 애쉬로 계속 하고 싶은데)를 밝혔는데,

같은 문장 안에 적 영웅(솔저, 소전, 둠피)도 등장한다.


기존 코드에서 stay 의도는 이런 식으로 판단했다.

1
2
if any(word in text for word in ["계속 쓰고", "계속 하고", "유지", "그 영웅", "현재 영웅", "내가 계속"]):
    return "stay"

“하고싶어”, “이기면서 즐기는”, “살기가 힘들어” 같은 표현은 이 목록에 없었다.

결국 적 영웅이 더 눈에 띄어 countergeneral로 분류됐고,

사용자가 원한 건 “지금 영웅으로 어떻게 상대하나”였는데 챗봇은 영웅 교체를 제안했다.

화이트리스트 방식의 한계다. 추가할수록 빠지는 게 생기고, 붙여 쓰면(“파라하고싶어”) 또 누락된다.



해결: 패턴 매칭으로 stay 의도 감지

detect_wants_to_keep_hero() 함수를 새로 만들었다.

특정 문구가 아니라 의미 구조를 정규식으로 잡는 방식이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def detect_wants_to_keep_hero(text: str) -> bool:
    # 영웅 이름이 없으면 판단하지 않음
    hero = find_first_hero(text)
    if not hero:
        return False

    # 붙여 쓰기 대응: "파라쓸건데", "파라할건데", "파라하고싶어"
    compact = re.sub(r"\s+", "", text)

    keep_patterns = [
        r"(하고싶|하고싶어|해보고싶|쓰고싶|쓸거|쓸건데|할거|할건데)",
        r"(계속|유지|고정|원챔|포기안|안바꾸|바꾸지않)",
        r"(이기고싶|이기면서|즐기고싶|즐기면서)",
        r"(해도돼|해도될까|가능할까|괜찮을까)",
    ]

    return any(re.search(pattern, compact) for pattern in keep_patterns)


핵심은 두 가지다.

공백을 제거한 compact 문자열에 패턴을 적용한다.

“파라 쓸건데”와 “파라쓸건데”를 동일하게 처리할 수 있다.

영웅 이름이 없으면 판단하지 않는다.

“이기고싶어”만 들어온 경우 특정 영웅을 유지하겠다는 의도가 아닐 수 있으므로, 영웅 이름이 있을 때만 stay로 분류한다.



infer_intent_by_rule 변경

의도 분류 함수에서 detect_wants_to_keep_hero()를 최우선으로 적용한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def infer_intent_by_rule(message: str, context: Dict[str, Any]) -> str:
    text = message.strip()

    # 1순위: 영웅 유지 의사 표현
    if detect_wants_to_keep_hero(text):
        return "stay"

    # 2순위: 교체 의도 — 단, "안 바꾸고"처럼 부정이면 stay로
    if any(word in text for word in ["말고", "다른 영웅", "바꾸", "변경", "픽 추천"]):
        compact = re.sub(r"\s+", "", text)
        if any(word in compact for word in ["안바꾸", "바꾸지않", "그대로", "유지", "고정"]):
            return "stay"
        return "swap"

    # 3순위 이하: 기존과 동일
    ...

“바꾸고 싶어”와 “안 바꾸고 싶어”를 구분하는 처리가 추가됐다.

이전에는 “바꾸”라는 단어만 보고 무조건 swap으로 분류했는데, 부정 표현이 함께 있으면 stay로 처리하도록 했다.



infer_current_hero 변경

current_hero를 추출할 때도 같은 함수를 활용한다.

1
2
3
4
5
6
7
8
9
10
11
12
# 이전
if any(word in text for word in ["계속 쓰고", "계속 하고", "현재", "플레이", "하고 있"]):
    hero = find_first_hero(text)
    ...

# 현재
if detect_wants_to_keep_hero(text) or any(word in text for word in [
    "계속 쓰고", "계속 하고", "현재", "플레이", "하고 있",
    "하고 싶어", "하고싶어", "쓰고 싶어", "쓸건데", "쓸 거", "고정", "원챔",
]):
    hero = find_first_hero(text)
    ...

“파라 쓸건데”에서 파라current_hero로 올바르게 추출할 수 있게 됐다.



LLM 프롬프트에도 규칙 추가

llm_parse_context_node의 프롬프트에 stay 의도 분류 규칙을 명시적으로 추가했다.

1
2
3
4
5
9. 사용자가 "X를 하고 싶어", "X 쓸건데", "X 할건데", "X 고정", "X 원챔",
"X로 이기고 싶어", "X로 즐기고 싶어", "X 해도 돼?"처럼 말하면
current_hero는 X이고 intent는 "stay"로 판단해라.
이 경우 사용자는 영웅 교체 추천을 원하는 것이 아니라,
X를 유지한 상태에서 상대 조합을 이기는 운영법을 원하는 것이다.

규칙 기반과 LLM 기반을 동시에 강화해서 어느 한쪽이 놓치더라도 다른 쪽에서 잡을 수 있게 했다.



답변 프롬프트에도 stay 응답 규칙 추가

generate_answer_node의 답변 작성 규칙에 아래 내용을 추가했다.

1
2
3
4
5
6. 사용자가 특정 영웅을 하고 싶다, 쓸 것이다, 고정으로 한다, 원챔이다, 해도 되냐고 말한 경우
   다른 영웅 추천을 먼저 하지 마라.
   첫 문장은 반드시 "그 영웅을 유지해도 된다" 또는 "불리하지만 운영으로 풀 수 있다"처럼
   사용자의 선택을 존중하는 방향으로 답해라.
   이후 그 영웅으로 상대 조합을 상대하는 구체적인 운영법을 제시해라.

이 규칙이 없으면 intent는 stay로 올바르게 분류됐더라도

LLM이 “불리하니 다른 영웅을 고려해보세요”라는 식으로 답하는 경우가 있었다.



stay 관련 컨텍스트 이어받기

merge_context_node에서 stay, performance_improve 의도일 때도 이전 대화의 target_enemy를 이어받도록 로직을 추가했다.

1
2
3
4
5
if intent in ["counter", "stay", "performance_improve"] and not target_enemy:
    previous_target_enemy = context.get("target_enemy")
    if previous_target_enemy:
        target_enemy = previous_target_enemy
        enemy_named_this_turn = True


예를 들어 “라마트라 카운터 알려줘” → “난 파라로 할건데” 순서로 대화하면

두 번째 메시지에서 상대가 라마트라라는 컨텍스트가 이어져 “파라로 라마트라 상대하는 법”을 제대로 답할 수 있다.






2. 영웅 별칭 처리 개선

문제: “둠피”를 인식하지 못함

이전에는 normalize_hero_name() 함수에 “솔저: 76”, “D.Va” 두 가지만 하드코딩돼 있었다.

“둠피”(둠피스트), “솔저”(솔저76) 같은 줄임말을 원문 검증 단계에서 인식하지 못하는 문제가 있었다.


해결: HERO_ALIASES 딕셔너리

별칭을 별도 딕셔너리로 분리했다.

1
2
3
4
5
6
7
HERO_ALIASES = {
    "둠피": "둠피스트",
    "솔저": "솔저76",
    "솔저: 76": "솔저76",
    "D.Va": "디바",
    "디바": "디바",
}


그리고 원문 검증을 담당하는 hero_mentioned_in_text() 함수를 새로 만들었다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
def hero_mentioned_in_text(hero: Optional[str], text: str) -> bool:
    """
    정규화된 영웅명이 원문에 직접 또는 별칭으로 언급되었는지 확인한다.
    예: hero='둠피스트', text='둠피가 나만 노려' -> True
    """
    if not hero or not text:
        return False

    normalized = normalize_hero_name(hero)

    # 정식 이름 그대로 등장
    if normalized and normalized in text:
        return True

    # HEROES 원본 표기 확인 (예: "솔저: 76")
    for h in HEROES:
        if normalize_hero_name(h) == normalized and h in text:
            return True

    # 별칭 확인
    for alias, canonical in HERO_ALIASES.items():
        if canonical == normalized and alias in text:
            return True

    return False


이전에는 normalized_enemy in message라는 단순 문자열 검사를 했는데,

이제는 정식 이름, 원본 표기, 별칭을 모두 체크한다.

“둠피가 나만 노려”처럼 별칭으로 언급해도 올바르게 인식한다.

llm_parse_context_node에서 target_enemy와 enemy_team을 원문 검증할 때 이 함수가 사용된다.

1
2
3
4
5
# 이전
and normalized_enemy in message

# 현재
and hero_mentioned_in_text(normalized_enemy, message)






3. 역할 위반 검사 범위 확장

generate_answer_node에서 LLM이 허용 목록 밖의 영웅을 답변에 포함했을 때 치환하는 로직이 있었다.

그런데 이전 버전에는 한 가지 예외 처리가 빠져있었다.

이전: 사용자가 메시지에서 직접 언급한 영웅(user_mentioned_heroes)만 치환 예외로 처리했다.

현재: 적 컨텍스트 영웅들도 예외에 추가했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# 이전 예외 목록
user_mentioned_heroes = set(find_all_heroes(state.get("message", "")))

# 현재 예외 목록
user_mentioned_heroes = set(find_all_heroes(state.get("message", "")))

enemy_context_heroes = set()
target_enemy = state.get("target_enemy")
if target_enemy:
    enemy_context_heroes.add(normalize_hero_name(target_enemy))
high_threat_enemy = state.get("high_threat_enemy")
if high_threat_enemy:
    enemy_context_heroes.add(normalize_hero_name(high_threat_enemy))
for h in state.get("enemy_team", []) or []:
    normalized = normalize_hero_name(h)
    if normalized:
        enemy_context_heroes.add(normalized)

forbidden_in_answer = [
    h for h in find_all_heroes(answer)
    if (
        h not in answer_allowed_hero_set
        and h not in user_mentioned_heroes
        and h not in enemy_context_heroes   # ← 추가
    )
]


예를 들어 딜러가 라마트라(탱커)를 상대하고 있을 때,

답변에서 “라마트라의 공허 방벽을 피하세요”라는 문장의 ‘라마트라’가 탱커라는 이유로 치환되던 문제를 해결했다.

상대 영웅은 추천한 게 아니라 언급한 것이기 때문에 치환 대상에서 제외해야 한다.






4. 대화 로깅 (views.py)

배포 후 실제 사용 데이터를 수집하기 위해 대화 내용을 JSONL 형식으로 저장하는 기능을 추가했다.


save_chat_jsonl() 함수

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
def save_chat_jsonl(
    *,
    session_key,
    turn_id,
    role,           # "USER" | "AI" | "ERROR"
    message,
    intent=None,
    current_hero=None,
    target_enemy=None,
    metadata=None,
):
    LOG_DIR.mkdir(parents=True, exist_ok=True)

    row = {
        "created_at": datetime.now().isoformat(timespec="seconds"),
        "session_key": session_key,
        "turn_id": turn_id,
        "role": role,
        "message": message,
        "intent": intent,
        "current_hero": current_hero,
        "target_enemy": target_enemy,
        "metadata": metadata or {},
    }

    with LOG_FILE.open("a", encoding="utf-8") as f:
        f.write(json.dumps(row, ensure_ascii=False) + "\n")

JSONL(JSON Lines)은 한 줄에 JSON 객체 하나를 저장하는 형식이다.

파일을 열지 않고도 tail -f로 실시간 모니터링할 수 있고, 나중에 줄 단위로 읽어서 파싱하기 쉽다.

저장되는 로그는 세 종류다.

role시점저장 내용
USER사용자 메시지 수신 직후질문 원문, 역할 필터, 이전 컨텍스트
AI답변 생성 후답변 원문, intent, 추천 영웅, 후속 질문
ERROR예외 발생 시에러 메시지, traceback



turn_id로 한 쌍을 묶음

같은 요청의 USER 로그와 AI 로그는 동일한 turn_id를 공유한다.

uuid.uuid4()로 생성해 요청마다 고유하게 부여한다.

1
2
3
4
turn_id = str(uuid.uuid4())

save_chat_jsonl(role="USER", turn_id=turn_id, ...)
save_chat_jsonl(role="AI",   turn_id=turn_id, ...)

나중에 분석할 때 같은 turn_id로 필터링하면 질문-답변 쌍을 한 번에 볼 수 있다.



session_key로 대화 흐름 추적

Django 세션 키를 로그에 함께 저장한다.

같은 session_key로 묶으면 한 사용자가 어떤 순서로 질문했는지 추적할 수 있다.

1
2
3
if not request.session.session_key:
    request.session.create()
session_key = request.session.session_key

세션이 없는 상태에서 첫 요청이 들어오면 명시적으로 create()를 호출해 키를 생성한다.



context_patch 적용 방식 단순화

views.py에서 컨텍스트를 업데이트하는 방식도 이번에 정리됐다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 이전 — extra_context를 별도로 병합
conversation_context = {
    **(request.session.get("coach_context", {}) or {}),
    **extra_context,
}
...
updated_context = {**base_context, **context_patch}

# 현재 — conversation_context에 context_patch를 직접 update()
conversation_context = request.session.get("coach_context", {})
...
context_patch = result.get("context_patch", {})
conversation_context.update(context_patch)
request.session["coach_context"] = conversation_context

extra_context(맵, 공수 정보)를 별도로 병합하던 로직을 제거하고,

context_patch를 세션에 직접 덮어쓰는 방식으로 통일했다.

맵/공수 정보는 그래프 내부에서 처리한다.



저장되는 로그 예시

실제로 저장되는 JSONL 파일 한 줄의 형태는 이렇다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
  "created_at": "2025-07-01T14:32:11",
  "session_key": "abcd1234efgh5678",
  "turn_id": "a1b2c3d4-...",
  "role": "USER",
  "message": "파라 카운터 알려줘",
  "intent": "counter",
  "current_hero": null,
  "target_enemy": "파라",
  "metadata": {
    "role_filter": null,
    "context_before": {}
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
  "created_at": "2025-07-01T14:32:13",
  "session_key": "abcd1234efgh5678",
  "turn_id": "a1b2c3d4-...",
  "role": "AI",
  "message": "파라를 상대할 때 가장 효과적인 영웅은 ...",
  "intent": "counter",
  "current_hero": null,
  "target_enemy": "파라",
  "metadata": {
    "recommendation_type": "counter_pick",
    "recommended_heroes": ["솜브라", "위도우메이커"],
    "suggested_questions": ["파라 잡는 스킬 순서는?", ...]
  }
}

이 데이터를 분석하면 어떤 영웅이 가장 많이 질문되는지, intent 분류가 실제로 얼마나 정확한지,

어떤 질문 패턴에서 에러가 많이 나는지 파악할 수 있다.






마무리

이번 업데이트에서 변경된 내용을 정리하면 다음과 같다.


항목변경 전변경 후
stay 의도 감지화이트리스트(고정 키워드)패턴 매칭(compact 문자열 + 정규식)
영웅 별칭 처리2개 하드코딩HERO_ALIASES 딕셔너리 + hero_mentioned_in_text()
원문 검증단순 문자열 포함 검사정식명 + 원본 표기 + 별칭 통합 검사
역할 위반 예외user_mentioned_heroes만 제외enemy_context_heroes 추가 제외
대화 로깅없음JSONL 파일 저장 (USER/AI/ERROR)
context 업데이트extra_context 별도 병합context_patch 직접 update()

stay 의도 개선은 단순해 보이지만 실제로는 꽤 많은 케이스를 커버한다.

사용자가 영웅 고집을 표현하는 방식은 “원챔”, “고정”, “쓸건데”, “해도 되냐”, “이기고 싶어” 등

제각각이기 때문에 패턴 매칭 방식이 훨씬 견고하다.

로깅은 배포 후 개선에 필요한 데이터를 모으는 가장 기본적인 작업이다.

어떤 질문이 많이 들어오는지, 어떤 상황에서 에러가 나는지 파악해야 다음 개선 방향을 잡을 수 있다.






부록: 트러블슈팅

stay + counter 혼합 문장 — 다른 영웅을 추천하거나 일반화하던 문제

  • 증상 A: “상대 딜러가 솔저랑 소전인데 난 파라를 하고싶어 어떻게 해야지 이기면서 즐기는 게임을 할 수 있을까?”

    → 파라 대신 다른 딜러 영웅을 추천했다.

  • 증상 B: “난 애쉬로 계속 하고 싶은데 둠피때문에 살기가 힘들어 어떻게 해야해?”

    → 둠피스트를 직접 다루지 않고 일반적인 생존 팁만 나열했다.

  • 원인: 두 케이스 모두 문장 안에 적 영웅 이름이 등장해 counter 의도로 분류됐다.

    “하고싶어”, “계속 하고 싶은데”라는 stay 표현이 화이트리스트에 없어서 무시됐다.

  • 해결: detect_wants_to_keep_hero()로 stay 표현을 먼저 확인하고, True이면 다른 키워드보다 우선해서 stay로 분류한다.

    적 영웅이 문장에 있어도 stay 의도가 먼저다.

    stay로 분류된 뒤에는 그 적 영웅(둠피, 솔저, 소전)을 target_enemy로 정상적으로 이어받아

    “애쉬로 둠피스트 상대하는 법”처럼 구체적인 답변이 나온다.

This post is licensed under CC BY 4.0 by the author.