Skip to content

Commit f3ce789

Browse files
wlsgur11Copilot
andauthored
#601 AI 조교 호출 횟수 제한 및 히스토리 영구 저장 기능 구현 (#611)
* #601 AI 조교 호출 횟수 제한 및 히스토리 영구 저장 기능 구현 - 백엔드: ProblemAIHintLog 모델 추가 및 영구 저장 로직 구현 - 백엔드: 일일 30회, 문제당 5회 횟수 제한 및 관리자(Admin) 우회 로직 적용 - 백엔드: 과거 AI 힌트 내역 및 사용 횟수 조회 API(AIHintHistoryAPI) 추가 - 프론트엔드: BottomDrag.vue에 백엔드 API 연동 및 남은 횟수 UI 렌더링 적용 - 프론트엔드: 기존 localStorage 기반의 임시 저장 로직 제거 - 테스트: 횟수 제한 및 API 관련 백엔드 단위 테스트 추가 및 100% 통과 * #611 copilot 리뷰사항 반영 - 남은 횟수 UI표기 변경 - 페이지가 열릴 때가 아닌 AI 조교 패널이 열릴 때 history 불러오기로 수정 - streaming chunk 누적 방식 string -> list로 변경 - 날짜 탐색 방식 범위 검색 방식으로 변경 - v-html="renderText" 보안 위협으로 인한 제거 * #611 renderText 삭제 - 사용하지 않는 renderText를 삭제하였습니다. * #611 AI 조교 횟수 제한 동시성 이슈 해결 - 브라우저 탭을 여러개 사용하여 횟수 제한을 우회할 수 있는 버그 수정 - 스트리밍 시작 전 빈 로그를 먼저 생성하여 즉시 횟수 차감 처리 (Transaction 적용) - vLLM 통신 에러 및 스트림 중단 시 생성된 빈 로그를 삭제하여 횟수 롤백 로직 추가 - AI 조교 일일 사용 횟수(30회) 제한 제거, 문제당 5회 제한만 유지 - ProblemAIHintLog 모델 성능 최적화: user, created_at 인덱스 추가 * #611 코파일럿 리뷰 반영 - select_for_update()를 활용하여 원자성 충족 -> 동시 요청 병렬적 처리 방지 (DB락) - daily_count 하드 코딩 제거 - 불필요한 import 삭제 - 일일 제한 테스트 코드 삭제 * Update frontend/src/pages/oj/views/problem/problemSolving/problemSolvingComponent/BottomDrag.vue Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * #611 코파일럿 리뷰 반영 - 내용이 조금이라도 있으면 저장, 1바이트도 없으면 횟수 환불 - 바깥에서 미리 만들어둔 hint_log 객체의 내용만 바꾼 뒤 저장 * #611 user_id, problem_id, created_at 인덱스 생성 - user_id, problem_id, created_at 인덱스 생성 * #611 AI 힌트 공백 응답 시 횟수 차감 버그 및 히스토리 API 오류 수정 - AI 힌트 스트리밍 빈 응답 시 횟수 차감 방지 로직 추가 - 히스토리 조회 API의 비공개/대회용 문제 접근 차단 및 조인 최적화 - 동시성 제어 중 발생하는 SimpleLazyObject 에러 해결 (get_user_model) - 힌트 생성 및 조회 관련 엣지 케이스 테스트 코드 추가 --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 25d3e3d commit f3ce789

11 files changed

Lines changed: 412 additions & 91 deletions

File tree

backend/problem/llm_hint.py

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import requests
77
from django.utils.html import strip_tags
8+
import time
89

910
LOCAL_VLLM_CHAT_COMPLETIONS_URL = "http://localhost:8000/v1/chat/completions"
1011
CLUSTER_VLLM_CHAT_COMPLETIONS_URL = "http://vllm:8000/v1/chat/completions"
@@ -62,9 +63,7 @@ def _format_samples(samples):
6263
for index, sample in enumerate(samples, start=1):
6364
input_text = (sample.get("input") or "").strip() or "(비어 있음)"
6465
output_text = (sample.get("output") or "").strip() or "(비어 있음)"
65-
rendered_samples.append(
66-
f"[샘플 입력 {index}]\n{input_text}\n[샘플 출력 {index}]\n{output_text}"
67-
)
66+
rendered_samples.append(f"[샘플 입력 {index}]\n{input_text}\n[샘플 출력 {index}]\n{output_text}")
6867
return "\n\n".join(rendered_samples)
6968

7069

@@ -104,8 +103,14 @@ def build_hint_payload(problem, stream=False):
104103
return {
105104
"model": VLLM_MODEL,
106105
"messages": [
107-
{"role": "system", "content": SYSTEM_PROMPT},
108-
{"role": "user", "content": build_problem_prompt(problem)},
106+
{
107+
"role": "system",
108+
"content": SYSTEM_PROMPT
109+
},
110+
{
111+
"role": "user",
112+
"content": build_problem_prompt(problem)
113+
},
109114
],
110115
"temperature": 0.55,
111116
"max_tokens": 512,
@@ -124,14 +129,18 @@ def _extract_stream_delta(response_json):
124129
return ""
125130

126131
if isinstance(content, list):
127-
return "".join(
128-
item.get("text", "") if isinstance(item, dict) else str(item)
129-
for item in content
130-
)
132+
return "".join(item.get("text", "") if isinstance(item, dict) else str(item) for item in content)
131133
return str(content)
132134

133135

134136
def stream_problem_hint(problem):
137+
if os.getenv("IS_LOCAL_TEST") == "True":
138+
mock_response = "이것은 로컬 테스트용 힌트입니다. 문제의 입력을 다시 확인해보세요."
139+
for char in mock_response:
140+
yield char
141+
time.sleep(0.05) # 실제 스트리밍 느낌을 위해 딜레이 추가
142+
return
143+
135144
response = None
136145
try:
137146
response = requests.post(
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# Generated by Django 3.2.25 on 2026-04-14 05:33
2+
3+
from django.conf import settings
4+
from django.db import migrations, models
5+
import django.db.models.deletion
6+
7+
8+
class Migration(migrations.Migration):
9+
10+
dependencies = [
11+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
12+
('problem', '0001_initial'),
13+
]
14+
15+
operations = [
16+
migrations.CreateModel(
17+
name='ProblemAIHintLog',
18+
fields=[
19+
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
20+
('hint_content', models.TextField()),
21+
('created_at', models.DateTimeField(auto_now_add=True)),
22+
('problem', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='problem.problem')),
23+
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
24+
],
25+
options={
26+
'db_table': 'problem_ai_hint_log',
27+
'ordering': ['-created_at'],
28+
},
29+
),
30+
]
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# Generated by Django 3.2.25 on 2026-04-21 02:22
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('problem', '0002_problemaihintlog'),
10+
]
11+
12+
operations = [
13+
migrations.AlterModelOptions(
14+
name='problemaihintlog',
15+
options={},
16+
),
17+
migrations.AddIndex(
18+
model_name='problemaihintlog',
19+
index=models.Index(fields=['user', 'created_at'], name='problem_ai__user_id_a502c8_idx'),
20+
),
21+
migrations.AddIndex(
22+
model_name='problemaihintlog',
23+
index=models.Index(fields=['problem', 'created_at'], name='problem_ai__problem_3abff5_idx'),
24+
),
25+
]
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Generated by Django 3.2.25 on 2026-04-21 05:14
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('problem', '0003_auto_20260421_1122'),
10+
]
11+
12+
operations = [
13+
migrations.AddIndex(
14+
model_name='problemaihintlog',
15+
index=models.Index(fields=['user', 'problem', 'created_at'], name='problem_ai__user_id_7fac82_idx'),
16+
),
17+
]

backend/problem/models.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,22 @@
66
from utils.constants import Choices
77

88

9+
class ProblemAIHintLog(models.Model):
10+
user = models.ForeignKey("account.User", on_delete=models.CASCADE)
11+
problem = models.ForeignKey("Problem", on_delete=models.CASCADE)
12+
hint_content = models.TextField()
13+
created_at = models.DateTimeField(auto_now_add=True)
14+
15+
class Meta:
16+
db_table = "problem_ai_hint_log"
17+
18+
indexes = [
19+
models.Index(fields=['user', 'created_at']),
20+
models.Index(fields=['problem', 'created_at']),
21+
models.Index(fields=['user', 'problem', 'created_at']),
22+
]
23+
24+
925
class ProblemTag(models.Model):
1026
name = models.TextField()
1127

backend/problem/serializers.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,17 @@
77
from utils.constants import Difficulty
88
from utils.serializers import LanguageNameMultiChoiceField, SPJLanguageNameChoiceField, LanguageNameChoiceField
99

10-
from .models import Problem, ProblemRuleType, ProblemTag, ProblemIOMode, ProblemDifficulty
10+
from .models import Problem, ProblemRuleType, ProblemTag, ProblemIOMode, ProblemDifficulty, ProblemAIHintLog
1111
from .utils import parse_problem_template
1212

1313

14+
class AIHintLogSerializer(serializers.ModelSerializer):
15+
16+
class Meta:
17+
model = ProblemAIHintLog
18+
fields = ["id", "hint_content", "created_at"]
19+
20+
1421
class TestCaseUploadForm(forms.Form):
1522
spj = forms.CharField(max_length=12)
1623
file = forms.FileField()

backend/problem/tests.py

Lines changed: 145 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,12 @@
1616
from unittest import mock
1717

1818
from .models import ProblemTag, ProblemIOMode, get_default_week_info
19-
from .models import Problem, ProblemRuleType
19+
from .models import Problem, ProblemRuleType, ProblemAIHintLog
2020
from .tasks import update_weekly_stats, update_bonus_problem
2121
from contest.models import Contest
2222
from contest.tests import DEFAULT_CONTEST_DATA
23-
from .llm_hint import (CLUSTER_VLLM_CHAT_COMPLETIONS_URL, LOCAL_VLLM_CHAT_COMPLETIONS_URL,
24-
VLLM_CONNECT_TIMEOUT_SEC, VLLM_MODEL, VLLM_STREAM_READ_TIMEOUT_SEC,
25-
get_vllm_chat_completions_url)
23+
from .llm_hint import (CLUSTER_VLLM_CHAT_COMPLETIONS_URL, LOCAL_VLLM_CHAT_COMPLETIONS_URL, VLLM_CONNECT_TIMEOUT_SEC,
24+
VLLM_MODEL, VLLM_STREAM_READ_TIMEOUT_SEC, get_vllm_chat_completions_url)
2625

2726
from .views.admin import TestCaseAPI
2827
from .utils import parse_problem_template
@@ -248,19 +247,21 @@ def test_get_one_problem(self):
248247
class ProblemLLMHintAPITest(ProblemCreateTestBase):
249248

250249
def setUp(self):
250+
os.environ["IS_LOCAL_TEST"] = "False"
251+
251252
self.create_school_fixtures(college_id=1, college_name="Test", department_id=1, department_name="Test")
252253
self.url = self.reverse("problem_llm_hint_api")
253254
self.admin = self.create_admin(login=False)
254255
self.problem = self.add_problem(DEFAULT_PROBLEM_DATA, self.admin)
255256
self.hidden_problem = self.create_problem_with_custom_field(self.admin, _id="A-211", visible=False)
256-
self.create_user(email="test@test.com", username="test", password="test1234!")
257+
# self.create_user(email="test@test.com", username="test", password="test1234!")
258+
self.user = self.create_user(email="test@test.com", username="test", password="test1234!")
259+
self.client.force_login(self.user)
257260

258261
@staticmethod
259262
def _streaming_body(response):
260263
return "".join(
261-
chunk.decode("utf-8") if isinstance(chunk, bytes) else chunk
262-
for chunk in response.streaming_content
263-
)
264+
chunk.decode("utf-8") if isinstance(chunk, bytes) else chunk for chunk in response.streaming_content)
264265

265266
@staticmethod
266267
def _mock_streaming_response(lines):
@@ -286,22 +287,81 @@ def test_stream_llm_hint(self, mocked_post):
286287
self.assertIn(json.dumps({"text": "힌트 "}, ensure_ascii=False), body)
287288
self.assertIn(json.dumps({"text": "스트림"}, ensure_ascii=False), body)
288289
self.assertIn('event: done', body)
290+
self.assertEqual(ProblemAIHintLog.objects.filter(user=self.user, problem=self.problem).count(), 1)
291+
saved_log = ProblemAIHintLog.objects.filter(user=self.user, problem=self.problem).first()
292+
self.assertEqual(saved_log.hint_content, "힌트 스트림")
293+
289294
mocked_post.assert_called_once()
290295
self.assertEqual(mocked_post.call_args.args[0], get_vllm_chat_completions_url())
291296
self.assertEqual(mocked_post.call_args.kwargs["json"]["model"], VLLM_MODEL)
292-
self.assertIn("힌트는 정확히 1개만 제공하라.", mocked_post.call_args.kwargs["json"]["messages"][0]["content"])
293-
self.assertIn("너무 추상적인 조언 대신", mocked_post.call_args.kwargs["json"]["messages"][0]["content"])
297+
self.assertIn("힌트는 정확히 1개의 핵심 아이디어만 제공하라.", mocked_post.call_args.kwargs["json"]["messages"][0]["content"])
298+
self.assertIn("너무 추상적인 조언은 피하고,", mocked_post.call_args.kwargs["json"]["messages"][0]["content"])
294299
self.assertIn(self.problem._id, mocked_post.call_args.kwargs["json"]["messages"][1]["content"])
295300
self.assertNotIn("<p>", mocked_post.call_args.kwargs["json"]["messages"][1]["content"])
296301
self.assertIn("짧은 힌트 하나만 작성해라.", mocked_post.call_args.kwargs["json"]["messages"][1]["content"])
297302
self.assertIn("관찰 포인트를 짚어라.", mocked_post.call_args.kwargs["json"]["messages"][1]["content"])
298-
self.assertEqual(mocked_post.call_args.kwargs["json"]["temperature"], 0.65)
303+
self.assertEqual(mocked_post.call_args.kwargs["json"]["temperature"], 0.55)
299304
self.assertEqual(mocked_post.call_args.kwargs["stream"], True)
300305
self.assertEqual(
301306
mocked_post.call_args.kwargs["timeout"],
302307
(VLLM_CONNECT_TIMEOUT_SEC, VLLM_STREAM_READ_TIMEOUT_SEC),
303308
)
304309

310+
@mock.patch("problem.llm_hint.requests.post")
311+
def test_stream_llm_hint_problem_limit(self, mocked_post):
312+
"""한 문제당 5회 제한에 걸리는지 테스트"""
313+
# 해당 문제에 대해 이미 5개의 힌트 로그가 존재하도록 세팅
314+
for i in range(5):
315+
ProblemAIHintLog.objects.create(user=self.user, problem=self.problem, hint_content=f"더미 {i}")
316+
317+
resp = self.client.get(f"{self.url}?problem_id={self.problem._id}")
318+
body = self._streaming_body(resp)
319+
320+
self.assertIn('event: app-error', body)
321+
self.assertIn("limit-exceeded", body)
322+
self.assertIn("소진", body)
323+
mocked_post.assert_not_called() # 제한에 걸리면 LLM 호출이 아예 안 일어나야 함
324+
325+
@mock.patch("problem.llm_hint.requests.post")
326+
def test_stream_llm_hint_admin_bypass(self, mocked_post):
327+
"""관리자는 횟수 제한 없이 무제한으로 사용 가능한지 테스트"""
328+
mocked_post.return_value = self._mock_streaming_response([
329+
'data: {"choices":[{"delta":{"content":"어드민 패스"}}]}',
330+
"data: [DONE]",
331+
])
332+
333+
# Admin 계정으로 5회 꽉 채움
334+
for i in range(5):
335+
ProblemAIHintLog.objects.create(user=self.admin, problem=self.problem, hint_content=f"더미 {i}")
336+
337+
# 일반 유저를 로그아웃시키고 Admin으로 로그인
338+
self.client.force_login(self.admin)
339+
resp = self.client.get(f"{self.url}?problem_id={self.problem._id}")
340+
body = self._streaming_body(resp)
341+
342+
# 제한에 걸리지 않고 정상 응답이 와야 함
343+
self.assertNotIn("limit-exceeded", body)
344+
self.assertIn("어드민 패스", body)
345+
mocked_post.assert_called_once()
346+
347+
@mock.patch("problem.llm_hint.requests.post")
348+
def test_stream_llm_hint_empty_response(self, mocked_post):
349+
"""정상적으로 스트리밍이 종료되었으나 텍스트가 비어있는 경우, 선제 생성된 로그가 삭제되는지 테스트"""
350+
# 공백만 리턴하는 모델 응답 Mocking
351+
mocked_post.return_value = self._mock_streaming_response([
352+
'data: {"choices":[{"delta":{"content":" "}}]}',
353+
"data: [DONE]",
354+
])
355+
356+
resp = self.client.get(f"{self.url}?problem_id={self.problem._id}")
357+
body = self._streaming_body(resp)
358+
359+
# 1. 정상적으로 done 이벤트가 와야 함
360+
self.assertIn('event: done', body)
361+
362+
# 2. 가장 중요: 빈 텍스트였기 때문에 DB에 로그가 남아있으면 안 됨 (횟수 차감 방어 성공)
363+
self.assertEqual(ProblemAIHintLog.objects.filter(user=self.user, problem=self.problem).count(), 0)
364+
305365
def test_stream_llm_hint_requires_login(self):
306366
self.client.logout()
307367

@@ -372,6 +432,80 @@ def test_get_vllm_chat_completions_url_for_kubernetes(self):
372432
self.assertEqual(get_vllm_chat_completions_url(), CLUSTER_VLLM_CHAT_COMPLETIONS_URL)
373433

374434

435+
class AIHintHistoryAPITest(ProblemCreateTestBase):
436+
437+
def setUp(self):
438+
self.create_school_fixtures(college_id=1, college_name="Test", department_id=1, department_name="Test")
439+
self.url = self.reverse("problem_ai_hint_history_api") # urls/oj.py 에 등록했던 name과 일치해야 함
440+
self.admin = self.create_admin(login=False)
441+
self.problem = self.add_problem(DEFAULT_PROBLEM_DATA, self.admin)
442+
self.user = self.create_user(email="history@test.com", username="history_test", password="test1234!")
443+
self.hidden_problem = self.create_problem_with_custom_field(self.admin, _id="A-211", visible=False)
444+
445+
def test_get_history_require_login(self):
446+
"""로그인하지 않은 유저가 조회 요청 시 에러"""
447+
self.client.logout()
448+
resp = self.client.get(f"{self.url}?problem_id={self.problem._id}")
449+
# 일반 APIView 응답 구조에 맞춰 검증 (에러 메시지 포함 여부)
450+
self.assertIsNotNone(resp.data.get("error"))
451+
452+
def test_get_history_success_and_structure(self):
453+
"""정상적으로 로그와 횟수 데이터가 넘어오는지 구조 검증"""
454+
self.client.force_login(self.user)
455+
456+
# 더미 데이터 생성
457+
ProblemAIHintLog.objects.create(user=self.user, problem=self.problem, hint_content="과거의 힌트 1")
458+
ProblemAIHintLog.objects.create(user=self.user, problem=self.problem, hint_content="과거의 힌트 2")
459+
460+
resp = self.client.get(f"{self.url}?problem_id={self.problem._id}")
461+
self.assertEqual(resp.status_code, 200)
462+
self.assertIsNone(resp.data.get("error"))
463+
464+
payload = resp.data.get("data")
465+
466+
# 응답 구조 검증
467+
self.assertIn("logs", payload)
468+
469+
# 값 검증
470+
self.assertEqual(len(payload["logs"]), 2)
471+
self.assertEqual(payload["logs"][0]["hint_content"], "과거의 힌트 1")
472+
473+
def test_get_history_with_hidden_problem(self):
474+
"""비공개 문제의 힌트 히스토리는 조회할 수 없어야 함"""
475+
self.client.force_login(self.user)
476+
477+
# setUp에서 미리 만들어둔 hidden_problem 사용
478+
resp = self.client.get(f"{self.url}?problem_id={self.hidden_problem._id}")
479+
480+
self.assertIsNotNone(resp.data.get("error"))
481+
self.assertIn("문제를 찾을 수 없습니다.", resp.data.get("data", ""))
482+
483+
def test_get_history_with_contest_problem(self):
484+
"""대회용 문제의 힌트 히스토리는 일반 API로 조회할 수 없어야 함"""
485+
self.client.force_login(self.user)
486+
487+
# 대회 및 대회용 문제 임시 생성
488+
from contest.models import Contest
489+
from contest.tests import DEFAULT_CONTEST_DATA
490+
contest = Contest.objects.create(created_by=self.admin, **DEFAULT_CONTEST_DATA)
491+
contest_problem = self.create_problem_with_custom_field(self.admin, _id="C-999")
492+
contest_problem.contest = contest
493+
contest_problem.save(update_fields=["contest"])
494+
495+
resp = self.client.get(f"{self.url}?problem_id={contest_problem._id}")
496+
497+
self.assertIsNotNone(resp.data.get("error"))
498+
self.assertIn("문제를 찾을 수 없습니다.", resp.data.get("data", ""))
499+
500+
def test_get_history_with_invalid_problem_id(self):
501+
"""존재하지 않는 문제 ID를 넣었을 때 처리"""
502+
self.client.force_login(self.user)
503+
resp = self.client.get(f"{self.url}?problem_id=INVALID_ID")
504+
505+
self.assertIsNotNone(resp.data.get("error"))
506+
self.assertIn("문제를 찾을 수 없습니다.", resp.data.get("data", ""))
507+
508+
375509
class ContestProblemAdminTest(APITestCase):
376510

377511
def setUp(self):

0 commit comments

Comments
 (0)