From b7c6398f3cc72fa5f4e10e6aba90199210045fa7 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=EB=AC=B8=EC=A7=84=ED=98=81?=
Date: Tue, 22 Sep 2026 16:51:15 +0900
Subject: [PATCH 1/2] =?UTF-8?q?#784=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?=
=?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=EC=97=90=20AI=20=EC=A1=B0=EA=B5=90?=
=?UTF-8?q?=20=EC=82=AC=EC=9A=A9=20=ED=98=84=ED=99=A9=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
관리자 페이지에 AI 조교 사용량과 학습 효과를 볼 수 있는 화면을 추가합니다.
---
backend/problem/tests.py | 115 ++++
backend/problem/urls/admin.py | 2 +
backend/problem/views/ai_hint_stats.py | 388 +++++++++++++
frontend/src/i18n/admin/en-US.js | 49 ++
frontend/src/pages/admin/api.js | 5 +
.../src/pages/admin/components/SideMenu.vue | 3 +
frontend/src/pages/admin/router.js | 6 +
.../pages/admin/views/general/AIHintStats.vue | 543 ++++++++++++++++++
frontend/src/pages/admin/views/index.js | 2 +
9 files changed, 1113 insertions(+)
create mode 100644 backend/problem/views/ai_hint_stats.py
create mode 100644 frontend/src/pages/admin/views/general/AIHintStats.vue
diff --git a/backend/problem/tests.py b/backend/problem/tests.py
index 35edcacb4..7c56d696c 100644
--- a/backend/problem/tests.py
+++ b/backend/problem/tests.py
@@ -20,6 +20,7 @@
from .tasks import update_weekly_stats, update_bonus_problem
from contest.models import Contest, ContestRuleType
from contest.tests import DEFAULT_CONTEST_DATA
+from submission.models import JudgeStatus, Submission
from utils.constants import CONTEST_PASSWORD_SESSION_KEY
from .llm_hint import (CLUSTER_VLLM_CHAT_COMPLETIONS_URL, LOCAL_VLLM_CHAT_COMPLETIONS_URL,
VLLM_CONNECT_TIMEOUT_SEC, VLLM_MODEL, VLLM_STREAM_READ_TIMEOUT_SEC,
@@ -1286,3 +1287,117 @@ def test_update_bonus_problem_with_database_error(self):
with mock.patch('problem.models.Problem.objects.filter', side_effect=DatabaseError("Database error")):
with self.assertRaises(DatabaseError):
update_bonus_problem()
+
+
+class AIHintStatsAPITest(APITestCase):
+ """집계 쿼리가 실제 Postgres 에서 도는지, 지표가 정의대로 나오는지 확인한다.
+
+ 윈도우 함수와 FILTER, percentile_disc 등 Postgres 전용 문법을 쓰므로
+ 이 테스트가 없으면 배포 후에야 오류를 알게 된다.
+ """
+
+ def setUp(self):
+ self.create_school_fixtures(college_id=1, college_name="Test", department_id=1, department_name="Test")
+ self.url = self.reverse("ai_hint_stats_api")
+ self.admin = self.create_super_admin()
+ self.problem = ProblemCreateTestBase.add_problem(DEFAULT_PROBLEM_DATA, self.admin)
+ self.user = self.create_user("hint@test.com", "hintuser", "hint1234!", login=False)
+
+ def _log(self, minutes_ago, content="[1단계] 힌트"):
+ log = ProblemAIHintLog.objects.create(user=self.user, problem=self.problem, hint_content=content)
+ # auto_now_add 라서 생성 후에 시각을 덮어써야 한다.
+ ProblemAIHintLog.objects.filter(id=log.id).update(
+ created_at=timezone.now() - timedelta(minutes=minutes_ago))
+ return log
+
+ def test_requires_super_admin(self):
+ self.client.logout()
+ self.create_user("normal@test.com", "normaluser", "normal1234!")
+ resp = self.client.get(self.url)
+ self.assertFailed(resp)
+
+ def test_empty_range_returns_zeros(self):
+ resp = self.client.get(self.url)
+ self.assertSuccess(resp)
+ data = resp.data["data"]
+ self.assertEqual(data["summary"]["turns"], 0)
+ self.assertEqual(data["depth"]["distribution"], [])
+ self.assertEqual(len(data["hourly"]), 24)
+
+ def test_session_split_by_gap(self):
+ # 10분 간격 두 건은 한 대화, 하루 전 한 건은 별도 대화로 묶여야 한다.
+ self._log(minutes_ago=20)
+ self._log(minutes_ago=10)
+ self._log(minutes_ago=60 * 24)
+
+ resp = self.client.get(self.url)
+ self.assertSuccess(resp)
+ data = resp.data["data"]
+
+ self.assertEqual(data["summary"]["turns"], 3)
+ self.assertEqual(data["summary"]["sessions"], 2)
+ self.assertEqual(data["summary"]["users"], 1)
+ self.assertEqual(data["depth"]["avg_turns"], 1.5)
+ self.assertEqual(data["depth"]["single_turn_rate"], 50.0)
+
+ def test_failure_and_label_rate(self):
+ self._log(minutes_ago=30, content="[1단계] 정상 응답")
+ self._log(minutes_ago=20, content="머리말 없는 응답")
+ self._log(minutes_ago=10, content="")
+
+ resp = self.client.get(self.url)
+ self.assertSuccess(resp)
+ quality = resp.data["data"]["quality"]
+
+ self.assertEqual(quality["turns"], 3)
+ self.assertEqual(quality["empty"], 1)
+ self.assertEqual(quality["failure_rate"], 33.3)
+ # 라벨 준수율은 빈 응답을 뺀 2건 중 1건이다.
+ self.assertEqual(quality["labeled"], 1)
+ self.assertEqual(quality["label_rate"], 50.0)
+
+ def test_effect_and_top_problems(self):
+ self._log(minutes_ago=30)
+ Submission.objects.create(problem=self.problem, user_id=self.user.id,
+ username=self.user.username, code="x",
+ result=JudgeStatus.ACCEPTED, language="Python3")
+
+ resp = self.client.get(self.url)
+ self.assertSuccess(resp)
+ data = resp.data["data"]
+
+ self.assertEqual(data["effect"]["with_hint"]["pairs"], 1)
+ self.assertEqual(data["effect"]["with_hint"]["accepted"], 1)
+ self.assertEqual(data["effect"]["with_hint"]["rate"], 100.0)
+ self.assertEqual(data["top_problems"][0]["display_id"], self.problem._id)
+ self.assertEqual(data["top_problems"][0]["turns"], 1)
+
+ def test_limit_reached_counts_per_problem_not_per_session(self):
+ # 5회를 여러 날에 걸쳐 나눠 써도 한도는 채운 것이다.
+ for days in range(5):
+ self._log(minutes_ago=60 * 24 * days)
+
+ resp = self.client.get(self.url)
+ self.assertSuccess(resp)
+ depth = resp.data["data"]["depth"]
+
+ # 세션은 5개로 쪼개지지만 (사용자, 문제) 기준으로는 한도 도달이다.
+ self.assertEqual(depth["sessions"], 5)
+ self.assertEqual(depth["single_turn_rate"], 100.0)
+ self.assertEqual(depth["limit_reached_rate"], 100.0)
+
+ def test_adoption_rate_never_exceeds_100(self):
+ # 힌트만 받고 제출하지 않은 경우가 있어도 채택률이 100%를 넘으면 안 된다.
+ self._log(minutes_ago=30)
+
+ resp = self.client.get(self.url)
+ self.assertSuccess(resp)
+ summary = resp.data["data"]["summary"]
+
+ self.assertEqual(summary["hinted_pairs"], 1)
+ self.assertEqual(summary["engaged_pairs"], 1)
+ self.assertEqual(summary["adoption_rate"], 100.0)
+
+ def test_invalid_date_is_rejected(self):
+ resp = self.client.get(self.url + "?start=2026-13-99")
+ self.assertFailed(resp)
diff --git a/backend/problem/urls/admin.py b/backend/problem/urls/admin.py
index 2ff626ee1..bceabd109 100644
--- a/backend/problem/urls/admin.py
+++ b/backend/problem/urls/admin.py
@@ -3,8 +3,10 @@
from ..views.admin import (ContestProblemAPI, ProblemAPI, TestCaseAPI, MakeContestProblemPublicAPIView, CompileSPJAPI,
AddContestProblemAPI, ExportProblemAPI, ImportProblemAPI, FPSProblemImport,
ProblemIdDuplicateCheckAPI, ImportContestProblemAPI)
+from ..views.ai_hint_stats import AIHintStatsAPI
urlpatterns = [
+ re_path(r"^ai_hint_stats/?$", AIHintStatsAPI.as_view(), name="ai_hint_stats_api"),
re_path(r"^test_case/?$", TestCaseAPI.as_view(), name="test_case_api"),
re_path(r"^compile_spj/?$", CompileSPJAPI.as_view(), name="compile_spj"),
re_path(r"^problem/?$", ProblemAPI.as_view(), name="problem_admin_api"),
diff --git a/backend/problem/views/ai_hint_stats.py b/backend/problem/views/ai_hint_stats.py
new file mode 100644
index 000000000..1bb44e833
--- /dev/null
+++ b/backend/problem/views/ai_hint_stats.py
@@ -0,0 +1,388 @@
+"""AI 조교 사용 현황 집계.
+
+`ProblemAIHintLog` 에는 (user, problem, hint_content, created_at) 네 가지만 쌓인다.
+단계 번호, 모델 이름, 응답 시간 같은 컬럼은 없다.
+그래서 지표는 전부 이 네 가지에서 파생시킨다.
+
+핵심 추상은 **세션과 턴**이다. 지금은 문제 하나당 힌트를 최대 5번 받는 구조지만,
+나중에 챗봇으로 바뀌어도 "한 번 앉아서 주고받은 묶음(세션)"과 "그 안의 발화(턴)"라는
+개념은 그대로 유지된다. 단계(1~5)를 1급 개념으로 쓰지 않는 이유다.
+
+세션 경계는 시간 간격으로 정의한다. 같은 (user, problem) 에서 연속된 로그의 간격이
+SESSION_GAP_MINUTES 를 넘으면 다른 세션으로 본다. 며칠 뒤 같은 문제를 다시 물어본 것을
+한 대화로 묶지 않기 위함이다.
+"""
+
+import re
+from datetime import datetime, timedelta
+
+from django.conf import settings
+from django.db import connection
+from django.utils import timezone
+
+from account.decorators import super_admin_required
+from utils.api import APIView
+
+# 이 간격을 넘겨 다시 요청하면 새 대화로 센다.
+# 2026-09 운영 데이터(연속 요청 208건) 기준으로 정했다. 87.9%가 15분 이내에 몰려 있고
+# 15~30분 구간은 0건이라 그 사이가 자연스러운 경계다. 15분과 30분은 결과가 같다.
+SESSION_GAP_MINUTES = 30
+
+# 문제당 힌트 횟수 제한. 챗봇 전환 시 사라지는 값이므로 여기 한 곳에만 둔다.
+HINT_LIMIT_PER_PROBLEM = 5
+
+# 프롬프트가 요구하는 응답 머리말. 예) "[2단계] ..."
+# 이것도 단계 체계가 사라지면 함께 없어질 한시적 품질 지표다.
+STAGE_LABEL_PATTERN = r"^\[[0-9]단계\]"
+
+# 일반 사용자 로그만 집계한다. 관리자는 횟수 제한을 받지 않아 분포를 왜곡한다.
+_BASE_LOGS = """
+ SELECT l.id, l.user_id, l.problem_id, l.created_at, l.hint_content
+ FROM problem_ai_hint_log l
+ JOIN "user" u ON u.id = l.user_id
+ WHERE u.admin_type = 'Regular User'
+ AND u.is_disabled = false
+ AND l.created_at >= %(start)s
+ AND l.created_at < %(end)s
+"""
+
+# 로그에 세션 번호와 세션 내 턴 번호를 붙인다. 아래 집계들이 모두 이 결과에서 파생된다.
+_TURNS_CTE = f"""
+WITH logs AS ({_BASE_LOGS}),
+gaps AS (
+ SELECT *,
+ CASE
+ WHEN LAG(created_at) OVER w IS NULL
+ OR created_at - LAG(created_at) OVER w > %(gap)s::interval
+ THEN 1 ELSE 0
+ END AS new_session
+ FROM logs
+ WINDOW w AS (PARTITION BY user_id, problem_id ORDER BY created_at)
+),
+turns AS (
+ SELECT *,
+ SUM(new_session) OVER (
+ PARTITION BY user_id, problem_id ORDER BY created_at
+ ) AS session_no
+ FROM gaps
+)
+"""
+
+
+def _rows(sql, params):
+ with connection.cursor() as cursor:
+ cursor.execute(sql, params)
+ columns = [col[0] for col in cursor.description]
+ return [dict(zip(columns, row)) for row in cursor.fetchall()]
+
+
+def _one(sql, params):
+ rows = _rows(sql, params)
+ return rows[0] if rows else {}
+
+
+def _rate(numerator, denominator, digits=1):
+ if not denominator:
+ return 0.0
+ return round(100.0 * numerator / denominator, digits)
+
+
+class AIHintStatsAPI(APIView):
+ """AI 조교 사용량과 효과를 한 번에 돌려준다.
+
+ 쿼리 파라미터
+ start, end : YYYY-MM-DD. 생략하면 최근 12개월.
+ """
+
+ @super_admin_required
+ def get(self, request):
+ try:
+ start, end = self._parse_range(request)
+ except ValueError:
+ return self.error("날짜가 올바르지 않습니다. YYYY-MM-DD 형식이어야 하고 시작일이 종료일보다 앞서야 합니다.")
+
+ params = {
+ "start": start,
+ "end": end,
+ "gap": f"{SESSION_GAP_MINUTES} minutes",
+ "limit": HINT_LIMIT_PER_PROBLEM,
+ "label": STAGE_LABEL_PATTERN,
+ # USE_TZ=True 이면 Django 가 DB 세션 타임존을 UTC 로 잡는다.
+ # 월 경계와 시간대 분포는 현지 시각 기준이어야 하므로 명시적으로 변환한다.
+ "tz": settings.TIME_ZONE,
+ }
+
+ return self.success({
+ "range": {
+ "start": start.date().isoformat(),
+ "end": (end - timedelta(days=1)).date().isoformat(),
+ "session_gap_minutes": SESSION_GAP_MINUTES,
+ },
+ "summary": self._summary(params),
+ "monthly": self._monthly(params),
+ "hourly": self._hourly(params),
+ "depth": self._depth(params),
+ "effect": self._effect(params),
+ "quality": self._quality(params),
+ "top_problems": self._top_problems(params),
+ })
+
+ @staticmethod
+ def _parse_range(request):
+ raw_end = request.GET.get("end")
+ raw_start = request.GET.get("start")
+
+ # make_aware 는 naive 만 받으므로 계산이 끝날 때까지 naive 로 다룬다.
+ if raw_end:
+ end = datetime.strptime(raw_end, "%Y-%m-%d")
+ else:
+ end = timezone.localtime().replace(tzinfo=None)
+ # end 는 그 날을 포함해야 하므로 다음 날 0시를 배타 상한으로 쓴다.
+ end = end.replace(hour=0, minute=0, second=0, microsecond=0) + timedelta(days=1)
+
+ if raw_start:
+ start = datetime.strptime(raw_start, "%Y-%m-%d")
+ else:
+ start = end - timedelta(days=365)
+ start = start.replace(hour=0, minute=0, second=0, microsecond=0)
+
+ if start >= end:
+ raise ValueError("start must be before end")
+
+ current_tz = timezone.get_current_timezone()
+ return timezone.make_aware(start, current_tz), timezone.make_aware(end, current_tz)
+
+ # --- 요약 -----------------------------------------------------------
+
+ def _summary(self, params):
+ row = _one(_TURNS_CTE + """
+ SELECT
+ count(*) AS turns,
+ count(DISTINCT (user_id, problem_id, session_no)) AS sessions,
+ count(DISTINCT user_id) AS users,
+ count(DISTINCT problem_id) AS problems,
+ count(*) FILTER (WHERE btrim(hint_content) = '') AS empty_turns
+ FROM turns
+ """, params)
+
+ # 채택률: 문제에 손을 댄 (사용자, 문제) 쌍 중 몇 퍼센트가 AI 조교를 썼는가.
+ # 분모는 제출한 쌍과 힌트를 받은 쌍의 합집합이다. 힌트만 받고 제출하지 않은
+ # 경우가 있어 제출 쌍만 분모로 쓰면 비율이 100%를 넘을 수 있다.
+ adoption = _one("""
+ SELECT
+ count(*) FILTER (WHERE hinted) AS hinted_pairs,
+ count(*) AS engaged_pairs
+ FROM (
+ SELECT user_id, problem_id, bool_or(hinted) AS hinted
+ FROM (
+ SELECT l.user_id, l.problem_id, true AS hinted
+ FROM problem_ai_hint_log l
+ JOIN "user" u ON u.id = l.user_id
+ WHERE u.admin_type = 'Regular User' AND u.is_disabled = false
+ AND l.created_at >= %(start)s AND l.created_at < %(end)s
+ UNION ALL
+ SELECT s.user_id, s.problem_id, false
+ FROM submission s
+ JOIN "user" u ON u.id = s.user_id
+ WHERE u.admin_type = 'Regular User' AND u.is_disabled = false
+ AND s.contest_id IS NULL
+ AND s.create_time >= %(start)s AND s.create_time < %(end)s
+ ) engaged
+ GROUP BY user_id, problem_id
+ ) x
+ """, params)
+
+ turns = row.get("turns") or 0
+ return {
+ "turns": turns,
+ "sessions": row.get("sessions") or 0,
+ "users": row.get("users") or 0,
+ "problems": row.get("problems") or 0,
+ "failure_rate": _rate(row.get("empty_turns") or 0, turns),
+ "hinted_pairs": adoption.get("hinted_pairs") or 0,
+ "engaged_pairs": adoption.get("engaged_pairs") or 0,
+ "adoption_rate": _rate(adoption.get("hinted_pairs") or 0,
+ adoption.get("engaged_pairs") or 0),
+ }
+
+ # --- 추이 -----------------------------------------------------------
+
+ def _monthly(self, params):
+ return _rows(_TURNS_CTE + """
+ SELECT
+ to_char(date_trunc('month', created_at AT TIME ZONE %(tz)s), 'YYYY-MM') AS month,
+ count(*) AS turns,
+ count(DISTINCT (user_id, problem_id, session_no)) AS sessions,
+ count(DISTINCT user_id) AS users
+ FROM turns
+ GROUP BY 1
+ ORDER BY 1
+ """, params)
+
+ def _hourly(self, params):
+ found = {
+ int(r["hour"]): r["turns"]
+ for r in _rows(_TURNS_CTE + """
+ SELECT EXTRACT(HOUR FROM created_at AT TIME ZONE %(tz)s) AS hour, count(*) AS turns
+ FROM turns GROUP BY 1
+ """, params)
+ }
+ return [{"hour": h, "turns": found.get(h, 0)} for h in range(24)]
+
+ # --- 대화 깊이 ------------------------------------------------------
+
+ def _depth(self, params):
+ """세션당 몇 턴을 주고받았는지. 챗봇으로 바뀌어도 그대로 쓰는 지표다."""
+ buckets = _rows(_TURNS_CTE + """
+ , per_session AS (
+ SELECT user_id, problem_id, session_no, count(*) AS turns
+ FROM turns
+ GROUP BY user_id, problem_id, session_no
+ )
+ SELECT turns, count(*) AS sessions
+ FROM per_session
+ GROUP BY turns
+ ORDER BY turns
+ """, params)
+
+ # 힌트 횟수 제한은 (사용자, 문제) 단위로 평생 누적된다. 세션 단위가 아니다.
+ # 5회를 며칠에 걸쳐 나눠 쓰면 세션마다는 1~2턴이지만 한도는 채운 것이므로
+ # 여기만 세션이 아닌 (사용자, 문제) 기준으로 센다.
+ limit_row = _one(_TURNS_CTE + """
+ , per_pair AS (
+ SELECT user_id, problem_id, count(*) AS turns
+ FROM turns
+ GROUP BY user_id, problem_id
+ )
+ SELECT
+ count(*) AS pairs,
+ count(*) FILTER (WHERE turns >= %(limit)s) AS at_limit
+ FROM per_pair
+ """, params)
+
+ total_sessions = sum(b["sessions"] for b in buckets)
+ total_turns = sum(b["turns"] * b["sessions"] for b in buckets)
+ single = next((b["sessions"] for b in buckets if b["turns"] == 1), 0)
+
+ return {
+ "distribution": [{"turns": b["turns"], "sessions": b["sessions"]} for b in buckets],
+ "sessions": total_sessions,
+ "avg_turns": round(total_turns / total_sessions, 2) if total_sessions else 0.0,
+ "single_turn_rate": _rate(single, total_sessions),
+ # 한도가 사라지면 아래 두 값만 의미를 잃는다.
+ "limit_reached_rate": _rate(limit_row.get("at_limit") or 0, limit_row.get("pairs") or 0),
+ "limit": HINT_LIMIT_PER_PROBLEM,
+ }
+
+ # --- 효과 -----------------------------------------------------------
+
+ def _effect(self, params):
+ """힌트를 받은 뒤 정답에 도달한 비율과, 힌트를 쓰지 않은 경우의 비율.
+
+ 주의: 두 집단은 무작위로 나뉘지 않았다. 힌트를 쓰는 학생과 쓰지 않는 학생의
+ 성향 차이가 그대로 섞여 있으므로 인과관계로 읽으면 안 된다.
+ 화면에서도 표본 수와 함께 보여주고 주의 문구를 붙인다.
+ """
+ with_hint = _one("""
+ SELECT count(*) AS pairs, count(*) FILTER (WHERE solved) AS accepted
+ FROM (
+ SELECT h.user_id, h.problem_id, EXISTS (
+ SELECT 1 FROM submission s
+ WHERE s.user_id = h.user_id
+ AND s.problem_id = h.problem_id
+ AND s.contest_id IS NULL
+ AND s.result = 0
+ AND s.create_time > h.first_hint
+ ) AS solved
+ FROM (
+ SELECT l.user_id, l.problem_id, min(l.created_at) AS first_hint
+ FROM problem_ai_hint_log l
+ JOIN "user" u ON u.id = l.user_id
+ WHERE u.admin_type = 'Regular User' AND u.is_disabled = false
+ AND l.created_at >= %(start)s AND l.created_at < %(end)s
+ GROUP BY l.user_id, l.problem_id
+ ) h
+ ) x
+ """, params)
+
+ without_hint = _one("""
+ SELECT count(*) AS pairs, count(*) FILTER (WHERE solved) AS accepted
+ FROM (
+ SELECT s.user_id, s.problem_id, bool_or(s.result = 0) AS solved
+ FROM submission s
+ JOIN "user" u ON u.id = s.user_id
+ WHERE u.admin_type = 'Regular User' AND u.is_disabled = false
+ AND s.contest_id IS NULL
+ AND s.create_time >= %(start)s AND s.create_time < %(end)s
+ AND NOT EXISTS (
+ SELECT 1 FROM problem_ai_hint_log l
+ WHERE l.user_id = s.user_id AND l.problem_id = s.problem_id
+ )
+ GROUP BY s.user_id, s.problem_id
+ ) x
+ """, params)
+
+ def shape(row):
+ pairs = row.get("pairs") or 0
+ accepted = row.get("accepted") or 0
+ return {"pairs": pairs, "accepted": accepted, "rate": _rate(accepted, pairs)}
+
+ return {"with_hint": shape(with_hint), "without_hint": shape(without_hint)}
+
+ # --- 응답 품질 ------------------------------------------------------
+
+ def _quality(self, params):
+ row = _one(_TURNS_CTE + """
+ SELECT
+ count(*) AS turns,
+ count(*) FILTER (WHERE btrim(hint_content) = '') AS empty,
+ count(*) FILTER (WHERE btrim(hint_content) ~ %(label)s) AS labeled,
+ percentile_disc(0.5) WITHIN GROUP (ORDER BY length(hint_content)) AS len_p50,
+ percentile_disc(0.9) WITHIN GROUP (ORDER BY length(hint_content)) AS len_p90,
+ max(length(hint_content)) AS len_max
+ FROM turns
+ """, params)
+
+ turns = row.get("turns") or 0
+ empty = row.get("empty") or 0
+ answered = turns - empty
+ return {
+ "turns": turns,
+ "empty": empty,
+ "failure_rate": _rate(empty, turns),
+ "length": {
+ "p50": row.get("len_p50") or 0,
+ "p90": row.get("len_p90") or 0,
+ "max": row.get("len_max") or 0,
+ },
+ # 단계 라벨은 프롬프트가 지시한 형식이다. 챗봇 전환 시 함께 걷어낸다.
+ "labeled": row.get("labeled") or 0,
+ "label_rate": _rate(row.get("labeled") or 0, answered),
+ }
+
+ # --- 문제별 -------------------------------------------------------
+
+ def _top_problems(self, params):
+ """요청이 몰리는 문제는 학생들이 막히는 지점이다."""
+ return _rows(_TURNS_CTE + """
+ SELECT
+ p.id AS problem_id,
+ p._id AS display_id,
+ p.title AS title,
+ count(*) AS turns,
+ count(DISTINCT t.user_id) AS users,
+ count(DISTINCT (t.user_id, t.problem_id, t.session_no)) AS sessions
+ FROM turns t
+ JOIN problem p ON p.id = t.problem_id
+ GROUP BY p.id, p._id, p.title
+ ORDER BY turns DESC, users DESC
+ LIMIT 10
+ """, params)
+
+
+def is_stage_labeled(hint_content):
+ """응답이 프롬프트가 요구한 `[N단계]` 머리말을 지켰는지.
+
+ SQL 쪽 `STAGE_LABEL_PATTERN` 과 같은 판정을 파이썬에서 쓰기 위한 헬퍼다.
+ """
+ return bool(re.match(STAGE_LABEL_PATTERN, (hint_content or "").strip()))
diff --git a/frontend/src/i18n/admin/en-US.js b/frontend/src/i18n/admin/en-US.js
index e887ba83e..392e6090d 100644
--- a/frontend/src/i18n/admin/en-US.js
+++ b/frontend/src/i18n/admin/en-US.js
@@ -24,6 +24,7 @@ export const m = {
Prune_Test_Case: "테스트 케이스 목록",
Home_Banner_Management: "홈 배너 관리",
Popup_Management: "팝업 관리",
+ AI_Hint_Stats: "AI 조교 현황",
Problem: "문제",
Problem_List: "문제 목록",
Create_Problem: "문제 생성",
@@ -407,4 +408,52 @@ export const m = {
Contest_Name: "대회명",
Contest_Creator: "대회 생성자",
Contest_Visible: "대회 공개 여부",
+
+ // AIHintStats
+ AI_Hint_Start_Date: "시작일",
+ AI_Hint_End_Date: "종료일",
+ AI_Hint_Card_Turns: "총 사용 횟수",
+ AI_Hint_Card_Sessions: "대화 수",
+ AI_Hint_Card_Sessions_Note: "{minutes}분 간격으로 구분",
+ AI_Hint_Card_Users: "사용 학생 수",
+ AI_Hint_Card_Problems: "사용된 문제 수",
+ AI_Hint_Card_Adoption: "채택률",
+ AI_Hint_Card_Failure: "응답 실패율",
+
+ AI_Hint_Usage_Trend: "사용 추이",
+ AI_Hint_Monthly: "월별 사용량",
+ AI_Hint_Hourly: "시간대별 사용량",
+ AI_Hint_Turns: "사용 횟수",
+ AI_Hint_Sessions: "대화 수",
+ AI_Hint_Users: "학생 수",
+
+ AI_Hint_Depth: "대화 깊이",
+ AI_Hint_Turn_Distribution: "몇 번째 힌트까지 이어졌나",
+ AI_Hint_Avg_Turns: "대화당 평균 횟수",
+ AI_Hint_Single_Turn_Rate: "한 번에 끝난 비율",
+ AI_Hint_Limit_Reached_Rate: "한도까지 사용한 비율",
+
+ AI_Hint_Effect: "학습 효과",
+ AI_Hint_Effect_Group: "구분",
+ AI_Hint_Effect_With: "AI 조교 사용",
+ AI_Hint_Effect_Without: "미사용",
+ AI_Hint_Effect_Pairs: "대상 (학생·문제)",
+ AI_Hint_Effect_Accepted: "정답 도달",
+ AI_Hint_Effect_Rate: "정답 도달률",
+ AI_Hint_Effect_Note:
+ "무작위로 나뉜 집단이 아니므로 인과관계로 해석할 수 없습니다.",
+
+ AI_Hint_Quality: "응답 품질",
+ AI_Hint_Failure_Rate: "응답 실패율",
+ AI_Hint_Label_Rate: "형식 준수율",
+ AI_Hint_Label_Note: "응답이 [N단계] 머리말을 지킨 비율",
+ AI_Hint_Length: "응답 길이 (글자)",
+ AI_Hint_Nth_Turn: "{n}회째",
+ AI_Hint_Length_Median: "중앙값",
+ AI_Hint_Length_P90: "상위 10%",
+ AI_Hint_Length_Max: "최대",
+
+ AI_Hint_Top_Problems: "요청이 많은 문제",
+ AI_Hint_Problem_ID: "문제 번호",
+ AI_Hint_Problem_Title: "제목",
}
diff --git a/frontend/src/pages/admin/api.js b/frontend/src/pages/admin/api.js
index 6dd0f2e35..bc98cc44f 100644
--- a/frontend/src/pages/admin/api.js
+++ b/frontend/src/pages/admin/api.js
@@ -364,6 +364,11 @@ export default {
getDashboardInfo() {
return ajax("admin/dashboard_info", "get")
},
+ getAIHintStats(start, end) {
+ return ajax("admin/ai_hint_stats", "get", {
+ params: { start, end },
+ })
+ },
getSessions() {
return ajax("sessions", "get")
},
diff --git a/frontend/src/pages/admin/components/SideMenu.vue b/frontend/src/pages/admin/components/SideMenu.vue
index bba973eaa..98c53da44 100644
--- a/frontend/src/pages/admin/components/SideMenu.vue
+++ b/frontend/src/pages/admin/components/SideMenu.vue
@@ -21,6 +21,9 @@
{{
$t("m.Judge_Server")
}}
+ {{
+ $t("m.AI_Hint_Stats")
+ }}
{{
$t("m.Prune_Test_Case")
}}
diff --git a/frontend/src/pages/admin/router.js b/frontend/src/pages/admin/router.js
index f2b2cbe44..c5e08bdf7 100644
--- a/frontend/src/pages/admin/router.js
+++ b/frontend/src/pages/admin/router.js
@@ -19,6 +19,7 @@ import {
ProblemImportOrExport,
AdminCatalog,
PopupManagement,
+ AIHintStats,
} from "./views"
Vue.use(VueRouter)
@@ -61,6 +62,11 @@ export default new VueRouter({
name: "conf",
component: Conf,
},
+ {
+ path: "/ai-hint-stats",
+ name: "ai-hint-stats",
+ component: AIHintStats,
+ },
{
path: "/judge-server",
name: "judge-server",
diff --git a/frontend/src/pages/admin/views/general/AIHintStats.vue b/frontend/src/pages/admin/views/general/AIHintStats.vue
new file mode 100644
index 000000000..56b7e31ef
--- /dev/null
+++ b/frontend/src/pages/admin/views/general/AIHintStats.vue
@@ -0,0 +1,543 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ card.value }}
+
{{ card.label }}
+
{{ card.note }}
+
+
+
+
+
+
+
+
+ {{ $t("m.AI_Hint_Monthly") }}
+
+
+
+ {{ $t("m.AI_Hint_Hourly") }}
+
+
+
+
+
+
+
+
+ {{ $t("m.AI_Hint_Turn_Distribution") }}
+
+
+
+
+
+ {{ $t("m.AI_Hint_Avg_Turns") }}
+ {{ depth.avg_turns }}
+
+
+ {{ $t("m.AI_Hint_Single_Turn_Rate") }}
+ {{ depth.single_turn_rate }}%
+
+
+ {{ $t("m.AI_Hint_Limit_Reached_Rate") }}
+ {{ depth.limit_reached_rate }}%
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ scope.row.rate }}%
+
+
+
+
+ {{ $t("m.AI_Hint_Effect_Note") }}
+
+
+
+
+
+
+ {{ $t("m.AI_Hint_Failure_Rate") }}
+ {{ quality.failure_rate }}%
+
+ ({{ quality.empty }} / {{ quality.turns }})
+
+
+
+ {{ $t("m.AI_Hint_Label_Rate") }}
+ {{ quality.label_rate }}%
+ {{ $t("m.AI_Hint_Label_Note") }}
+
+
+
+ {{ $t("m.AI_Hint_Length") }}
+
+
+
+
+
+
+
+ {{ $t("m.AI_Hint_Length_Median") }} {{ quality.length.p50 }}
+ {{ $t("m.AI_Hint_Length_P90") }} {{ quality.length.p90 }}
+ {{ $t("m.AI_Hint_Length_Max") }} {{ quality.length.max }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ scope.row.turns }}
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/pages/admin/views/index.js b/frontend/src/pages/admin/views/index.js
index a1f08ea97..29d115d9f 100644
--- a/frontend/src/pages/admin/views/index.js
+++ b/frontend/src/pages/admin/views/index.js
@@ -7,6 +7,7 @@ import JudgeServer from "./general/JudgeServer.vue"
import PruneTestCase from "./general/PruneTestCase.vue"
import HomeBannerManagement from "./general/HomeBannerManagement.vue"
import PopupManagement from "./general/PopupManagement.vue"
+import AIHintStats from "./general/AIHintStats.vue"
import Problem from "./problem/Problem.vue"
import ProblemList from "./problem/ProblemList.vue"
import ContestSubmission from "./contest/ContestSubmission.vue"
@@ -34,4 +35,5 @@ export {
Dashboard,
ProblemImportOrExport,
PopupManagement,
+ AIHintStats,
}
From 59efb6b8421f81b751f9cab3683e69b4af6bd8c4 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=EB=AC=B8=EC=A7=84=ED=98=81?=
Date: Thu, 1 Oct 2026 08:56:29 +0900
Subject: [PATCH 2/2] =?UTF-8?q?fix:=20AI=20=EC=A1=B0=EA=B5=90=20=EB=8C=80?=
=?UTF-8?q?=EC=8B=9C=EB=B3=B4=EB=93=9C=20=EC=BD=94=EB=93=9C=20=EB=A6=AC?=
=?UTF-8?q?=EB=B7=B0=20=EB=B0=98=EC=98=81?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 시간대 버킷팅을 요청 타임존 기준으로 수정
- 채택률 분모를 힌트/제출 합집합으로 교정
- 날짜 파라미터 없는 요청 실패 수정
- statement_timeout 을 트랜잭션으로 감쌈
- 항상 0이던 응답 실패율 지표 제거
- 힌트 한도를 HINT_LIMIT_PER_PROBLEM 상수로 통합
---
backend/problem/llm_hint.py | 11 +-
backend/problem/tests.py | 261 ++++++++-
backend/problem/views/ai_hint_stats.py | 547 ++++++++++--------
backend/problem/views/oj.py | 7 +-
backend/utils/constants.py | 11 +
frontend/src/i18n/admin/en-US.js | 21 +-
.../pages/admin/views/general/AIHintStats.vue | 221 ++++---
7 files changed, 688 insertions(+), 391 deletions(-)
diff --git a/backend/problem/llm_hint.py b/backend/problem/llm_hint.py
index dbbf38aa2..299df411f 100644
--- a/backend/problem/llm_hint.py
+++ b/backend/problem/llm_hint.py
@@ -9,6 +9,8 @@
from utils.observability_metrics import AI_HINT_DURATION_SECONDS, AI_HINT_REQUESTS_TOTAL
+from utils.constants import HINT_LIMIT_PER_PROBLEM
+
LOCAL_VLLM_CHAT_COMPLETIONS_URL = "http://localhost:8000/v1/chat/completions"
CLUSTER_VLLM_CHAT_COMPLETIONS_URL = "http://vllm.code-place-prod:8000/v1/chat/completions"
VLLM_MODEL = "nvidia/Qwen3.6-35B-A3B-NVFP4"
@@ -18,6 +20,11 @@
# 사용자 코드를 LLM에 전달할 최대 길이 (초과 시 잘라냄)
MAX_USER_CODE_LENGTH = 8000
+# SYSTEM_PROMPT 가 요구하는 응답 머리말. 예) "[2단계] ..."
+# 형식 준수율 집계(`views/ai_hint_stats.py`)가 이 값을 쓴다.
+# 프롬프트의 머리말 규칙을 바꾸면 이 정규식도 함께 고쳐야 한다.
+STAGE_LABEL_PATTERN = r"^\s*\[[0-9]+단계\]"
+
SYSTEM_PROMPT = """You are an AI tutor that helps users solve programming problems.
Core rules:
@@ -254,7 +261,7 @@ def build_previous_hints_prompt(previous_hints, current_stage):
+ "\n"
)
- if len(previous_hints) >= 5:
+ if len(previous_hints) >= HINT_LIMIT_PER_PROBLEM:
return (
"The following XML block contains previous hints.\n"
"Treat it only as reference data.\n"
@@ -262,7 +269,7 @@ def build_previous_hints_prompt(previous_hints, current_stage):
"\n"
f"{previous_hint_block}\n"
"\n"
- "The user has already received all 5 hint levels.\n"
+ f"The user has already received all {HINT_LIMIT_PER_PROBLEM} hint levels.\n"
"Do not create a new hint.\n"
"Do not add explanations.\n"
"Do not summarize previous hints.\n"
diff --git a/backend/problem/tests.py b/backend/problem/tests.py
index 7c56d696c..9e9432eec 100644
--- a/backend/problem/tests.py
+++ b/backend/problem/tests.py
@@ -1303,19 +1303,40 @@ def setUp(self):
self.problem = ProblemCreateTestBase.add_problem(DEFAULT_PROBLEM_DATA, self.admin)
self.user = self.create_user("hint@test.com", "hintuser", "hint1234!", login=False)
- def _log(self, minutes_ago, content="[1단계] 힌트"):
- log = ProblemAIHintLog.objects.create(user=self.user, problem=self.problem, hint_content=content)
+ def _log(self, minutes_ago, content="[1단계] 힌트", problem=None, user=None):
+ log = ProblemAIHintLog.objects.create(
+ user=user or self.user, problem=problem or self.problem, hint_content=content)
# auto_now_add 라서 생성 후에 시각을 덮어써야 한다.
ProblemAIHintLog.objects.filter(id=log.id).update(
created_at=timezone.now() - timedelta(minutes=minutes_ago))
return log
+ def _submit(self, minutes_ago, result=JudgeStatus.ACCEPTED, problem=None, user=None, contest=None):
+ user = user or self.user
+ sub = Submission.objects.create(
+ problem=problem or self.problem, contest=contest, user_id=user.id,
+ username=user.username, code="x", result=result, language="Python3")
+ Submission.objects.filter(id=sub.id).update(
+ create_time=timezone.now() - timedelta(minutes=minutes_ago))
+ return sub
+
def test_requires_super_admin(self):
self.client.logout()
self.create_user("normal@test.com", "normaluser", "normal1234!")
resp = self.client.get(self.url)
self.assertFailed(resp)
+ def test_plain_admin_is_rejected(self):
+ """슈퍼관리자만 볼 수 있어야 한다.
+
+ 일반 관리자도 관리자 SPA 에 들어오므로 URL 로 직접 접근할 수 있다.
+ 데코레이터가 admin_role_required 로 바뀌면 학생별 힌트 기록이 노출된다.
+ """
+ self.client.logout()
+ self.create_admin()
+ resp = self.client.get(self.url)
+ self.assertFailed(resp)
+
def test_empty_range_returns_zeros(self):
resp = self.client.get(self.url)
self.assertSuccess(resp)
@@ -1340,35 +1361,30 @@ def test_session_split_by_gap(self):
self.assertEqual(data["depth"]["avg_turns"], 1.5)
self.assertEqual(data["depth"]["single_turn_rate"], 50.0)
- def test_failure_and_label_rate(self):
+ def test_label_rate_counts_prompt_format_compliance(self):
+ """응답이 [N단계] 머리말을 지켰는지 센다.
+
+ 빈 응답은 `_persist_hint_log` 가 행째로 지우므로 이 테이블로는 실패율을
+ 낼 수 없다. 실패 신호는 Prometheus 쪽에 있다.
+ """
self._log(minutes_ago=30, content="[1단계] 정상 응답")
self._log(minutes_ago=20, content="머리말 없는 응답")
- self._log(minutes_ago=10, content="")
resp = self.client.get(self.url)
self.assertSuccess(resp)
quality = resp.data["data"]["quality"]
- self.assertEqual(quality["turns"], 3)
- self.assertEqual(quality["empty"], 1)
- self.assertEqual(quality["failure_rate"], 33.3)
- # 라벨 준수율은 빈 응답을 뺀 2건 중 1건이다.
- self.assertEqual(quality["labeled"], 1)
self.assertEqual(quality["label_rate"], 50.0)
+ self.assertNotIn("failure_rate", quality)
- def test_effect_and_top_problems(self):
+ def test_top_problems(self):
self._log(minutes_ago=30)
- Submission.objects.create(problem=self.problem, user_id=self.user.id,
- username=self.user.username, code="x",
- result=JudgeStatus.ACCEPTED, language="Python3")
+ self._submit(minutes_ago=10)
resp = self.client.get(self.url)
self.assertSuccess(resp)
data = resp.data["data"]
- self.assertEqual(data["effect"]["with_hint"]["pairs"], 1)
- self.assertEqual(data["effect"]["with_hint"]["accepted"], 1)
- self.assertEqual(data["effect"]["with_hint"]["rate"], 100.0)
self.assertEqual(data["top_problems"][0]["display_id"], self.problem._id)
self.assertEqual(data["top_problems"][0]["turns"], 1)
@@ -1379,10 +1395,11 @@ def test_limit_reached_counts_per_problem_not_per_session(self):
resp = self.client.get(self.url)
self.assertSuccess(resp)
- depth = resp.data["data"]["depth"]
+ data = resp.data["data"]
+ depth = data["depth"]
# 세션은 5개로 쪼개지지만 (사용자, 문제) 기준으로는 한도 도달이다.
- self.assertEqual(depth["sessions"], 5)
+ self.assertEqual(data["summary"]["sessions"], 5)
self.assertEqual(depth["single_turn_rate"], 100.0)
self.assertEqual(depth["limit_reached_rate"], 100.0)
@@ -1398,6 +1415,214 @@ def test_adoption_rate_never_exceeds_100(self):
self.assertEqual(summary["engaged_pairs"], 1)
self.assertEqual(summary["adoption_rate"], 100.0)
+ def test_contest_activity_is_excluded_from_every_metric(self):
+ """대회 문제는 힌트 쪽과 제출 쪽 모두에서 빠져야 한다.
+
+ 한쪽만 거르면 힌트는 세는데 정답은 빠지는 짝이 생겨
+ 채택률은 부풀고 정답 도달률은 깎인다.
+ """
+ contest = Contest.objects.create(created_by=self.admin, **DEFAULT_CONTEST_DATA)
+ contest_problem = ProblemCreateTestBase.create_problem_with_custom_field(
+ self.admin, _id="C-1", contest=contest)
+
+ self._log(minutes_ago=30, problem=contest_problem)
+ self._submit(minutes_ago=10, problem=contest_problem, contest=contest)
+
+ resp = self.client.get(self.url)
+ self.assertSuccess(resp)
+ data = resp.data["data"]
+
+ self.assertEqual(data["summary"]["turns"], 0)
+ self.assertEqual(data["summary"]["engaged_pairs"], 0)
+ self.assertEqual(data["top_problems"], [])
+
+ def test_admin_activity_is_excluded(self):
+ """관리자는 횟수 제한을 받지 않아 분포를 왜곡하므로 집계에서 빠져야 한다."""
+ self._log(minutes_ago=30, user=self.admin)
+ self._submit(minutes_ago=10, user=self.admin)
+
+ resp = self.client.get(self.url)
+ self.assertSuccess(resp)
+ data = resp.data["data"]
+
+ self.assertEqual(data["summary"]["turns"], 0)
+ self.assertEqual(data["summary"]["engaged_pairs"], 0)
+
+ def test_orphan_empty_log_is_not_counted(self):
+ """워커가 죽어 남은 빈 로그가 없던 턴으로 잡히면 안 된다."""
+ self._log(minutes_ago=30, content="[1단계] 정상 응답")
+ self._log(minutes_ago=20, content="")
+
+ resp = self.client.get(self.url)
+ self.assertSuccess(resp)
+ data = resp.data["data"]
+
+ self.assertEqual(data["summary"]["turns"], 1)
+ self.assertEqual(data["quality"]["label_rate"], 100.0)
+ self.assertNotIn("turns", data["quality"])
+ self.assertGreater(data["quality"]["length"]["p50"], 0)
+
+ def test_limit_reached_counts_lifetime_turns_not_range_turns(self):
+ """한도는 평생 누적이므로 조회 구간 밖의 힌트도 세야 한다."""
+ for days in range(4):
+ self._log(minutes_ago=60 * 24 * (200 + days)) # 구간 밖(약 200일 전)
+ # 자정 직후에 돌아도 오늘 안에 들어오도록 정오에 고정한다.
+ noon = timezone.localtime().replace(hour=12, minute=0, second=0, microsecond=0)
+ log = self._log(minutes_ago=0)
+ ProblemAIHintLog.objects.filter(id=log.id).update(created_at=noon)
+
+ today = timezone.localtime().date()
+ resp = self.client.get(self.url + f"?start={today.isoformat()}&end={today.isoformat()}")
+ self.assertSuccess(resp)
+ data = resp.data["data"]
+ depth = data["depth"]
+
+ # 오늘 구간에서 보이는 턴은 1개지만, 이 짝의 평생 누적은 5개라 한도 도달이다.
+ self.assertEqual(data["summary"]["sessions"], 1)
+ self.assertEqual(depth["limit_reached_rate"], 100.0)
+
+ def test_adoption_counts_submitters_without_hints(self):
+ """힌트를 쓰지 않고 제출만 한 짝도 분모에 들어가야 한다.
+
+ 분모에서 빠지면 채택률이 늘 100% 로 나온다.
+ """
+ other = ProblemCreateTestBase.create_problem_with_custom_field(self.admin, _id="A-9")
+ self._log(minutes_ago=30)
+ self._submit(minutes_ago=20)
+ self._submit(minutes_ago=10, problem=other) # 힌트 없이 제출만
+
+ resp = self.client.get(self.url)
+ self.assertSuccess(resp)
+ summary = resp.data["data"]["summary"]
+
+ self.assertEqual(summary["hinted_pairs"], 1)
+ self.assertEqual(summary["engaged_pairs"], 2)
+ self.assertEqual(summary["adoption_rate"], 50.0)
+
+ def test_buckets_use_local_timezone(self):
+ """월·시간 버킷이 UTC 가 아니라 현지 시각 기준이어야 한다.
+
+ AT TIME ZONE 이 빠지면 한 달이 밀리고 시간대가 9시간 어긋난다.
+ """
+ # 현지 기준 어느 달 1일 00:30. UTC 로는 전달 말일 15:30 이다.
+ local = timezone.localtime().replace(day=1, hour=0, minute=30, second=0, microsecond=0)
+ log = self._log(minutes_ago=0)
+ ProblemAIHintLog.objects.filter(id=log.id).update(created_at=local)
+
+ day = local.date()
+ resp = self.client.get(self.url + f"?start={day.isoformat()}&end={day.isoformat()}")
+ self.assertSuccess(resp)
+ data = resp.data["data"]
+
+ self.assertEqual(data["monthly"][0]["month"], local.strftime("%Y-%m"))
+ self.assertEqual(data["hourly"][0]["hour"], 0)
+ self.assertEqual(data["hourly"][0]["turns"], 1)
+
+ def test_monthly_fills_empty_months(self):
+ """데이터가 없는 달도 0으로 채워야 선 그래프가 공백을 건너뛰지 않는다."""
+ self._log(minutes_ago=30)
+
+ today = timezone.localtime().date()
+ start = (today - timedelta(days=70)).isoformat()
+ resp = self.client.get(self.url + f"?start={start}&end={today.isoformat()}")
+ self.assertSuccess(resp)
+ monthly = resp.data["data"]["monthly"]
+
+ self.assertGreaterEqual(len(monthly), 3)
+ months = [m["month"] for m in monthly]
+ self.assertEqual(months, sorted(months))
+ self.assertEqual(sum(m["turns"] for m in monthly), 1)
+
+ def test_session_straddling_range_start_is_not_split(self):
+ """구간 경계를 걸친 대화가 둘로 쪼개지면 세션 수가 부풀어 오른다.
+
+ 구간 앞쪽으로 gap 만큼 더 읽어 LAG 문맥을 주고,
+ 세션은 구간 안에서 **시작한** 것만 세는 구조를 고정한다.
+ """
+ # 구간 시작(오늘 0시) 직전 10분과 직후 5분. lookback 창(30분) 안이라
+ # LAG 가 앞 턴을 보고 두 로그를 한 대화로 묶는다.
+ midnight = timezone.localtime().replace(hour=0, minute=0, second=0, microsecond=0)
+
+ first = self._log(minutes_ago=0)
+ second = self._log(minutes_ago=0)
+ ProblemAIHintLog.objects.filter(id=first.id).update(
+ created_at=midnight - timedelta(minutes=10))
+ ProblemAIHintLog.objects.filter(id=second.id).update(
+ created_at=midnight + timedelta(minutes=5))
+
+ today = timezone.localtime().date()
+ resp = self.client.get(self.url + f"?start={today.isoformat()}&end={today.isoformat()}")
+ self.assertSuccess(resp)
+ data = resp.data["data"]
+
+ # 턴과 세션의 기준이 다르다.
+ # 두 번째 로그는 오늘 구간 안에서 일어났으므로 턴으로는 센다.
+ self.assertEqual(data["summary"]["turns"], 1)
+ # 그러나 대화는 어제 시작했으므로 오늘의 대화로는 세지 않는다.
+ # lookback 을 빼면 새 대화로 보여 1이 된다.
+ self.assertEqual(data["summary"]["sessions"], 0)
+ self.assertEqual(data["depth"]["distribution"], [])
+ self.assertEqual(data["depth"]["avg_turns"], 0.0)
+
+ def test_session_continuing_past_range_end_is_not_truncated(self):
+ """구간 끝에서 시작해 밖으로 이어진 대화도 깊이가 온전해야 한다.
+
+ 뒤쪽 lookahead 가 짧으면 5턴 대화가 2턴으로 보이고
+ 평균 턴 수가 낮게, 1턴 종료율이 높게 나온다.
+ """
+ today = timezone.localtime().date()
+ end_boundary = timezone.localtime().replace(
+ hour=0, minute=0, second=0, microsecond=0) + timedelta(days=1)
+
+ # 오늘 23:50 부터 25분 간격으로 5턴. 뒤 4턴은 내일로 넘어간다.
+ for i in range(5):
+ log = self._log(minutes_ago=0)
+ ProblemAIHintLog.objects.filter(id=log.id).update(
+ created_at=end_boundary - timedelta(minutes=10) + timedelta(minutes=25 * i))
+
+ resp = self.client.get(self.url + f"?start={today.isoformat()}&end={today.isoformat()}")
+ self.assertSuccess(resp)
+ depth = resp.data["data"]["depth"]
+
+ self.assertEqual(depth["distribution"], [{"turns": 5, "sessions": 1}])
+ self.assertEqual(depth["avg_turns"], 5.0)
+ self.assertEqual(depth["single_turn_rate"], 0.0)
+
+ def test_monthly_sessions_sum_to_summary(self):
+ """월별 대화 수의 합은 요약 카드의 대화 수와 같아야 한다."""
+ self._log(minutes_ago=30)
+ self._log(minutes_ago=60 * 24 * 40)
+
+ today = timezone.localtime().date()
+ start = (today - timedelta(days=70)).isoformat()
+ resp = self.client.get(self.url + f"?start={start}&end={today.isoformat()}")
+ self.assertSuccess(resp)
+ data = resp.data["data"]
+
+ self.assertEqual(sum(m["sessions"] for m in data["monthly"]),
+ data["summary"]["sessions"])
+
+ def test_range_width_boundary(self):
+ """상한 자체는 통과하고 하루 더 넘기면 거절해야 한다.
+
+ 프론트의 날짜 선택 제한이 이 경계와 어긋나면 고를 수는 있는데
+ 서버가 거절하는 구간이 생긴다.
+ """
+ from problem.views.ai_hint_stats import MAX_RANGE_DAYS
+
+ end = timezone.localtime().date()
+ ok_start = end - timedelta(days=MAX_RANGE_DAYS - 1)
+ too_early = end - timedelta(days=MAX_RANGE_DAYS)
+
+ self.assertSuccess(
+ self.client.get(self.url + f"?start={ok_start.isoformat()}&end={end.isoformat()}"))
+ self.assertFailed(
+ self.client.get(self.url + f"?start={too_early.isoformat()}&end={end.isoformat()}"))
+
+ def test_range_width_is_capped(self):
+ resp = self.client.get(self.url + "?start=2000-01-01&end=2026-01-01")
+ self.assertFailed(resp)
+
def test_invalid_date_is_rejected(self):
resp = self.client.get(self.url + "?start=2026-13-99")
self.assertFailed(resp)
diff --git a/backend/problem/views/ai_hint_stats.py b/backend/problem/views/ai_hint_stats.py
index 1bb44e833..17eb97430 100644
--- a/backend/problem/views/ai_hint_stats.py
+++ b/backend/problem/views/ai_hint_stats.py
@@ -11,43 +11,119 @@
세션 경계는 시간 간격으로 정의한다. 같은 (user, problem) 에서 연속된 로그의 간격이
SESSION_GAP_MINUTES 를 넘으면 다른 세션으로 본다. 며칠 뒤 같은 문제를 다시 물어본 것을
한 대화로 묶지 않기 위함이다.
+
+집계 범위에 대한 결정
+---------------------
+**대회 문제는 모든 지표에서 제외한다.** 대회는 AI 조교가 기본 비활성이고 풀이 역학도
+연습 문제와 다르다. 무엇보다 힌트 쪽과 제출 쪽의 필터가 어긋나면 "힌트는 세는데 정답은
+빠지는" 짝이 생겨 채택률과 정답 도달률이 동시에 왜곡된다. 양쪽을 같은 기준
+(`problem.contest_id`)으로 맞춘다.
+
+**관리자 로그도 제외한다.** 관리자는 횟수 제한을 받지 않아 분포를 왜곡한다.
+반대로 `is_disabled` 는 거르지 않는다. 계정이 나중에 비활성화되면 이미 지나간 달의
+수치가 소급해서 바뀌기 때문이다.
+
+알려진 한계: `admin_type` 도 지금 값을 보므로 같은 성질이 남아 있다. 학생이 나중에
+조교로 승격되면 그가 학생일 때 쌓은 기록이 과거 구간에서도 사라진다. 로그 행에
+당시 역할을 남기지 않는 한 완전한 재현성은 얻을 수 없다.
+
+**학습 효과(정답 도달률) 비교는 넣지 않는다.** "AI 조교를 써서 더 잘 풀었는가"는
+어떤 짝을 모집단에 넣느냐에 따라 결과가 뒤집힌다. 구간 이전의 힌트, 구간 이전의 정답,
+힌트를 누르기 전에 이미 푼 경우, 제출 없이 힌트만 받은 경우, 채점이 끝나지 않은 제출 —
+각각을 어떻게 다루느냐가 그대로 숫자를 좌우한다. 무작위 배정도 아니어서 인과로 읽을 수도
+없다. 객관적인 수치로 제시하기 어려운 지표이므로, 방법론을 먼저 합의한 뒤 별도로 만든다.
+
+**채택률의 분모는 조회 구간 전체다.** AI 조교가 없던 시기를 포함해 조회하면
+분모에는 들어가고 분자에는 못 들어가므로 채택률이 실제보다 낮게 나온다.
+기능 시작 시점을 자동으로 잡아 보정하려면 힌트 로그 전체를 훑어야 하는데,
+그 비용이 보정 효과보다 커서 넣지 않았다. 기능 도입 전후를 함께 보는 구간이라면
+이 점을 감안해서 읽어야 한다.
+
+**내용이 빈 로그도 제외한다.** 스트리밍 시작 전에 선점 생성되는 행이라
+정상 경로에서는 `_persist_hint_log` 가 지우지만, 워커가 중간에 죽으면 그대로 남는다.
+그런 행을 세면 없던 턴이 생기고 응답 길이와 형식 준수율이 함께 내려간다.
"""
-import re
from datetime import datetime, timedelta
from django.conf import settings
-from django.db import connection
+from django.db import connection, transaction
from django.utils import timezone
from account.decorators import super_admin_required
+from account.models import AdminType
from utils.api import APIView
+from utils.constants import HINT_LIMIT_PER_PROBLEM
+
+from ..llm_hint import STAGE_LABEL_PATTERN
+
# 이 간격을 넘겨 다시 요청하면 새 대화로 센다.
# 2026-09 운영 데이터(연속 요청 208건) 기준으로 정했다. 87.9%가 15분 이내에 몰려 있고
# 15~30분 구간은 0건이라 그 사이가 자연스러운 경계다. 15분과 30분은 결과가 같다.
SESSION_GAP_MINUTES = 30
-# 문제당 힌트 횟수 제한. 챗봇 전환 시 사라지는 값이므로 여기 한 곳에만 둔다.
-HINT_LIMIT_PER_PROBLEM = 5
-
-# 프롬프트가 요구하는 응답 머리말. 예) "[2단계] ..."
-# 이것도 단계 체계가 사라지면 함께 없어질 한시적 품질 지표다.
-STAGE_LABEL_PATTERN = r"^\[[0-9]단계\]"
-
-# 일반 사용자 로그만 집계한다. 관리자는 횟수 제한을 받지 않아 분포를 왜곡한다.
-_BASE_LOGS = """
- SELECT l.id, l.user_id, l.problem_id, l.created_at, l.hint_content
+# 응답 머리말 규칙은 프롬프트가 소유한다(`llm_hint.STAGE_LABEL_PATTERN`).
+# 프롬프트를 고치면 이 지표가 조용히 0% 로 떨어지므로 정의를 한 곳에 둔다.
+
+# `submission.create_time` 에 인덱스가 없고, `problem_ai_hint_log` 의 인덱스는 전부
+# `user`/`problem` 으로 시작해서 `created_at` 단독 범위 조건으로는 탐색이 안 된다.
+# 그래서 조회 구간이 넓으면 양쪽 다 전체 스캔이 된다.
+# 근본 해결은 `create_time` 인덱스다. 다만 백엔드 파드가 여러 개이고 기동마다
+# `manage.py migrate` 가 도는 구조라, CONCURRENTLY 마이그레이션을 이 PR 에 섞으면
+# 동시 실행으로 INVALID 인덱스가 남을 수 있다. 인덱스는 별도 작업으로 분리한다.
+MAX_RANGE_DAYS = 400
+DEFAULT_RANGE_DAYS = 365
+
+# 모집단 조건. 여섯 개 쿼리가 **같은 기준**을 써야 "힌트는 세는데 정답은 빠지는"
+# 짝이 생기지 않는다. 조건을 바꿀 일이 있으면 이 두 조각만 고친다.
+_LOG_POPULATION = """
FROM problem_ai_hint_log l
JOIN "user" u ON u.id = l.user_id
- WHERE u.admin_type = 'Regular User'
- AND u.is_disabled = false
- AND l.created_at >= %(start)s
- AND l.created_at < %(end)s
+ JOIN problem p ON p.id = l.problem_id
+ WHERE u.admin_type = %(regular)s
+ AND p.contest_id IS NULL
+ AND btrim(l.hint_content) <> ''
+"""
+
+_SUB_POPULATION = """
+ FROM submission s
+ JOIN "user" u ON u.id = s.user_id
+ JOIN problem p ON p.id = s.problem_id
+ WHERE u.admin_type = %(regular)s
+ AND p.contest_id IS NULL
+"""
+
+# 일반 사용자의, 내용이 있는, 연습 문제 힌트만 본다. 모듈 주석의 "집계 범위" 참고.
+_BASE_LOGS = f"""
+ SELECT
+ l.user_id,
+ l.problem_id,
+ l.created_at,
+ length(l.hint_content) AS content_length,
+ l.hint_content ~ %(label)s AS labeled
+{_LOG_POPULATION.rstrip()}
+ -- 구간 양쪽으로 gap 만큼 더 읽는다. 앞쪽은 LAG 가 앞 턴을 보기 위해,
+ -- 뒤쪽은 구간 끝에서 시작한 대화가 한도까지 이어져도 잘리지 않을 만큼이다.
+ -- 이 여분 행들은 아래에서 지표별로 걸러낸다.
+ AND l.created_at >= %(start)s::timestamptz - %(gap)s::interval
+ AND l.created_at < %(end)s::timestamptz + %(tail)s::interval
+"""
+
+# 기간 안의 연습 문제 제출. 채택률("문제에 손을 댔나")은 채점 상태를 따지지 않는다.
+# 채점 대기를 빼면 채점이 밀릴수록 분모가 줄어 채택률이 저절로 올라간다.
+_ENGAGED_SUBMISSIONS = f"""
+ SELECT s.user_id, s.problem_id
+{_SUB_POPULATION.rstrip()}
+ AND s.create_time >= %(start)s
+ AND s.create_time < %(end)s
+ GROUP BY s.user_id, s.problem_id
"""
-# 로그에 세션 번호와 세션 내 턴 번호를 붙인다. 아래 집계들이 모두 이 결과에서 파생된다.
-_TURNS_CTE = f"""
+# 턴 단위 지표를 한 문장으로 낸다.
+# 지표마다 CTE 를 따로 붙이면 같은 윈도 함수 파이프라인을 대여섯 번 다시 돈다.
+# 여기서는 turns 를 한 번만 만들고 각 집계를 JSON 으로 모아 한 행으로 돌려받는다.
+_TURN_AGGREGATES = f"""
WITH logs AS ({_BASE_LOGS}),
gaps AS (
SELECT *,
@@ -59,26 +135,171 @@
FROM logs
WINDOW w AS (PARTITION BY user_id, problem_id ORDER BY created_at)
),
-turns AS (
+numbered AS (
SELECT *,
SUM(new_session) OVER (
PARTITION BY user_id, problem_id ORDER BY created_at
) AS session_no
FROM gaps
+),
+scoped AS (
+ SELECT *,
+ MIN(created_at) OVER (PARTITION BY user_id, problem_id, session_no) AS session_start
+ FROM numbered
+),
+turns AS (
+ -- 턴 지표: 구간 안에서 일어난 턴만 센다. 양옆에서 읽어 온 여분 행을 뺀다.
+ SELECT * FROM scoped
+ WHERE created_at >= %(start)s AND created_at < %(end)s
+),
+session_turns AS (
+ -- 세션 지표: **구간 안에서 시작한 대화**의 턴 전부. 구간 밖으로 이어진 꼬리까지
+ -- 포함해야 대화 깊이가 실제대로 나온다. 걸친 대화를 양쪽에서 세면 구간을
+ -- 좁힐수록 세션 수가 부풀고 월별 합계가 요약 카드와 어긋난다.
+ SELECT * FROM scoped
+ WHERE session_start >= %(start)s AND session_start < %(end)s
+),
+totals AS (
+ SELECT
+ count(*) AS turns,
+ (SELECT count(DISTINCT (user_id, problem_id, session_no)) FROM session_turns) AS sessions,
+ count(DISTINCT user_id) AS users,
+ count(DISTINCT problem_id) AS problems,
+ count(*) FILTER (WHERE labeled) AS labeled,
+ percentile_disc(0.5) WITHIN GROUP (ORDER BY content_length) AS len_p50,
+ percentile_disc(0.9) WITHIN GROUP (ORDER BY content_length) AS len_p90,
+ coalesce(max(content_length), 0) AS len_max
+ FROM turns
+),
+monthly AS (
+ SELECT
+ to_char(date_trunc('month', created_at AT TIME ZONE %(tz)s), 'YYYY-MM') AS month,
+ count(*) AS turns,
+ count(DISTINCT user_id) AS users
+ FROM turns
+ GROUP BY 1
+),
+monthly_sessions AS (
+ -- 대화는 시작한 달에 한 번만 센다. 턴이 속한 달로 세면 월 경계를 걸친 대화가
+ -- 두 달에 각각 잡혀 월별 합계가 요약 카드와 달라진다.
+ SELECT
+ to_char(date_trunc('month', session_start AT TIME ZONE %(tz)s), 'YYYY-MM') AS month,
+ count(*) AS sessions
+ FROM (SELECT DISTINCT user_id, problem_id, session_no, session_start FROM session_turns) x
+ GROUP BY 1
+),
+month_axis AS (
+ SELECT to_char(m, 'YYYY-MM') AS month
+ FROM generate_series(
+ date_trunc('month', %(start)s::timestamptz AT TIME ZONE %(tz)s),
+ date_trunc('month', (%(end)s::timestamptz - interval '1 second') AT TIME ZONE %(tz)s),
+ interval '1 month'
+ ) m
+),
+monthly_filled AS (
+ -- 비어 있는 달을 건너뛰면 선 그래프가 떨어진 두 달을 이어 붙여
+ -- 공백이 한 달의 등락처럼 그려진다.
+ SELECT
+ ax.month,
+ coalesce(mo.turns, 0) AS turns,
+ coalesce(mo.users, 0) AS users,
+ coalesce(ms.sessions, 0) AS sessions
+ FROM month_axis ax
+ LEFT JOIN monthly mo ON mo.month = ax.month
+ LEFT JOIN monthly_sessions ms ON ms.month = ax.month
+),
+hourly AS (
+ SELECT EXTRACT(HOUR FROM created_at AT TIME ZONE %(tz)s)::int AS hour, count(*) AS turns
+ FROM turns
+ GROUP BY 1
+),
+per_session AS (
+ SELECT user_id, problem_id, session_no, count(*) AS turns
+ FROM session_turns
+ GROUP BY user_id, problem_id, session_no
+),
+depth AS (
+ SELECT turns, count(*) AS sessions FROM per_session GROUP BY turns
+),
+lifetime AS (
+ -- 힌트 횟수 제한은 (사용자, 문제) 단위로 **평생** 누적된다. 세션 단위도 조회 구간
+ -- 단위도 아니므로, 한도 판정만 구간 필터가 걸리지 않은 원본 로그를 센다.
+ -- 요청 제한(oj.py)은 빈 로그까지 포함해 세므로 여기서도 같은 기준으로 센다.
+ -- 조회 구간 종료 시점까지만 보아야 과거 구간을 다시 조회해도 같은 값이 나온다.
+ SELECT a.user_id, a.problem_id, count(l.id) AS lifetime_turns
+ FROM (SELECT DISTINCT user_id, problem_id FROM turns) a
+ JOIN problem_ai_hint_log l
+ ON l.user_id = a.user_id AND l.problem_id = a.problem_id
+ AND l.created_at < %(end)s
+ GROUP BY a.user_id, a.problem_id
+),
+limits AS (
+ SELECT
+ count(*) AS pairs,
+ count(*) FILTER (WHERE lifetime_turns >= %(limit)s) AS at_limit
+ FROM lifetime
+),
+adoption AS (
+ -- 채택률: 문제에 손을 댄 (사용자, 문제) 짝 중 몇 퍼센트가 AI 조교를 썼는가.
+ -- 분모는 제출한 짝과 힌트를 받은 짝의 합집합이다. 힌트만 받고 제출하지 않은
+ -- 경우가 있어 제출 짝만 분모로 쓰면 비율이 100%%를 넘을 수 있다.
+ SELECT
+ count(*) FILTER (WHERE hinted) AS hinted_pairs,
+ count(*) AS engaged_pairs
+ FROM (
+ SELECT user_id, problem_id, bool_or(hinted) AS hinted
+ FROM (
+ SELECT DISTINCT user_id, problem_id, true AS hinted FROM turns
+ UNION ALL
+ SELECT user_id, problem_id, false FROM ({_ENGAGED_SUBMISSIONS}) s
+ ) e
+ GROUP BY user_id, problem_id
+ ) x
+),
+top_problems AS (
+ SELECT
+ p.id AS problem_id,
+ p._id AS display_id,
+ p.title AS title,
+ count(*) AS turns,
+ count(DISTINCT t.user_id) AS users,
+ count(DISTINCT (t.user_id, t.problem_id, t.session_no))
+ FILTER (WHERE t.session_start >= %(start)s) AS sessions
+ FROM turns t
+ JOIN problem p ON p.id = t.problem_id
+ GROUP BY p.id, p._id, p.title
+ -- p.id 를 마지막 정렬 키로 둬야 동점일 때 새로고침마다 순서가 바뀌지 않는다.
+ ORDER BY turns DESC, users DESC, p.id
+ LIMIT 10
)
+SELECT
+ (SELECT row_to_json(t) FROM totals t) AS totals,
+ (SELECT coalesce(json_agg(m ORDER BY m.month), '[]'::json)
+ FROM monthly_filled m) AS monthly,
+ (SELECT coalesce(json_agg(h ORDER BY h.hour), '[]'::json) FROM hourly h) AS hourly,
+ (SELECT coalesce(json_agg(d ORDER BY d.turns), '[]'::json) FROM depth d) AS depth,
+ (SELECT row_to_json(l) FROM limits l) AS limits,
+ (SELECT row_to_json(a) FROM adoption a) AS adoption,
+ (SELECT coalesce(json_agg(tp ORDER BY tp.turns DESC, tp.users DESC, tp.problem_id),
+ '[]'::json) FROM top_problems tp) AS top_problems
"""
-def _rows(sql, params):
- with connection.cursor() as cursor:
- cursor.execute(sql, params)
- columns = [col[0] for col in cursor.description]
- return [dict(zip(columns, row)) for row in cursor.fetchall()]
+# 인덱스가 없어 넓은 구간 조회가 오래 걸릴 수 있다. 워커를 물고 있는 대신
+# 빨리 실패하게 만든다. 관리자 화면이므로 몇 초를 넘기면 그 자체가 신호다.
+STATEMENT_TIMEOUT_MS = 10_000
def _one(sql, params):
- rows = _rows(sql, params)
- return rows[0] if rows else {}
+ # SET LOCAL 은 트랜잭션 블록 안에서만 유효하다. Django 는 기본이 autocommit 이라
+ # atomic 으로 감싸지 않으면 경고만 남기고 조용히 무시된다.
+ with transaction.atomic(), connection.cursor() as cursor:
+ cursor.execute("SET LOCAL statement_timeout = %s", [STATEMENT_TIMEOUT_MS])
+ cursor.execute(sql, params)
+ row = cursor.fetchone()
+ if row is None:
+ return {}
+ return dict(zip([col[0] for col in cursor.description], row))
def _rate(numerator, denominator, digits=1):
@@ -91,40 +312,53 @@ class AIHintStatsAPI(APIView):
"""AI 조교 사용량과 효과를 한 번에 돌려준다.
쿼리 파라미터
- start, end : YYYY-MM-DD. 생략하면 최근 12개월.
+ start, end : YYYY-MM-DD. 생략하면 최근 1년. 최대 폭은 MAX_RANGE_DAYS 일.
"""
@super_admin_required
def get(self, request):
try:
start, end = self._parse_range(request)
- except ValueError:
- return self.error("날짜가 올바르지 않습니다. YYYY-MM-DD 형식이어야 하고 시작일이 종료일보다 앞서야 합니다.")
+ except (ValueError, OverflowError) as exc:
+ # 9999-12-31 같은 경계 날짜는 timedelta 연산에서 OverflowError 를 낸다.
+ message = str(exc) if isinstance(exc, ValueError) else "조회할 수 없는 날짜입니다."
+ return self.error(message)
params = {
"start": start,
"end": end,
"gap": f"{SESSION_GAP_MINUTES} minutes",
+ # 대화 하나는 최대 HINT_LIMIT_PER_PROBLEM 턴이고 턴 사이 간격은 gap 미만이다.
+ # 구간 끝에서 시작한 대화가 잘리지 않으려면 그만큼 뒤까지 읽어야 한다.
+ # 가정: 한 대화의 턴 수가 한도를 넘지 않는다. 관리자는 한도를 받지 않으므로
+ # 관리자였다가 일반 사용자로 바뀐 계정의 과거 기록은 잘릴 수 있다.
+ "tail": f"{SESSION_GAP_MINUTES * (HINT_LIMIT_PER_PROBLEM - 1)} minutes",
"limit": HINT_LIMIT_PER_PROBLEM,
"label": STAGE_LABEL_PATTERN,
+ "regular": AdminType.REGULAR_USER,
# USE_TZ=True 이면 Django 가 DB 세션 타임존을 UTC 로 잡는다.
# 월 경계와 시간대 분포는 현지 시각 기준이어야 하므로 명시적으로 변환한다.
"tz": settings.TIME_ZONE,
}
+ agg = _one(_TURN_AGGREGATES, params)
+ totals = agg.get("totals") or {}
+
return self.success({
"range": {
"start": start.date().isoformat(),
"end": (end - timedelta(days=1)).date().isoformat(),
"session_gap_minutes": SESSION_GAP_MINUTES,
+ # 시작일과 종료일 사이로 허용되는 최대 간격(일). 클라이언트가 포함/배타 규칙을
+ # 다시 계산하지 않도록 계산된 값을 그대로 보낸다.
+ "max_range_span_days": MAX_RANGE_DAYS - 1,
},
- "summary": self._summary(params),
- "monthly": self._monthly(params),
- "hourly": self._hourly(params),
- "depth": self._depth(params),
- "effect": self._effect(params),
- "quality": self._quality(params),
- "top_problems": self._top_problems(params),
+ "summary": self._summary(totals, agg.get("adoption") or {}),
+ "monthly": agg.get("monthly") or [],
+ "hourly": self._fill_hours(agg.get("hourly") or []),
+ "depth": self._depth(agg.get("depth") or [], agg.get("limits") or {}),
+ "quality": self._quality(totals),
+ "top_problems": agg.get("top_problems") or [],
})
@staticmethod
@@ -134,71 +368,40 @@ def _parse_range(request):
# make_aware 는 naive 만 받으므로 계산이 끝날 때까지 naive 로 다룬다.
if raw_end:
- end = datetime.strptime(raw_end, "%Y-%m-%d")
+ try:
+ end = datetime.strptime(raw_end, "%Y-%m-%d")
+ except ValueError:
+ raise ValueError("날짜 형식이 올바르지 않습니다. YYYY-MM-DD 로 보내주세요.")
else:
end = timezone.localtime().replace(tzinfo=None)
# end 는 그 날을 포함해야 하므로 다음 날 0시를 배타 상한으로 쓴다.
end = end.replace(hour=0, minute=0, second=0, microsecond=0) + timedelta(days=1)
if raw_start:
- start = datetime.strptime(raw_start, "%Y-%m-%d")
+ try:
+ start = datetime.strptime(raw_start, "%Y-%m-%d")
+ except ValueError:
+ raise ValueError("날짜 형식이 올바르지 않습니다. YYYY-MM-DD 로 보내주세요.")
else:
- start = end - timedelta(days=365)
+ start = end - timedelta(days=DEFAULT_RANGE_DAYS)
start = start.replace(hour=0, minute=0, second=0, microsecond=0)
if start >= end:
- raise ValueError("start must be before end")
+ raise ValueError("시작일이 종료일보다 앞서야 합니다.")
+ if (end - start).days > MAX_RANGE_DAYS:
+ raise ValueError(f"조회 구간은 최대 {MAX_RANGE_DAYS}일까지만 가능합니다.")
current_tz = timezone.get_current_timezone()
return timezone.make_aware(start, current_tz), timezone.make_aware(end, current_tz)
# --- 요약 -----------------------------------------------------------
- def _summary(self, params):
- row = _one(_TURNS_CTE + """
- SELECT
- count(*) AS turns,
- count(DISTINCT (user_id, problem_id, session_no)) AS sessions,
- count(DISTINCT user_id) AS users,
- count(DISTINCT problem_id) AS problems,
- count(*) FILTER (WHERE btrim(hint_content) = '') AS empty_turns
- FROM turns
- """, params)
-
- # 채택률: 문제에 손을 댄 (사용자, 문제) 쌍 중 몇 퍼센트가 AI 조교를 썼는가.
- # 분모는 제출한 쌍과 힌트를 받은 쌍의 합집합이다. 힌트만 받고 제출하지 않은
- # 경우가 있어 제출 쌍만 분모로 쓰면 비율이 100%를 넘을 수 있다.
- adoption = _one("""
- SELECT
- count(*) FILTER (WHERE hinted) AS hinted_pairs,
- count(*) AS engaged_pairs
- FROM (
- SELECT user_id, problem_id, bool_or(hinted) AS hinted
- FROM (
- SELECT l.user_id, l.problem_id, true AS hinted
- FROM problem_ai_hint_log l
- JOIN "user" u ON u.id = l.user_id
- WHERE u.admin_type = 'Regular User' AND u.is_disabled = false
- AND l.created_at >= %(start)s AND l.created_at < %(end)s
- UNION ALL
- SELECT s.user_id, s.problem_id, false
- FROM submission s
- JOIN "user" u ON u.id = s.user_id
- WHERE u.admin_type = 'Regular User' AND u.is_disabled = false
- AND s.contest_id IS NULL
- AND s.create_time >= %(start)s AND s.create_time < %(end)s
- ) engaged
- GROUP BY user_id, problem_id
- ) x
- """, params)
-
- turns = row.get("turns") or 0
+ def _summary(self, totals, adoption):
return {
- "turns": turns,
- "sessions": row.get("sessions") or 0,
- "users": row.get("users") or 0,
- "problems": row.get("problems") or 0,
- "failure_rate": _rate(row.get("empty_turns") or 0, turns),
+ "turns": totals.get("turns") or 0,
+ "sessions": totals.get("sessions") or 0,
+ "users": totals.get("users") or 0,
+ "problems": totals.get("problems") or 0,
"hinted_pairs": adoption.get("hinted_pairs") or 0,
"engaged_pairs": adoption.get("engaged_pairs") or 0,
"adoption_rate": _rate(adoption.get("hinted_pairs") or 0,
@@ -207,182 +410,46 @@ def _summary(self, params):
# --- 추이 -----------------------------------------------------------
- def _monthly(self, params):
- return _rows(_TURNS_CTE + """
- SELECT
- to_char(date_trunc('month', created_at AT TIME ZONE %(tz)s), 'YYYY-MM') AS month,
- count(*) AS turns,
- count(DISTINCT (user_id, problem_id, session_no)) AS sessions,
- count(DISTINCT user_id) AS users
- FROM turns
- GROUP BY 1
- ORDER BY 1
- """, params)
-
- def _hourly(self, params):
- found = {
- int(r["hour"]): r["turns"]
- for r in _rows(_TURNS_CTE + """
- SELECT EXTRACT(HOUR FROM created_at AT TIME ZONE %(tz)s) AS hour, count(*) AS turns
- FROM turns GROUP BY 1
- """, params)
- }
+ @staticmethod
+ def _fill_hours(rows):
+ found = {int(r["hour"]): r["turns"] for r in rows}
return [{"hour": h, "turns": found.get(h, 0)} for h in range(24)]
# --- 대화 깊이 ------------------------------------------------------
- def _depth(self, params):
+ @staticmethod
+ def _depth(buckets, limits):
"""세션당 몇 턴을 주고받았는지. 챗봇으로 바뀌어도 그대로 쓰는 지표다."""
- buckets = _rows(_TURNS_CTE + """
- , per_session AS (
- SELECT user_id, problem_id, session_no, count(*) AS turns
- FROM turns
- GROUP BY user_id, problem_id, session_no
- )
- SELECT turns, count(*) AS sessions
- FROM per_session
- GROUP BY turns
- ORDER BY turns
- """, params)
-
- # 힌트 횟수 제한은 (사용자, 문제) 단위로 평생 누적된다. 세션 단위가 아니다.
- # 5회를 며칠에 걸쳐 나눠 쓰면 세션마다는 1~2턴이지만 한도는 채운 것이므로
- # 여기만 세션이 아닌 (사용자, 문제) 기준으로 센다.
- limit_row = _one(_TURNS_CTE + """
- , per_pair AS (
- SELECT user_id, problem_id, count(*) AS turns
- FROM turns
- GROUP BY user_id, problem_id
- )
- SELECT
- count(*) AS pairs,
- count(*) FILTER (WHERE turns >= %(limit)s) AS at_limit
- FROM per_pair
- """, params)
-
total_sessions = sum(b["sessions"] for b in buckets)
total_turns = sum(b["turns"] * b["sessions"] for b in buckets)
single = next((b["sessions"] for b in buckets if b["turns"] == 1), 0)
-
return {
- "distribution": [{"turns": b["turns"], "sessions": b["sessions"]} for b in buckets],
- "sessions": total_sessions,
+ "distribution": buckets,
"avg_turns": round(total_turns / total_sessions, 2) if total_sessions else 0.0,
"single_turn_rate": _rate(single, total_sessions),
# 한도가 사라지면 아래 두 값만 의미를 잃는다.
- "limit_reached_rate": _rate(limit_row.get("at_limit") or 0, limit_row.get("pairs") or 0),
+ "limit_reached_rate": _rate(limits.get("at_limit") or 0, limits.get("pairs") or 0),
"limit": HINT_LIMIT_PER_PROBLEM,
}
- # --- 효과 -----------------------------------------------------------
+ # --- 응답 품질 ------------------------------------------------------
- def _effect(self, params):
- """힌트를 받은 뒤 정답에 도달한 비율과, 힌트를 쓰지 않은 경우의 비율.
+ @staticmethod
+ def _quality(totals):
+ """응답 형식과 길이.
- 주의: 두 집단은 무작위로 나뉘지 않았다. 힌트를 쓰는 학생과 쓰지 않는 학생의
- 성향 차이가 그대로 섞여 있으므로 인과관계로 읽으면 안 된다.
- 화면에서도 표본 수와 함께 보여주고 주의 문구를 붙인다.
+ "응답 실패율"은 여기서 낼 수 없다. `ProblemLLMHintAPI._persist_hint_log` 가
+ 빈 응답일 때 로그 행 자체를 지우므로(횟수도 환불된다) 실패한 요청은 테이블에
+ 남지 않는다. 실패 신호는 Prometheus 의 `AI_HINT_API_OUTCOME_TOTAL`
+ (`empty_response` / `llm_error`)에 있고, 그건 Grafana 가 볼 영역이다.
"""
- with_hint = _one("""
- SELECT count(*) AS pairs, count(*) FILTER (WHERE solved) AS accepted
- FROM (
- SELECT h.user_id, h.problem_id, EXISTS (
- SELECT 1 FROM submission s
- WHERE s.user_id = h.user_id
- AND s.problem_id = h.problem_id
- AND s.contest_id IS NULL
- AND s.result = 0
- AND s.create_time > h.first_hint
- ) AS solved
- FROM (
- SELECT l.user_id, l.problem_id, min(l.created_at) AS first_hint
- FROM problem_ai_hint_log l
- JOIN "user" u ON u.id = l.user_id
- WHERE u.admin_type = 'Regular User' AND u.is_disabled = false
- AND l.created_at >= %(start)s AND l.created_at < %(end)s
- GROUP BY l.user_id, l.problem_id
- ) h
- ) x
- """, params)
-
- without_hint = _one("""
- SELECT count(*) AS pairs, count(*) FILTER (WHERE solved) AS accepted
- FROM (
- SELECT s.user_id, s.problem_id, bool_or(s.result = 0) AS solved
- FROM submission s
- JOIN "user" u ON u.id = s.user_id
- WHERE u.admin_type = 'Regular User' AND u.is_disabled = false
- AND s.contest_id IS NULL
- AND s.create_time >= %(start)s AND s.create_time < %(end)s
- AND NOT EXISTS (
- SELECT 1 FROM problem_ai_hint_log l
- WHERE l.user_id = s.user_id AND l.problem_id = s.problem_id
- )
- GROUP BY s.user_id, s.problem_id
- ) x
- """, params)
-
- def shape(row):
- pairs = row.get("pairs") or 0
- accepted = row.get("accepted") or 0
- return {"pairs": pairs, "accepted": accepted, "rate": _rate(accepted, pairs)}
-
- return {"with_hint": shape(with_hint), "without_hint": shape(without_hint)}
-
- # --- 응답 품질 ------------------------------------------------------
-
- def _quality(self, params):
- row = _one(_TURNS_CTE + """
- SELECT
- count(*) AS turns,
- count(*) FILTER (WHERE btrim(hint_content) = '') AS empty,
- count(*) FILTER (WHERE btrim(hint_content) ~ %(label)s) AS labeled,
- percentile_disc(0.5) WITHIN GROUP (ORDER BY length(hint_content)) AS len_p50,
- percentile_disc(0.9) WITHIN GROUP (ORDER BY length(hint_content)) AS len_p90,
- max(length(hint_content)) AS len_max
- FROM turns
- """, params)
-
- turns = row.get("turns") or 0
- empty = row.get("empty") or 0
- answered = turns - empty
+ turns = totals.get("turns") or 0
return {
- "turns": turns,
- "empty": empty,
- "failure_rate": _rate(empty, turns),
"length": {
- "p50": row.get("len_p50") or 0,
- "p90": row.get("len_p90") or 0,
- "max": row.get("len_max") or 0,
+ "p50": totals.get("len_p50") or 0,
+ "p90": totals.get("len_p90") or 0,
+ "max": totals.get("len_max") or 0,
},
# 단계 라벨은 프롬프트가 지시한 형식이다. 챗봇 전환 시 함께 걷어낸다.
- "labeled": row.get("labeled") or 0,
- "label_rate": _rate(row.get("labeled") or 0, answered),
+ "label_rate": _rate(totals.get("labeled") or 0, turns),
}
-
- # --- 문제별 -------------------------------------------------------
-
- def _top_problems(self, params):
- """요청이 몰리는 문제는 학생들이 막히는 지점이다."""
- return _rows(_TURNS_CTE + """
- SELECT
- p.id AS problem_id,
- p._id AS display_id,
- p.title AS title,
- count(*) AS turns,
- count(DISTINCT t.user_id) AS users,
- count(DISTINCT (t.user_id, t.problem_id, t.session_no)) AS sessions
- FROM turns t
- JOIN problem p ON p.id = t.problem_id
- GROUP BY p.id, p._id, p.title
- ORDER BY turns DESC, users DESC
- LIMIT 10
- """, params)
-
-
-def is_stage_labeled(hint_content):
- """응답이 프롬프트가 요구한 `[N단계]` 머리말을 지켰는지.
-
- SQL 쪽 `STAGE_LABEL_PATTERN` 과 같은 판정을 파이썬에서 쓰기 위한 헬퍼다.
- """
- return bool(re.match(STAGE_LABEL_PATTERN, (hint_content or "").strip()))
diff --git a/backend/problem/views/oj.py b/backend/problem/views/oj.py
index 8ee1275f2..aada63dce 100644
--- a/backend/problem/views/oj.py
+++ b/backend/problem/views/oj.py
@@ -13,7 +13,7 @@
from contest.models import Contest, ContestRuleType, ContestStatus, ContestType
from submission.models import JudgeStatus, Submission
from utils.api import APIView
-from utils.constants import Difficulty, ProblemField, Tier
+from utils.constants import Difficulty, HINT_LIMIT_PER_PROBLEM, ProblemField, Tier
from utils.observability_metrics import AI_HINT_API_OUTCOME_TOTAL
from ..llm_hint import LLMHintError, MAX_USER_CODE_LENGTH, stream_problem_hint
@@ -311,9 +311,10 @@ def get(self, request):
previous_logs = ProblemAIHintLog.objects.filter(user=request.user, problem=problem).order_by("created_at")
problem_hint_count = previous_logs.count()
- if problem_hint_count >= 5:
+ if problem_hint_count >= HINT_LIMIT_PER_PROBLEM:
return self._error_response(
- "이 문제에 대한 AI 조교 사용 횟수(5회)를 모두 소진했습니다. 이전 답변을 복습해 보세요.",
+ f"이 문제에 대한 AI 조교 사용 횟수({HINT_LIMIT_PER_PROBLEM}회)를 모두 소진했습니다. "
+ "이전 답변을 복습해 보세요.",
err="problem-limit-exceeded",
metric_status="problem_limit_exceeded",
scope=scope,
diff --git a/backend/utils/constants.py b/backend/utils/constants.py
index ad2836a1a..2fac168ed 100644
--- a/backend/utils/constants.py
+++ b/backend/utils/constants.py
@@ -107,3 +107,14 @@ def next_tier(cls, current_tier):
return new_tier, new_current_score, new_next_score
except IndexError:
return None
+
+
+# 문제 하나당 받을 수 있는 AI 조교 힌트 횟수. (사용자, 문제) 단위로 평생 누적된다.
+# 요청 제한(problem/views/oj.py), 프롬프트 종료 조건(problem/llm_hint.py),
+# 집계(problem/views/ai_hint_stats.py)가 이 값을 참조한다.
+#
+# 다만 이 값만 바꾸면 기능이 어긋난다. 아래 두 곳은 아직 5가 글로 박혀 있다.
+# - llm_hint.SYSTEM_PROMPT: "exactly 5 levels" 와 [1단계]~[5단계] 목록
+# - frontend BottomDrag.vue: 학생에게 보이는 "n/5 회" 표시
+# 숫자를 바꾸려면 위 두 곳도 함께 손봐야 한다.
+HINT_LIMIT_PER_PROBLEM = 5
diff --git a/frontend/src/i18n/admin/en-US.js b/frontend/src/i18n/admin/en-US.js
index 392e6090d..7fd21f1b1 100644
--- a/frontend/src/i18n/admin/en-US.js
+++ b/frontend/src/i18n/admin/en-US.js
@@ -413,38 +413,27 @@ export const m = {
AI_Hint_Start_Date: "시작일",
AI_Hint_End_Date: "종료일",
AI_Hint_Card_Turns: "총 사용 횟수",
- AI_Hint_Card_Sessions: "대화 수",
- AI_Hint_Card_Sessions_Note: "{minutes}분 간격으로 구분",
+ AI_Hint_Card_Sessions_Note: "{minutes}분 간격으로 구분 · 시작일 기준",
AI_Hint_Card_Users: "사용 학생 수",
AI_Hint_Card_Problems: "사용된 문제 수",
AI_Hint_Card_Adoption: "채택률",
- AI_Hint_Card_Failure: "응답 실패율",
AI_Hint_Usage_Trend: "사용 추이",
AI_Hint_Monthly: "월별 사용량",
AI_Hint_Hourly: "시간대별 사용량",
AI_Hint_Turns: "사용 횟수",
AI_Hint_Sessions: "대화 수",
+ AI_Hint_Session_Count: "{count}건",
AI_Hint_Users: "학생 수",
AI_Hint_Depth: "대화 깊이",
AI_Hint_Turn_Distribution: "몇 번째 힌트까지 이어졌나",
AI_Hint_Avg_Turns: "대화당 평균 횟수",
- AI_Hint_Single_Turn_Rate: "한 번에 끝난 비율",
- AI_Hint_Limit_Reached_Rate: "한도까지 사용한 비율",
-
- AI_Hint_Effect: "학습 효과",
- AI_Hint_Effect_Group: "구분",
- AI_Hint_Effect_With: "AI 조교 사용",
- AI_Hint_Effect_Without: "미사용",
- AI_Hint_Effect_Pairs: "대상 (학생·문제)",
- AI_Hint_Effect_Accepted: "정답 도달",
- AI_Hint_Effect_Rate: "정답 도달률",
- AI_Hint_Effect_Note:
- "무작위로 나뉜 집단이 아니므로 인과관계로 해석할 수 없습니다.",
+ AI_Hint_Single_Turn_Rate: "한 번에 끝난 대화 비율",
+ AI_Hint_Limit_Reached_Rate: "한도({limit}회)까지 쓴 학생·문제 비율",
+ AI_Hint_Limit_Reached_Rate_Plain: "한도까지 쓴 학생·문제 비율",
AI_Hint_Quality: "응답 품질",
- AI_Hint_Failure_Rate: "응답 실패율",
AI_Hint_Label_Rate: "형식 준수율",
AI_Hint_Label_Note: "응답이 [N단계] 머리말을 지킨 비율",
AI_Hint_Length: "응답 길이 (글자)",
diff --git a/frontend/src/pages/admin/views/general/AIHintStats.vue b/frontend/src/pages/admin/views/general/AIHintStats.vue
index 56b7e31ef..8cf247c1b 100644
--- a/frontend/src/pages/admin/views/general/AIHintStats.vue
+++ b/frontend/src/pages/admin/views/general/AIHintStats.vue
@@ -3,27 +3,31 @@
+
+ {{ stats.range.start }} ~ {{ stats.range.end }}
+
open || (pickedAt = null)"
/>
-
-
-
+
+
{{ card.value }}
{{ card.label }}
{{ card.note }}
-
-
-
+
+
@@ -56,7 +60,7 @@
{{ depth.single_turn_rate }}%
- {{ $t("m.AI_Hint_Limit_Reached_Rate") }}
+ {{ limitReachedLabel }}
{{ depth.limit_reached_rate }}%
@@ -64,47 +68,9 @@
-
-
-
-
-
-
-
-
-
-
-
- {{ scope.row.rate }}%
-
-
-
-
- {{ $t("m.AI_Hint_Effect_Note") }}
-
-
-
- {{ $t("m.AI_Hint_Failure_Rate") }}
- {{ quality.failure_rate }}%
-
- ({{ quality.empty }} / {{ quality.turns }})
-
-
{{ $t("m.AI_Hint_Label_Rate") }}
{{ quality.label_rate }}%
@@ -163,69 +129,43 @@ const EMPTY = {
sessions: 0,
users: 0,
problems: 0,
- failure_rate: 0,
hinted_pairs: 0,
engaged_pairs: 0,
adoption_rate: 0,
},
- range: { session_gap_minutes: 30 },
+ range: { start: "", end: "", session_gap_minutes: 0, max_range_span_days: 0 },
monthly: [],
hourly: [],
depth: {
distribution: [],
- sessions: 0,
avg_turns: 0,
single_turn_rate: 0,
limit_reached_rate: 0,
- limit: 5,
- },
- effect: {
- with_hint: { pairs: 0, accepted: 0, rate: 0 },
- without_hint: { pairs: 0, accepted: 0, rate: 0 },
+ limit: 0,
},
quality: {
- turns: 0,
- empty: 0,
- failure_rate: 0,
length: { p50: 0, p90: 0, max: 0 },
- labeled: 0,
label_rate: 0,
},
top_problems: [],
}
-const ACCENT = "#409eff"
+const DAY_MS = 24 * 60 * 60 * 1000
+
// 순서형 램프. 진한 쪽이 앞 단계다. 색만으로 구분하지 않도록 막대에 값도 함께 찍는다.
const ORDINAL = ["#1c5cab", "#2a78d6", "#3987e5", "#5598e7", "#86b6ef"]
-// 요점이 하나인 막대는 그 하나만 진하게, 나머지는 물러나게 한다.
const MUTED = "#c6dcf7"
-// 막대 하나짜리 단순 차트는 형태가 같아 한 곳에서 만든다.
-// colors 를 주면 막대마다 색을 달리한다.
-function barOption(categories, values, name, colors) {
- return {
- tooltip: { trigger: "axis" },
- grid: { left: 40, right: 16, top: 24, bottom: 28 },
- xAxis: { type: "category", data: categories },
- yAxis: { type: "value", minInterval: 1 },
- series: [
- {
- name,
- type: "bar",
- data: colors
- ? values.map((v, i) => ({ value: v, itemStyle: { color: colors[i] } }))
- : values,
- itemStyle: { color: ACCENT },
- },
- ],
- }
-}
-
export default {
name: "AIHintStats",
data() {
return {
loading: false,
+ requestSeq: 0,
+ pickedAt: null,
+ // 응답이 실패해도 폭 제한이 풀리면 안 되므로 stats 와 따로 들고 있는다.
+ maxRangeSpanDays: 0,
+ fetchTimer: null,
range: [],
stats: JSON.parse(JSON.stringify(EMPTY)),
}
@@ -234,6 +174,33 @@ export default {
this.fetch()
},
computed: {
+ // 백엔드 MAX_RANGE_DAYS 와 같은 폭으로 막는다. 넘겨 고르면 서버가 거절하고
+ // 화면이 0으로 비워져 무슨 일이 난 건지 알 수 없다.
+ pickerOptions() {
+ return {
+ disabledDate: (date) => {
+ if (date > new Date()) return true
+ if (!this.pickedAt || !this.maxRangeMs) return false
+ return Math.abs(date - this.pickedAt) > this.maxRangeMs
+ },
+ onPick: ({ minDate, maxDate }) => {
+ this.pickedAt = maxDate ? null : minDate
+ },
+ }
+ },
+ maxRangeMs() {
+ // 허용 폭은 서버가 계산해 응답에 실어 보낸다. 포함/배타 규칙을 여기서
+ // 다시 세면 서버가 바뀔 때 조용히 어긋난다. 아직 모르면 폭 검사를 쉰다.
+ return this.maxRangeSpanDays * DAY_MS
+ },
+ maxTopProblemTurns() {
+ return Math.max(...this.topProblems.map((p) => p.turns), 1)
+ },
+ limitReachedLabel() {
+ return this.depth.limit
+ ? this.$t("m.AI_Hint_Limit_Reached_Rate", { limit: this.depth.limit })
+ : this.$t("m.AI_Hint_Limit_Reached_Rate_Plain")
+ },
summary() {
return this.stats.summary
},
@@ -255,11 +222,14 @@ export default {
note: "",
},
{
- label: this.$t("m.AI_Hint_Card_Sessions"),
+ label: this.$t("m.AI_Hint_Sessions"),
value: s.sessions,
- note: this.$t("m.AI_Hint_Card_Sessions_Note", {
- minutes: this.stats.range.session_gap_minutes,
- }),
+ // 아직 응답이 없으면 간격을 모르므로 설명을 비워 둔다.
+ note: this.stats.range.session_gap_minutes
+ ? this.$t("m.AI_Hint_Card_Sessions_Note", {
+ minutes: this.stats.range.session_gap_minutes,
+ })
+ : "",
},
{
label: this.$t("m.AI_Hint_Card_Users"),
@@ -276,11 +246,6 @@ export default {
value: s.adoption_rate + "%",
note: `${s.hinted_pairs} / ${s.engaged_pairs}`,
},
- {
- label: this.$t("m.AI_Hint_Card_Failure"),
- value: s.failure_rate + "%",
- note: "",
- },
]
},
monthlyOption() {
@@ -319,15 +284,26 @@ export default {
],
}
},
+ // 요점이 하나인 막대는 그 하나만 진하게, 나머지는 물러나게 한다.
hourlyOption() {
const values = this.stats.hourly.map((h) => h.turns)
const peak = Math.max(...values, 0)
- return barOption(
- this.stats.hourly.map((h) => h.hour),
- values,
- this.$t("m.AI_Hint_Turns"),
- values.map((v) => (v === peak && peak > 0 ? "#1c5cab" : MUTED)),
- )
+ return {
+ tooltip: { trigger: "axis" },
+ grid: { left: 40, right: 16, top: 24, bottom: 28 },
+ xAxis: { type: "category", data: this.stats.hourly.map((h) => h.hour) },
+ yAxis: { type: "value", minInterval: 1 },
+ series: [
+ {
+ name: this.$t("m.AI_Hint_Turns"),
+ type: "bar",
+ data: values.map((v) => ({
+ value: v,
+ itemStyle: { color: v === peak && peak > 0 ? ORDINAL[0] : MUTED },
+ })),
+ },
+ ],
+ }
},
// "N턴짜리 대화가 몇 건"이 아니라 "몇 건이 N턴까지 이어졌나"를 보여준다.
// 어디에서 멈추는지가 알고 싶은 것이고, 그건 누적 잔존이라야 보인다.
@@ -373,21 +349,16 @@ export default {
label: {
show: true,
position: "right",
- formatter: (p) => `${rows[p.dataIndex].sessions}건 · ${rows[p.dataIndex].rate}%`,
+ formatter: (p) =>
+ this.$t("m.AI_Hint_Session_Count", {
+ count: rows[p.dataIndex].sessions,
+ }) + ` · ${rows[p.dataIndex].rate}%`,
color: "#5c6773",
},
},
],
}
},
- effectRows() {
- const e = this.stats.effect
- // 미사용은 기준선이므로 회색, 관심 대상인 사용 쪽만 강조한다.
- return [
- { group: this.$t("m.AI_Hint_Effect_With"), color: ACCENT, ...e.with_hint },
- { group: this.$t("m.AI_Hint_Effect_Without"), color: "#c0c4cc", ...e.without_hint },
- ]
- },
// 응답 길이는 분포이므로 눈금 두 개를 얹은 범위 막대로 보여준다.
lengthScale() {
const max = Math.max(this.quality.length.max, 1)
@@ -395,21 +366,38 @@ export default {
return { p50: pct(this.quality.length.p50), p90: pct(this.quality.length.p90) }
},
},
+ beforeDestroy() {
+ clearTimeout(this.fetchTimer)
+ },
methods: {
topProblemWidth(turns) {
- const max = Math.max(...this.topProblems.map((p) => p.turns), 1)
- return Math.round((100 * turns) / max) + "%"
+ return Math.round((100 * turns) / this.maxTopProblemTurns) + "%"
+ },
+ // 달력을 연달아 바꾸면 요청이 겹친다. 응답만 버리면 질의는 그대로 나가므로
+ // 마지막 선택만 실제로 보낸다.
+ scheduleFetch() {
+ clearTimeout(this.fetchTimer)
+ this.fetchTimer = setTimeout(this.fetch, 250)
},
fetch() {
+ // 날짜를 빠르게 바꾸면 느린 응답이 나중에 도착해 최신 결과를 덮어쓸 수 있다.
+ // 마지막 요청만 반영한다.
+ const seq = ++this.requestSeq
this.loading = true
const [start, end] = this.range || []
api
.getAIHintStats(start, end)
.then((res) => {
+ if (seq !== this.requestSeq) return
this.stats = res.data.data
+ this.maxRangeSpanDays =
+ this.stats.range.max_range_span_days || this.maxRangeSpanDays
this.loading = false
})
.catch(() => {
+ if (seq !== this.requestSeq) return
+ // 실패한 구간의 화면에 이전 구간 숫자가 남아 있으면 오독한다.
+ this.stats = JSON.parse(JSON.stringify(EMPTY))
this.loading = false
})
},
@@ -418,7 +406,14 @@ export default {