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 @@ + + + + + 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 }} +
- - -
+
+

{{ 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 @@
- - - - - - - - - -

{{ $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 {