Post

챗봇 안정화

챗봇 안정화

오버워치 챗봇 안정화

PR내용
#8스탯창이 아닌 이미지 조기 반려, 화면 용어 통일
#10스탯창 팀 인원수(5인/6인)를 이미지마다 판별
#12단일 모듈 분리 + 운영 기능 추가
#135대5/6대6 메타를 상수 하나로, 경기 중 캡처 대응, 검색 설정
#14캐시 응답이 세션 타임아웃을 건너뛰던 문제, 메타 6대6 전환






스탯창이 아닌 이미지 조기 반려

TAB 스탯창 스크린샷을 올리면 팀과 영웅은 OpenCV로, 숫자와 코치 피드백은 Gemini로 분석한다.

그런데 analyze_scoreboard_image는 이미지 디코딩(cv2.imdecode)만 성공하면 팀 영역(box) 검출 결과와 상관없이 다음 단계로 넘어갔다.


파란색과 빨간색 팀 영역을 하나도 못 찾아서 모든 행이 unknown인 상태여도 Gemini에 숫자 인식과 피드백 생성을 요청했다.

그래서 스탯창이 아닌 사진(셀카, 다른 게임 화면)을 올리면 실제로는 존재하지 않는 수치와 피드백이 만들어졌다.

“스탯창이 아닌 것 같다”는 안내는 나가지 않았다.


양 팀 행을 만든 직후에 검사를 하나 넣었다.

1
2
3
4
5
# 양쪽 팀 다 파란/빨간 팀 영역을 하나도 못 찾았다면 스탯창 캡처가 아닐
# 가능성이 높다 — 이 상태로 Gemini에 숫자 인식을 요청하면 관계없는
# 이미지에 대해 헛수치/헛피드백만 생성하게 되므로, 여기서 조기에 반려한다.
if ally_detected_count == 0 and enemy_detected_count == 0:
    raise ScoreboardAnalysisError("스탯창 팀 패널을 전혀 찾지 못했습니다(스탯창 캡처가 아닐 가능성).")


chat_scoreboard_ocrScoreboardAnalysisError 핸들러가 이미 있어서 뷰 코드는 바꾸지 않았다.

1
2
3
except ScoreboardAnalysisError as e:
    logger.warning("chat_scoreboard_ocr 분석 실패: %s", e)
    return JsonResponse({"error": "이미지를 분석할 수 없습니다. 스탯창 화면 캡처인지 확인해주세요."}, status=400)


판정 근거가 OpenCV 색상 검출 성공 여부뿐이라 완전히 무관한 사진은 잘 걸러낸다.

다만 스탯창과 색감이 비슷한 다른 UI까지는 구분하지 못한다.



화면 용어 통일

사용자에게 보이는 문구를 “스탯창(TAB)”으로 통일했다.

1
2
3
4
5
6
<button
  type="button"
  class="upload-btn"
  id="uploadBtn"
  title="스탯창(TAB) 스크린샷 업로드"
></button>
1
"next_tips": (parsed.get("next_tips") or "").strip() or "다음 판에는 좀 더 선명한 스탯창 스크린샷으로 다시 확인해보세요.",


바꾼 곳은 업로드 버튼 title, 분석 결과 안내 문구, 업로드 가이드 모달, 온보딩 투어 설명, 에러 메시지, 관리자 페이지 라벨이다.

ScoreboardAnalysisError, chat_scoreboard_ocr, logs/scoreboard_debug/ 같은 코드 식별자와

내부 프롬프트는 사용자에게 노출되지 않아서 그대로 뒀다.






스탯창 팀 인원수 판별

오버워치2가 5vs5에서 6vs6으로 패치됐다가 다시 돌아올 수 있어서 인원수를 한쪽으로 고정할 수 없다.

기존 코드는 팀당 인원수와 행별 역할 순서를 둘 다 상수로 적어뒀다.

1
2
3
4
PLAYERS_PER_TEAM = 5  # 오버워치2 5vs5 기준 — 인식 성공 여부와 무관하게 항상 5슬롯을 만든다.

ROW_ROLES = ["탱커", "딜러", "딜러", "힐러", "힐러"]
ROW_ROLE_CODES = ["tank", "damage", "damage", "support", "support"]


팀 영역을 무조건 5등분하고 5행에 무조건 탱커, 딜러, 딜러, 힐러, 힐러 순으로 역할을 배정했다.

그래서 6인 로스터가 들어오면 6명을 5행으로 나누게 되어 한 행 안에 서로 다른 두 선수가 반씩 걸쳐 잘렸다.


상수를 고정값이 아니라, 인원수를 확정하기 전에 임시로 쓰는 값이자 판별에 실패했을 때 돌아갈 값으로 바꿨다.

1
2
3
4
5
# 팀당 인원수(5/6)는 고정값이 아니라 이미지마다 판별한다(_resolve_roster_size).
# DEFAULT_PLAYERS_PER_TEAM은 인원수 확정 전 임시 분할값이자, 판별에 실패했을
# 때의 폴백값이다.
DEFAULT_PLAYERS_PER_TEAM = 5
ROSTER_SIZE_CANDIDATES = (5, 6)



인원수 판별 : 밝기 신호의 자기상관

인원수 판별과 역할 배분 판별은 서로 다른 방법을 쓴다.

인원수는 역할 문양 인식과 무관하게, 팀 영역의 세로 1px당 밝기 평균 신호만 보고 판단한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
def _row_period_correlation(row_mean: "np.ndarray", period: float) -> float:
    """row_mean(팀 패널의 세로 1px당 밝기 평균 신호)을 자기 자신과 period
    만큼 밀어서 비교하는 정규화 자기상관(피어슨 상관계수, -1~1)이다."""
    n = len(row_mean)
    lag = int(round(period))
    if lag <= 0 or lag >= n:
        return -1.0
    a = row_mean[:-lag] - row_mean[:-lag].mean()
    b = row_mean[lag:] - row_mean[lag:].mean()
    denom = float((a * a).sum() * (b * b).sum()) ** 0.5
    if denom <= 0:
        return -1.0
    return float((a * b).sum() / denom)


가로 한 줄씩 밝기를 평균 내면 세로 위치별 밝기 그래프가 나온다.

닉네임 줄 사이 구분선이 어두워서 행이 바뀌는 자리마다 그래프에 골이 생긴다.


여기서 period는 행 하나의 높이다.

팀 영역 높이를 인원수로 나눈 값이라 5인으로 보면 높이/5, 6인으로 보면 높이/6이 된다.


이 그래프를 period만큼 아래로 밀어서 원래 그래프와 얼마나 겹치는지 본다.

인원수를 맞게 잡았으면 골과 골, 행 중간과 행 중간이 겹쳐서 상관계수가 높게 나온다.

인원수를 틀리게 잡았으면 골이 행 중간에 겹쳐서 낮아진다.


6인 스크린샷으로 테스트해보니 상대팀 기준 5인으로 봤을 때가 0.068, 6인으로 봤을 때가 0.794로 갈렸다.

기존 5인 스크린샷 2장에서는 반대로 5인으로 봤을 때가 높게 나왔다.


1
2
3
4
5
6
7
8
9
def _estimate_roster_size_for_team(cv2, np, image, player_area_box: Dict[str, int]) -> Dict[int, float]:
    x0, y0, x1, y1 = player_area_box["x0"], player_area_box["y0"], player_area_box["x1"], player_area_box["y1"]
    region = image[y0:y1, x0:x1]
    if region.size == 0:
        return {}
    gray = cv2.cvtColor(region, cv2.COLOR_BGR2GRAY)
    row_mean = gray.mean(axis=1).astype("float64")
    h = len(row_mean)
    return {n: _row_period_correlation(row_mean, h / n) for n in ROSTER_SIZE_CANDIDATES}


두 팀 중 상대팀(빨강) 점수를 먼저 본다.

우리팀은 본인 강조 행 같은 노이즈가 있어 신호가 덜 뚜렷하고, 실제 이미지 3장으로 테스트했을 때 상대팀 점수가 우리팀보다 높았다.

1
2
ROSTER_SIZE_MIN_CORRELATION = 0.3
ROSTER_SIZE_MIN_MARGIN = 0.15
1
2
3
4
5
6
7
8
9
10
11
def _pick(scores: Dict[int, float]) -> Optional[int]:
    if not scores:
        return None
    ranked = sorted(scores.items(), key=lambda kv: kv[1], reverse=True)
    best_n, best_score = ranked[0]
    second_score = ranked[1][1] if len(ranked) > 1 else -1.0
    if best_score < ROSTER_SIZE_MIN_CORRELATION:
        return None
    if best_score - second_score < ROSTER_SIZE_MIN_MARGIN:
        return None
    return best_n


절대 점수가 낮거나 두 후보 사이 격차가 작으면 판별을 포기하고 5로 폴백한다.

애매한 상태에서 6인으로 잘못 나누면 행 전체가 어긋나기 때문에 더 흔한 쪽을 기본값으로 뒀다.



역할 배분 판별 : Otsu 이진화 + matchShapes

인원수가 정해지면 5인은 배분이 고정이라 넘어가면 된다.

6인만 탱커와 딜러 배분이 1+3인지 2+2인지 판별하면 된다.

hero_icons/에 탱커(방패)와 딜러(탄창) 문양 참조 이미지를 두고 각 행 왼쪽 문양과 대조한다.


문양을 영웅 아이콘과 같은 방식(그레이스케일 raw/blurred/clahe + TM_CCOEFF_NORMED)으로 비교하면 점수가 갈리지 않는다.

참조 이미지는 회색 배경 위 회색 방패인데 실제 인게임 문양은 팀 색 배경 위 흰색 실루엣이라 밝기 자체가 다르다.

점수를 찍어보니 6등분한 행에서 탱커 0.641, 딜러 0.5 안팎, 힐러 0.47~0.52로 전부 비슷하게 나왔다.


그래서 밝기 대신 윤곽선 모양으로 비교하도록 바꿨다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def _icon_binary_mask(cv2, region_bgr):
    """아이콘 crop을 이진화한다 — 문양은 단색 배경 위의 흰색 실루엣이라
    Otsu 임계값으로 깨끗하게 분리된다."""
    gray = cv2.cvtColor(region_bgr, cv2.COLOR_BGR2GRAY)
    _, binary = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
    if binary.mean() > 127:
        binary = 255 - binary
    return binary


def _icon_largest_contour(cv2, binary):
    contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
    if not contours:
        return None
    return max(contours, key=cv2.contourArea)


cv2.matchShapes는 Hu 모멘트 기반이라 밝기나 색과 무관하게 모양만 본다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def _best_distance(role_code: str) -> Optional[float]:
    templates = role_templates.get(role_code)
    if not templates:
        return None
    return min(
        float(cv2.matchShapes(contour, tmpl, cv2.CONTOURS_MATCH_I1, 0.0))
        for tmpl in templates
    )

tank_distance = _best_distance("tank")
damage_distance = _best_distance("damage")
if tank_distance is not None and tank_distance < ROLE_ICON_TANK_MAX_SHAPE_DISTANCE:
    role_code = "tank"
elif damage_distance is not None and damage_distance < ROLE_ICON_DAMAGE_MAX_SHAPE_DISTANCE:
    role_code = "damage"
else:
    role_code = None


직접 테스트해보니 진짜 탱커 문양과 템플릿 사이 거리는 0.018~0.02, 진짜 딜러는 0.032~0.213이었다.

틀리게 인식한 경우는 전부 0.16 이상이었다.

그래서 두 값 사이에 여유를 두고 임계값을 정했다.

1
2
ROLE_ICON_TANK_MAX_SHAPE_DISTANCE = 0.08
ROLE_ICON_DAMAGE_MAX_SHAPE_DISTANCE = 0.35


힐러 문양 템플릿은 따로 두지 않았다.

탱커와 딜러 어느 쪽 임계값도 통과하지 못하면 힐러로 본다.

힐러는 항상 마지막 2행이라는 규칙과 합치면 충분했다.


판별 결과가 구성 규칙에 안 맞으면 마지막에 보정한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def _score_roster_hypothesis(role_labels: List[str]) -> Tuple[bool, str]:
    """힐러는 항상 마지막 2행이고, 그 앞은 전부 tank/damage여야 하며,
    탱커 수는 5인이면 정확히 1명, 6인이면 1~2명이어야 한다."""
    n = len(role_labels)
    combat_labels = role_labels[: n - 2]
    support_labels = role_labels[n - 2:]
    if any(r == "support" for r in combat_labels):
        return False, "전투(탱커/딜러) 구간에 support로 분류된 행이 있음"
    if any(r != "support" for r in support_labels):
        return False, "힐러 구간(마지막 2행)에 tank/damage로 분류된 행이 있음"
    tank_count = combat_labels.count("tank")
    if n == 5 and tank_count != 1:
        return False, f"5인 로스터인데 탱커로 분류된 행이 {tank_count}개(1개여야 함)"
    if n == 6 and tank_count not in (1, 2):
        return False, f"6인 로스터인데 탱커로 분류된 행이 {tank_count}개(1~2개여야 함)"
    return True, ""



인원수에 맞춰 같이 바꾼 곳

_compute_team_layout에서 인원수를 확정하고 그 값으로 행을 다시 나눈다.

1
2
3
4
5
6
7
8
9
10
11
12
13
roster_size, roster_size_diag = _resolve_roster_size(cv2, np, image, layout)
role_templates = _load_role_icon_templates(cv2, np) if roster_size != DEFAULT_PLAYERS_PER_TEAM else {}
for team in ("ally", "enemy"):
    entry = layout[team]
    player_area_box = entry.get("player_area_box")
    if entry.get("layout_validation_ok") and player_area_box:
        row_y_bounds = _slice_bounds((player_area_box["y0"], player_area_box["y1"]), roster_size)
        entry["row_boxes"] = [...]
        entry["expected_row_height"] = (player_area_box["y1"] - player_area_box["y0"]) / roster_size
        role_codes = _resolve_role_codes_for_team(cv2, np, image, player_area_box, roster_size, role_templates)
    ...
    entry["role_codes"] = role_codes
    entry["role_labels"] = [ROLE_CODE_TO_LABEL[c] for c in role_codes]


_build_team_rows는 상수를 직접 보지 않고 부르는 쪽에서 넘겨준 role_codes를 쓴다.

1
2
3
4
5
6
def _build_team_rows(
    cv2, np, image, row_boxes, templates, team, team_box,
    role_codes: List[str],
):
    ...
    role_code = role_codes[i]


개인 피드백에서 상대팀의 같은 역할을 찾는 부분도 바꿨다.

기존에는 행 순서가 고정이라고 보고 같은 행 번호를 그대로 썼다.

그런데 6인에서는 우리팀 탱커 2명, 상대 탱커 1명처럼 배분이 달라 행 번호가 어긋난다.

1
2
3
4
5
6
if self_known and enemy_ok:
    self_role_code = my_team[self_row_idx]["role_code"]
    for candidate in enemy_team:
        if candidate["role_code"] == self_role_code and candidate["hero"] != "unknown":
            enemy_counterpart = candidate
            break


팀 피드백 프롬프트의 행 순서도 고정 문구에서 실제 순서로 바꿨다.

1
2
- 우리팀( 순서: 탱커, 딜러, 딜러, 힐러, 힐러):
+ 우리팀( 순서: {my_team_role_order}):
1
my_team_role_order = ", ".join(e["role"] for e in my_team)


영웅 인식률이 낮은지 판단하는 기준도 상수 대신 실제 행 수를 쓴다.

1
2
- low_hero_recognition = total_unknown >= (PLAYERS_PER_TEAM * 2) * LOW_HERO_RECOGNITION_RATIO
+ low_hero_recognition = total_unknown >= (len(my_team) + len(enemy_team)) * LOW_HERO_RECOGNITION_RATIO


관리자 페이지의 디버그 이미지 파일명 화이트리스트도 행 번호를 5까지만 받고 있었다.

1
2
- r"(?:ally|enemy)_row_[1-5]_(?:row|hero)_crop\.png|original\.png|coarse_crop\.png"
+ r"(?:ally|enemy)_row_\d+_(?:row|hero)_crop\.png|original\.png|coarse_crop\.png"

진단 카드에 하드코딩돼 있던 “5명 중 N명”도 실제 판별된 인원수를 쓰도록 바꾸고, 팀별 역할 순서(role_labels)를 한 줄 추가했다.






단일 모듈 분리

chat/chatbot_graph.py 한 파일에 상수, 영웅 사전, 규칙 기반 분류, 캐시 응답, 모든 노드, 프롬프트가 전부 있었다.

함수 단위로도 generate_answer_node 664줄, merge_context_node 601줄, llm_parse_context_node 314줄이었다.


주제별 폴더로 나눴다. 의존 방향은 항상 한 방향이고 순환 import는 없다.

1
2
3
4
5
6
7
8
9
chat/
├── (앱 뼈대)  apps.py  urls.py  views.py  admin.py  models.py  migrations/
├── domain/    heroes.py  intent_rules.py  answer_format.py  prompts.py
├── graph/     state.py  nodes_context.py  nodes_retrieval.py  nodes_answer.py
│              pipeline.py  canned.py
├── rag/       vectorstore.py  components.py  llm_utils.py
├── vision/    scoreboard.py  debug_images.py  hero_icons/
├── management/  commands/rag_cli.py  commands/cleanup_chatlogs.py
└── static/  templates/


Django가 정해진 위치에서 찾는 파일(apps.py, urls.py, views.py, admin.py, models.py, migrations/)은 앱 루트에 그대로 뒀다.

이름이 바뀐 파일은 아래와 같다.


기존변경 후
chatbot_graph.pygraph/pipeline.py
graph_state.pygraph/state.py
vision_stats.pyvision/scoreboard.py
rag_class.pyrag/vectorstore.py
chatbot_service.pyrag/components.py


hero_icons/vision/ 안으로 옮겼다.

hero_icons/를 읽는 코드가 scoreboard.py뿐이고, 같은 폴더에 두면 경로 상수를 손댈 필요가 없다.

1
HERO_ICON_DIR = os.path.join(os.path.dirname(__file__), "hero_icons")


앱 내부 import는 전부 절대 경로로 통일했다.

폴더가 여러 겹으로 나뉘면 상대 경로 import는 어느 폴더에서 가져오는지 한눈에 안 보인다.

1
2
3
4
5
6
- from .chatbot_graph import ROLE_LABELS, run_chatbot_graph, try_canned_shortcut
- from .models import ChatLog
- from .vision_stats import analyze_scoreboard_image, ScoreboardAnalysisError
+ from chat.graph.pipeline import role_filter_label, run_chatbot_graph, try_canned_shortcut
+ from chat.models import ChatLog
+ from chat.vision.scoreboard import analyze_scoreboard_image, ScoreboardAnalysisError


pipeline.py에는 그래프 배선과 실행 함수만 남기고, 바깥에서 쓰는 이름들을 한곳에 모아 다시 내보내게 했다.

views.py처럼 파이프라인 바깥에서 부르는 쪽이 내부 모듈 경로를 일일이 알 필요가 없게 하려고 아래와 같이 작성했다.

1
2
3
4
5
6
7
8
from chat.domain.heroes import (  # noqa: F401
    HERO_ALIASES,
    HERO_TO_ROLE,
    ROLE_HEROES,
    ROLE_LABELS,
    find_all_heroes,
    ...
)



테스트용 LLM 교체

테스트를 돌릴 때는 실제 Gemini를 부르지 않는다.

미리 정해둔 답만 돌려주는 테스트용 LLM으로 바꿔 끼워서 돌린다.

get_chatbot_components가 LLM을 가져다주는 함수라, 테스트는 이 함수를 테스트용 LLM을 돌려주는 함수로 바꿔놓는다.


노드가 함수를 가져오는 방식을 바꿨다.

1
2
- from .chatbot_service import get_chatbot_components
+ from chat.rag import components as chatbot_service


함수를 직접 import하면 노드 파일 안에 그 함수를 가리키는 이름이 따로 생긴다.

원본을 바꿔도 노드 파일 안의 이름은 예전 함수를 계속 가리켜서, 노드 파일마다 하나씩 바꿔줘야 한다.

파일이 chatbot_graph.py 하나일 때는 한 번만 바꾸면 됐지만, 노드를 세 파일로 나눈 뒤로는 세 번 반복해야 한다.


모듈만 import하고 chatbot_service.get_chatbot_components()로 부르면 함수를 부르는 순간에 chat.rag.components 안을 다시 찾는다.

그래서 chat.rag.components.get_chatbot_components 한 곳만 바꿔도 모든 노드가 테스트용 LLM을 쓴다.



스탯 판단 프롬프트 정리

같은 판단 기준이 세 곳에 글자 그대로 복사돼 있었다.

영웅 킷 특성, 메르시 피해량 예외, 경감량 해석, 자원 관리형 힐러 규칙이다.


복사돼 있던 위치는 스탯창 팀 피드백 프롬프트, 개인 피드백 프롬프트, 채팅 답변의 stat_analysis_instruction이다.

한 곳만 고치면 같은 판인데 스탯창 카드와 채팅 답변이 서로 다른 기준으로 지적하게 된다.


chat/domain/prompts.pystat_judgement_rules() 한 곳에 규칙을 모아두고, 세 프롬프트가 가져다 쓰도록 바꿨다.



views.py 중복 제거와 입력 길이 상한

“세션 확보 → ERROR 로그 저장 → 예외 삼킴” 블록이 세 곳에 복붙돼 있었다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def ensure_log_session_id(request):
    """대화 묶음 추적용 UUID를 세션에 보장하고 그 값을 돌려준다."""
    if not request.session.get("log_session_id"):
        request.session["log_session_id"] = str(uuid.uuid4())
        request.session.modified = True
    return request.session["log_session_id"]


def save_error_log(request, exc, *, source=None, extra_metadata=None):
    """예외를 ERROR 로그로 남긴다. 오류 처리 경로에서 또 예외가 나
    사용자 응답까지 막는 일이 없도록 실패는 삼킨다."""
    try:
        metadata = {"traceback": traceback.format_exc()}
        ...
    except Exception:
        logger.exception("ERROR 로그 저장 실패")


이미지에는 8MB 상한이 있는데 텍스트에는 상한이 아예 없었다.

그래서 수십 KB 문자열도 그대로 프롬프트에 실렸다.

1
2
3
# 입력 길이 상한. 없으면 수십 KB 문자열이 그대로 LLM 프롬프트에 실린다.
MAX_MESSAGE_LENGTH = 500
MAX_FEEDBACK_REASON_LENGTH = 1000
1
2
3
4
5
if len(message) > MAX_MESSAGE_LENGTH:
    return JsonResponse(
        {"error": f"질문이 너무 깁니다. {MAX_MESSAGE_LENGTH}자 이내로 입력해주세요."},
        status=400,
    )

그래프와 LLM 호출 전에 거절한다.



임베딩 모델 중복 로드 방지

initialize_chatbot()은 이미 만들어져 있는지 확인하고 없으면 만드는 함수인데, 락이 없었다.

서버를 켜자마자 요청 두 개가 동시에 들어오면 둘 다 “아직 없다”를 보고 임베딩 모델(수 GB)을 두 번 로드할 수 있었다.

문서 검색 노드가 ThreadPoolExecutor로 이 함수를 여러 스레드에서 부른다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 임베딩 모델 로드는 수십 초에 GB 단위 메모리를 쓰므로 락으로 최초 1회만 만든다.
_init_lock = threading.Lock()


def initialize_chatbot() -> Tuple[ChatBot, Any, Any]:
    global _chatbot, _retriever, _llm

    # 초기화가 끝난 뒤에는 락 없이 바로 반환한다(대부분의 호출이 이 경로).
    if _chatbot is not None and _retriever is not None and _llm is not None:
        return _chatbot, _retriever, _llm

    with _init_lock:
        # 락을 기다리는 동안 다른 스레드가 초기화를 끝냈을 수 있으므로 다시 확인한다.
        if _chatbot is not None and _retriever is not None and _llm is not None:
            return _chatbot, _retriever, _llm
        ...



스탯창 Gemini 호출 병렬화

스탯창 분석은 Gemini를 세 번 부른다. 숫자 인식 → 팀 피드백 → 개인 피드백 순이다.

팀 피드백과 개인 피드백은 서로를 참조하지 않고 둘 다 앞에서 확정된 숫자에만 의존한다.

그런데 하나씩 순서대로 부르고 있어서 응답을 기다리는 시간이 그대로 더해졌다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 팀 피드백과 개인 피드백은 서로를 참조하지 않고 둘 다 위에서 확정된
# 숫자에만 의존한다 — 직렬로 부르면 Gemini 왕복이 그대로 두 번 쌓여
# 스탯창 응답이 느려지므로 함께 실행한다(retrieve_docs_node와 같은 패턴).
with ThreadPoolExecutor(max_workers=2) as executor:
    team_future = executor.submit(
        _generate_team_feedback, llm, my_team, enemy_team, enemy_ok, low_hero_recognition,
    )
    personal_future = (
        executor.submit(
            _generate_personal_feedback, llm, my_team[self_row_idx], enemy_counterpart,
        )
        if self_known else None
    )

    team_feedback = team_future.result()
    personal_feedback = personal_future.result() if personal_future else None



CLI 분리와 로그 정리 명령

ChatBot 349줄 중 절반 이상이 터미널 CLI 전용(chat_history, answer, run_cli)이었다.

ChatBot 인스턴스는 싱글턴으로 공유되기 때문에 “웹에서 chat_history를 쓰면 안 된다”를 주석으로만 막고 있었다.


CLI를 python manage.py rag_cli로 옮겼다.

대화 기록이 명령 실행 동안만 사는 지역 변수가 되어서 웹 요청과 섞일 수 없다.


로그를 지우려면 관리자 화면에서 직접 지우는 방법밖에 없어서 계속 쌓였다.

ChatLog.metadata에는 턴마다 컨텍스트와 카드 전문이 들어가고

스탯창 분석은 logs/scoreboard_debug/{turn_id}/에 행별 crop 이미지를 남긴다.

1
2
python manage.py cleanup_chatlogs --days 90            # 90일 지난 로그 삭제
python manage.py cleanup_chatlogs --days 90 --dry-run  # 삭제 없이 건수만 확인

DB 로그가 이미 지워지고 디스크 폴더만 남은 고아 폴더(DB 기록 없는 폴더)도 함께 정리한다.


관리자 페이지에만 있던 디버그 폴더 삭제 함수는 chat/vision/debug_images.py로 옮겼다.

관리자 페이지와 정리 명령이 삭제 규칙(경로 검증, 권한 실패 보고)을 그대로 공유해야 하기 때문이다.



그 밖에

Gemini 사용 여부를 소스 상수에서 환경변수로 바꿨다.

인식 품질 문제나 장애로 급히 꺼야 할 때 코드 배포가 필요했다.

1
2
3
ENABLE_GEMINI_STATS_AND_FEEDBACK = os.getenv(
    "ENABLE_GEMINI_STATS_AND_FEEDBACK", "true"
).strip().lower() not in ("0", "false", "no", "off")


ChatLog에 기본 정렬과 복합 인덱스를 넣었다.

1
2
3
4
5
6
7
8
9
10
11
12
class Meta:
    verbose_name = "채팅 로그"
    verbose_name_plural = "채팅 로그"
    # 기본 정렬이 없으면 페이지네이션 경고가 뜨고 순서가 흔들린다.
    ordering = ("-created_at",)
    indexes = [
        # 세션 조회가 항상 (log_session_id, created_at) 조합으로 나간다.
        models.Index(
            fields=["log_session_id", "created_at"],
            name="chatlog_session_created_idx",
        ),
    ]


프론트에서는 지난 답변에 붙어 있던 선택 버튼을 비활성화하도록 했다.

예전 질문에 붙어 있던 버튼을 지금 누르면 엉뚱한 답이 나간다.

1
2
3
4
5
6
function disableOldChoiceButtons() {
  messages.querySelectorAll(".suggest-btn").forEach((btn) => {
    btn.disabled = true;
    btn.classList.add("suggest-btn-used");
  });
}


온보딩 투어 툴팁은 이전 단계의 문구가 담긴 상태로 높이를 재고 그 값으로 위치를 잡았다.

그래서 문구가 길어진 단계에서 툴팁이 설명 대상을 덮어버렸다.


문구를 먼저 넣고 크기를 재도록 순서를 바꿨다.

위아래 어디에도 안 들어가면 더 넓은 쪽에 붙이고 그 공간에 맞춰 스크롤되게 했다.

1
2
3
4
5
6
7
8
9
10
11
12
// 문구를 먼저 넣고 나서 크기를 잰다
tooltip.querySelector('#tourStepLabel').textContent = `[${tourStep + 1}/${tourSteps.length}] ${step.title}`;
tooltip.querySelector('#tourDesc').textContent = step.desc;
...
} else {
  // 위아래 어디에도 다 들어가지 않으면 더 넓은 쪽에 붙이고 그 공간에 맞춰 스크롤되게 한다.
  const useBelow = spaceBelow >= spaceAbove;
  const room = Math.max(useBelow ? spaceBelow : spaceAbove, 120);
  tooltip.style.maxHeight = room + 'px';
  th = Math.min(tooltip.offsetHeight, room);
  top = useBelow ? r.bottom + gap : Math.max(r.top - th - gap, margin);
}






5대5 / 6대6 메타를 상수 하나로

오버워치2 메타가 5대5와 6대6을 왔다갔다 한다.

DEFAULT_ROSTER_SIZE = 6이 있긴 했지만 세 가지가 이 값을 따라가지 않았다.


  • 답변 하단 정정 버튼이 “5대5예요”로 하드코딩돼 있어서, 5대5 메타에서는 “5대5예요”를 다시 누르게 되는 버튼이 됐다
  • 인원수가 프롬프트에 전혀 안 들어가서 LLM은 몇 대 몇인지 모른 채 답했다
  • 정원만큼(6대6에서 6명) 나열해도 여전히 “네 자리는 뭘로?”를 답하려 들었다


상수 이름을 바꾸고, 인원수를 바꾸는 유일한 고정점이라고 주석에 적었다.

1
2
3
4
5
6
7
8
9
10
# ─────────────────────────────────────────────────────────────────────────
# 현재 패치 메타의 팀 인원수. 5vs5 ↔ 6vs6를 바꾸는 유일한 고정점이다.
# 패치가 바뀌면 이 값만 5 또는 6으로 고치면 역할 좁히기, 답변 프롬프트에
# 들어가는 규격 설명, 답변 하단 정정 버튼("5대5예요"/"6대6이에요")이 전부
# 따라 바뀐다. 다른 곳에 5나 6을 직접 쓰지 마라.
#
# 인원수는 추측하지 않는다 — 사용자가 직접 말하거나("5대5야") 답변 하단
# 버튼을 누르기 전까지는 항상 이 값을 쓴다.
# ─────────────────────────────────────────────────────────────────────────
CURRENT_META_ROSTER_SIZE = 5


예전 이름은 별칭으로 남기지 않았다.

같은 값에 이름이 둘이면 한 곳만 고치면 된다는 말이 성립하지 않는다.


인원수를 다루는 함수들도 한곳에 모았다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
def resolve_roster_size(declared: Optional[int]) -> int:
    """이번 답변에 실제로 적용할 인원수. 사용자가 밝힌 값이 있으면 그 값,
    없으면 현재 메타(CURRENT_META_ROSTER_SIZE)."""
    if declared in ROSTER_ROLE_RANGES:
        return declared
    return CURRENT_META_ROSTER_SIZE


def alternate_roster_size(roster_size: Optional[int] = None) -> int:
    """지금 적용 중인 인원수의 반대쪽(5↔6). 답변 하단 정정 버튼용."""
    return 5 if resolve_roster_size(roster_size) == 6 else 6


def roster_size_button_label(roster_size: int) -> str:
    """정정 버튼 라벨. 받침 유무에 따라 조사가 달라진다
    (5="""5대5예요", 6="""6대6이에요")."""
    suffix = "예요" if roster_size == 5 else "이에요"
    return f"{roster_size_label(roster_size)}{suffix}"


def roster_role_quota_text(roster_size: int) -> str:
    """프롬프트에 넣는 역할 정원 설명. 예: "탱커 1명, 딜러 2명, 힐러 2명"
    (6인은 "탱커 1~2명, 딜러 2~3명, 힐러 2명")."""
    ...



정정 버튼 라벨을 메타에 맞추기

can_be_five_vs_five는 5가 그대로 적혀 있어서, 인원수를 인자로 받도록 바꿨다.

1
2
3
4
5
6
7
8
9
10
11
- def can_be_five_vs_five(ally_heroes: List[str]) -> bool:
-     counts = count_roles(ally_heroes)
-     if len(ally_heroes or []) >= 5:
-         return False
-     return _fits_roster(counts, 5)
+ def can_be_roster_size(ally_heroes: List[str], roster_size: int) -> bool:
+     if roster_size not in ROSTER_ROLE_RANGES:
+         return False
+     if len(ally_heroes or []) >= roster_size:
+         return False
+     return _fits_roster(count_roles(ally_heroes), roster_size)


버튼 라벨과 값은 지금 적용한 인원수의 반대쪽으로 만든다.

1
2
3
4
5
6
7
8
9
10
11
12
# 인원수 정정 버튼. 조합을 기준으로 답한 턴에만 붙이고, 라벨은 지금 적용한
# 인원수의 반대쪽이다 — 6대6으로 답했으면 "5대5예요", 5대5로 답했으면
# "6대6이에요". 반대쪽 규격으로는 성립할 수 없는 조합이면 숨긴다.
# 역할 좁히기 여부와 무관하게 인원수는 답변 내용을 바꾸므로 항상 정정할 수 있게 둔다.
if team_comp_analysis:
    other_roster_size = alternate_roster_size(effective_roster_size)
    if can_be_roster_size(ally_team, other_roster_size):
        answer_choice_buttons.append({
            "label": roster_size_button_label(other_roster_size),
            "value": str(other_roster_size),
            "type": "roster_size",
        })


버튼을 붙이는 조건도 옮겼다.

예전에는 역할 좁히기 블록 안에 있어서 사용자가 역할을 이미 밝히면 인원수는 정정할 수 없었다.

인원수는 역할과 상관없이 답 내용을 바꾸기 때문에, 조합을 기준으로 답한 턴이면 항상 붙게 했다.


프론트와 chat_api는 값을 그대로 되돌려주는 구조라 화면 코드는 바꾸지 않았다.



인원수를 프롬프트에 넣기

추천 카드와 조합 평가 답변 양쪽에 한 팀이 몇 명이고 역할별 정원이 몇 명인지를 넣었다.

1
2
3
4
5
6
7
8
9
# 인원수는 판마다/패치마다 달라지므로 규격을 프롬프트에 명시한다.
roster_size = resolve_roster_size(state.get("roster_size_effective"))
open_slots = max(1, roster_size - len(ally_team))
context_block = f"""
이번 판은 {roster_size_label(roster_size)}이다(한 팀 {roster_size}명, 역할 정원은
{roster_role_quota_text(roster_size)}).
아군 조합(이미 정해진 인원): {ally_display}
상대 조합: {', '.join(display_enemy_team) if display_enemy_team else '없음'}
사용자를 포함해 아직 {open_slots}자리가 비어 있다."""


추천 카드 프롬프트에 “남은 한 자리”라고 하드코딩돼 있던 문구도 실제 남은 자리 수로 바꿨다.

6대6에서 3명만 말했으면 남은 건 두 자리다.



정원이 찬 조합은 조합 평가

우리팀을 인원수만큼 나열했으면 사용자가 채울 자리가 없다.

예전에는 정원이 찬 조합도 역할을 못 좁혔을 때와 똑같이 세 역할 전부로 빠져서, 자리가 없는 팀에 탱커/딜러/힐러 픽을 다 추천했다.

1
2
3
# 말한 아군만으로 정원이 다 찼으면 사용자가 채울 자리가 없다 — "내가 뭘
# 고를까"가 아니라 완성된 팀 조합 자체를 평가해달라는 질문이다.
full_roster = len(ally_heroes or []) >= roster_size


정원이 찼으면 역할 후보를 비워 역할 좁히기와 역할 버튼을 끈다.

1
2
3
4
5
6
roster_is_full = bool(team_comp_analysis and team_comp_analysis["is_full_roster"])
team_comp_role_candidates = (
    list(team_comp_analysis["candidate_roles"])
    if team_comp_analysis and not roster_is_full
    else []
)


추천 요청 표현이 있어도 카드를 만들지 않는다.

1
2
3
4
5
6
if (
    (is_team_comp_question or composition_reask)
    and wants_composition_recommendation(effective_message)
    and not roster_is_full
):
    recommend_card_mode = "composition"


답변 프롬프트에는 개인 픽을 다루지 말라는 지시를 넣는다.

1
2
3
4
5
6
7
# 정원이 이미 찬 조합은 "내 자리"라는 개념 자체가 없다.
if state.get("roster_is_full"):
    roster_line += (
        f"\n    아군 {comp_roster_size}명이 모두 정해진 완성된 조합이라 "
        "사용자가 채울 빈자리가 없다. 사용자 개인이 무엇을 골라야 할지는 "
        "다루지 말고, 팀 조합 전체가 어떤지를 평가해라."
    )



상태 필드를 두 개로 분리

1
2
3
4
5
# roster_size는 사용자가 직접 밝힌 값(없으면 None), roster_size_effective는
# 실제로 답변에 적용한 값(밝히지 않았으면 현재 메타). 답변 프롬프트는 후자를 쓴다.
"roster_size": roster_size,
"roster_size_effective": effective_roster_size,
"roster_is_full": roster_is_full,


메타 기본값을 세션에 저장하면 나중에 상수를 바꿔도 세션에 남아 있는 예전 값이 계속 우선한다.

그래서 두 값을 합치면 안 된다.



경기 진행 중 캡처의 팀 영역 검출

경기 종료 화면 캡처는 잘 되는데 경기 진행 중 전체화면 캡처에서 우리팀 줄을 제대로 못 나눴다.

원인이 두 가지였다.


  • 우리팀 맨 윗줄인 본인 강조 행이 하얗게(탈채도) 표시된다. 파란 패널 검출이 채도 하한을 쓰기 때문에 이 행이 마스크에서 빠지고, 우리팀 영역이 2행부터 시작한다
  • 경기 중 화면은 배경 자체가 붉은 계열로 읽힌다. 빨간 마스크의 가장 큰 영역이 화면의 71%가 되어 상대 패널이 배경과 통째로 뭉친다


우측 상단 배너 색은 모드마다 다르다. 빠른대전은 파랑, 아케이드는 녹색, 경쟁전은 빨강이다.

특정 색 UI를 지우는 방식은 다른 모드에서 또 깨진다.

배너가 아니라 배경 전체가 패널과 같은 색 범위로 들어오는 게 원인이기 때문이다.


색이 아니라 “밝은 표 vs 어두운 배경”이라는 구조로 표를 찾는 경로를 추가했다.

1
2
3
4
5
6
7
8
def _detect_structural_table_boxes(cv2, np, image):
    """표는 가운데에 있으므로 가운데 세로 띠의 행별 밝기에서 위쪽 밝은
    구간=아군, 아래쪽 밝은 구간=상대로 본다(둘 사이 어두운 구간이 VS 구분선)."""
    h, w = image.shape[:2]
    gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY).astype(np.float32)
    cx0, cx1 = int(w * STRUCTURAL_CENTER_XBAND[0]), int(w * STRUCTURAL_CENTER_XBAND[1])
    row_profile = cv2.GaussianBlur(gray[:, cx0:cx1].mean(axis=1).reshape(-1, 1), (1, 9), 0).ravel()
    ...


임계값은 프로파일 자체의 15/85 백분위수 기반이라 절대 밝기에 의존하지 않는다.

1
2
3
4
5
STRUCTURAL_CENTER_XBAND = (0.35, 0.65)
# 밝기/컬럼 임계값 = lo + ratio*(hi-lo), lo/hi는 프로파일의 15/85 백분위수.
STRUCTURAL_BRIGHT_PCT = (15, 85)
STRUCTURAL_ROW_THR_RATIO = 0.45
STRUCTURAL_COL_THR_RATIO = 0.40

우측 상단 배너는 가운데 띠 밖이고 밴드 y범위 밖이라 자연히 빠진다. 배너 색과 무관하다.


구조로 찾은 결과가 색으로 찾은 결과를 무조건 대체하지는 않는다.

잘 되던 캡처까지 새 방식으로 바꾸면 멀쩡하던 인식이 깨질 수 있다.

색 team_box와 구조 박스의 IoU를 팀별로 재서, 둘 다 기준 이상이면 색 결과를 그대로 둔다.

1
2
3
4
# 색 team_box가 구조 박스와 이 IoU 미만으로 겹치면 색 검출이 깨진 것으로 보고
# 구조 기반 박스로 교체한다. 0.75면 경기 종료 캡처(IoU 0.9+)는 색을 그대로 쓰고,
# 경기 중 캡처(상대 IoU 0.3 수준)만 구조로 넘어간다.
STRUCTURAL_FALLBACK_MIN_IOU = 0.75
1
2
3
4
5
6
7
8
9
10
healthy = True
for team, sbox in struct_by_team.items():
    entry = layout.get(team) or {}
    cbox = entry.get("team_box") if entry.get("layout_validation_ok") else None
    iou = _box_iou(cbox, sbox)
    diag[f"{team}_iou"] = round(iou, 3)
    if iou < STRUCTURAL_FALLBACK_MIN_IOU:
        healthy = False
if healthy:
    return layout, diag


교체는 인원수 판별과 행 분할 직전에 끼워 넣었다.

그래서 교체된 박스가 기존 헤더 제외, 인원수 판별, 행 분할, 영웅 인식 경로를 그대로 탄다.

1
2
3
layout, structural_diag = _maybe_replace_with_structural(cv2, np, image, layout)

roster_size, roster_size_diag = _resolve_roster_size(cv2, np, image, layout)


구조 박스는 위쪽에 칼럼 제목 바가 포함돼 있다.

밴드 상단부에서 수평 에지가 가장 강한 y를 헤더 경계로 잡는다. 헤더를 잘라내는 기준도 색과 무관하다.

1
2
3
gy = np.abs(cv2.Sobel(gray, cv2.CV_32F, 0, 1, ksize=3)).mean(axis=1)
top = max(4, int((y1 - y0) * STRUCTURAL_HEADER_SEARCH_RATIO))
return int(np.argmax(gy[4:top])) + 4



RAG 검색 설정 반영

chunk 800 기준으로 다시 잰 결과를 코드에 반영했다.

1
2
3
4
- chunk_overlap=50,
- search_k=7,
+ chunk_overlap=0,
+ search_k=10,
1
2
3
4
5
# overlap 0: 겹침을 주면 같은 내용의 chunk가 늘어 상위 k를 중복이 차지하고
#   서로 다른 정답 섹션이 밀려난다(k=10에서 Hit@k 0.840 → 0.800).
#   분할 경계 376개를 전수 조사한 결과 문장이 끊긴 사례는 0건이라 겹침이 필요 없었다.
# search_k 10: k=7 대비 Hit@k가 0.680 → 0.840으로 오른다.
# 이 셋 중 하나라도 바꾸면 ChromaDB를 재구축해야 한다(chunk 경계가 달라진다).


검색에서 통째로 빼는 섹션도 추가했다.

1
2
3
4
# "6. 영웅 수 검증"은 영웅 이름만 나열해둔 검산용 메모라 답변 근거가 될 내용이
# 없는데, 모든 영웅 이름이 한 chunk에 들어있어서 어떤 영웅 질문에도 유사도가
# 높게 잡힌다. 실제 평가에서 카운터 질문의 검색 1위가 이 chunk였다.
EXCLUDED_H1_SECTIONS = {"6. 영웅 수 검증"}
1
2
3
4
kept_docs = [
    doc for doc in header_docs
    if doc.metadata.get("H1") not in EXCLUDED_H1_SECTIONS
]

상위 k 자리를 하나 차지하고 정보는 주지 않으니, 원래 들어왔어야 할 상성 문서가 밀려난다.

측정 과정은 따로 정리한 글에 적었다.






캐시 응답이 세션 타임아웃을 건너뛰던 문제

웰컴 화면 예시 버튼 5개는 그래프를 타지 않고 미리 만들어둔 답을 즉시 돌려준다.


조합 추천 버튼을 누르면 카드는 정상적으로 나온다.

답변 아래에는 추천 질문 3개가 같이 나오는데, 그중 “우리 조합 운영법 알려줘”를 눌렀더니 방금 카드에 나온 우리팀 조합이 아니라 전혀 다른 영웅들을 우리팀이라고 답했다.

답변 전체가 갑자기 다른 영웅 개인 코칭이 되고, 우리팀 영웅 이름 하나는 “다른 영웅”으로 바뀌어 나갔다.


원인이 두 가지 겹쳤다.



1. 세션 타임아웃 검사가 그래프 안에만 있었다

merge_context_node는 마지막 메시지로부터 10분이 지나면 대화 컨텍스트를 통째로 비운다.

그런데 캐시 응답은 그래프를 타지 않으므로 타임아웃 검사를 건너뛰면서 last_message_ts만 지금 시각으로 갱신한다.


그래서 며칠 전 세션이 “방금까지 대화 중”으로 되살아나 그대로 살아남았다.

캐시 다음의 첫 질문이 예전 current_heroally_team을 가지고 답해버린다.


로그에서 last_message_ts는 지금인데 ally_team_ts는 약 6.6일 전이었다.

그 조합은 이번 대화에서 한 번도 말한 적 없는 값이었다.


판단을 함수로 빼서 두 경로가 같은 검사를 쓰게 했다.

1
2
3
4
5
6
7
8
9
10
11
12
# 마지막 메시지로부터 이만큼 지나면 새 판으로 보고 coach_context를 통째로 비운다.
SESSION_TIMEOUT_SECONDS = 10 * 60


def is_session_timed_out(context: Dict[str, Any], now_ts: Optional[float] = None) -> bool:
    """merge_context_node 안에 지역 변수로 있던 판단을 함수로 뺐다 — 웰컴 버튼
    캐시 응답(canned)은 그래프를 타지 않아 이 검사를 건너뛰면서 세션의
    `last_message_ts`만 지금 시각으로 갱신한다."""
    last_message_ts = context.get("last_message_ts")
    if not last_message_ts:
        return False
    return ((now_ts if now_ts is not None else time.time()) - last_message_ts) > SESSION_TIMEOUT_SECONDS


chat_api가 캐시와 그래프로 갈라지기 전에 먼저 호출한다.

1
2
3
4
5
6
7
8
9
10
# 세션 타임아웃(10분) 검사는 원래 그래프 안(merge_context_node)에만 있었다.
# 그런데 캐시 응답은 그래프를 타지 않으면서 last_message_ts만 지금 시각으로
# 갱신하므로, 캐시로 시작한 대화는 며칠 전 세션을 그대로 물고 간다. 그래서
# 두 경로가 갈리기 전인 여기서 먼저 본다.
if is_session_timed_out(conversation_context):
    logger.info(
        "[SESSION TIMEOUT] 마지막 메시지로부터 %.0f초 경과 — 컨텍스트 초기화 (새 게임으로 간주)",
        time.time() - conversation_context.get("last_message_ts", 0),
    )
    conversation_context = {}

그래프 안의 검사는 그대로 뒀다. 앞에서 비웠으면 last_message_ts가 없어 자연히 통과한다.



2. 조합 캐시가 우리팀 조합을 세션에 안 남겼다

캐시는 답변 문자열만 미리 만들어둔 것이지 그 턴이 없었던 게 아니다.

실제 파이프라인이 세션에 채우는 값을 캐시도 똑같이 채워야 뒤에 오는 질문이 이어받는다.


enemy_teamfocus_heroes만 남기고 있어서, 세션이 깨끗해져도 우리팀 자리가 비어 상대 조합이 우리팀으로 흘러들 수 있다.

1
2
3
4
5
6
7
8
9
context_patch = {
    **_canned_base_context_patch(message, answer_style, "composition"),
    "enemy_team": list(CANNED_COMPOSITION_ENEMY_LIST),
    # 아군 조합도 상대 조합과 똑같이 세션에 남긴다 — 실제 파이프라인이
    # merge_context_node에서 하는 일이다(ally_team/ally_team_ts).
    "ally_team": list(CANNED_COMPOSITION_ALLY_LIST),
    "ally_team_ts": time.time(),
    "focus_heroes": [h["hero"] for h in CANNED_COMPOSITION_HEROES],
}

나머지 캐시 4개(카운터, 맵, 스탯, 영웅 유지)는 우리팀 조합을 다루지 않아서 고칠 게 없었다.



메타 전환

메타가 6대6이 되면서 상수 한 줄만 바꿨다.

1
2
- CURRENT_META_ROSTER_SIZE = 5
+ CURRENT_META_ROSTER_SIZE = 6


역할 좁히기 기준(탱1~2/딜2~3/힐2), 답변과 카드 프롬프트의 규격 설명, 답변 하단 정정 버튼 라벨(이제 “5대5예요”),

정원이 찬 조합 판정(이제 6명 기준)이 전부 따라간다.

스탯창 분석은 이 상수와 무관하게 이미지마다 인원수를 판별하므로 영향이 없다.

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