diff --git a/backend/contest/migrations/0003_contest_ai_assistant_enabled.py b/backend/contest/migrations/0003_contest_ai_assistant_enabled.py new file mode 100644 index 000000000..0ad29fe85 --- /dev/null +++ b/backend/contest/migrations/0003_contest_ai_assistant_enabled.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.25 on 2026-04-23 05:36 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('contest', '0002_contest_allow_paste'), + ] + + operations = [ + migrations.AddField( + model_name='contest', + name='ai_assistant_enabled', + field=models.BooleanField(default=True), + ), + ] diff --git a/backend/contest/models.py b/backend/contest/models.py index 090beb481..09b58ecb4 100644 --- a/backend/contest/models.py +++ b/backend/contest/models.py @@ -25,6 +25,8 @@ class Contest(models.Model): # 是否可见 false的话相当于删除 visible = models.BooleanField(default=True) allowed_ip_ranges = JSONField(default=list) + #AI assistant + ai_assistant_enabled = models.BooleanField(default=True) @property def status(self): diff --git a/backend/contest/serializers.py b/backend/contest/serializers.py index d9a884f1d..9ba483812 100644 --- a/backend/contest/serializers.py +++ b/backend/contest/serializers.py @@ -28,6 +28,7 @@ class CreateConetestSeriaizer(serializers.Serializer): real_time_rank = serializers.BooleanField() allow_paste = serializers.BooleanField() allowed_ip_ranges = serializers.ListField(child=serializers.CharField(max_length=32), allow_empty=True) + ai_assistant_enabled = serializers.BooleanField(default=True) class EditConetestSeriaizer(serializers.Serializer): @@ -41,6 +42,7 @@ class EditConetestSeriaizer(serializers.Serializer): real_time_rank = serializers.BooleanField() allow_paste = serializers.BooleanField() allowed_ip_ranges = serializers.ListField(child=serializers.CharField(max_length=32)) + ai_assistant_enabled = serializers.BooleanField() class ContestAdminSerializer(serializers.ModelSerializer): diff --git a/backend/problem/tests.py b/backend/problem/tests.py index b925708c1..9fa7248ff 100644 --- a/backend/problem/tests.py +++ b/backend/problem/tests.py @@ -18,10 +18,12 @@ from .models import ProblemTag, ProblemIOMode, get_default_week_info from .models import Problem, ProblemRuleType, ProblemAIHintLog from .tasks import update_weekly_stats, update_bonus_problem -from contest.models import Contest +from contest.models import Contest, ContestRuleType from contest.tests import DEFAULT_CONTEST_DATA -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, get_vllm_chat_completions_url) +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, + get_vllm_chat_completions_url) from .views.admin import TestCaseAPI from .utils import parse_problem_template @@ -404,6 +406,161 @@ def test_stream_llm_hint_with_contest_problem(self): self.assertIn('event: app-error', body) self.assertIn("문제를 찾을 수 없습니다.", body) + # ------------------------------------------------------------------ + # contest_id path tests + # ------------------------------------------------------------------ + + def _make_contest(self, problem_id, **contest_kwargs): + """Return (contest, problem) — underway, public, ai_enabled by default.""" + defaults = { + "title": "contest test", + "description": "desc", + "start_time": timezone.localtime(timezone.now()) - timedelta(hours=1), + "end_time": timezone.localtime(timezone.now()) + timedelta(days=1), + "rule_type": ContestRuleType.ACM, + "password": "", + "allowed_ip_ranges": [], + "visible": True, + "real_time_rank": True, + "allow_paste": True, + "ai_assistant_enabled": True, + } + defaults.update(contest_kwargs) + contest = Contest.objects.create(created_by=self.admin, **defaults) + problem = self.create_problem_with_custom_field(self.admin, _id=problem_id) + problem.contest = contest + problem.save(update_fields=["contest"]) + return contest, problem + + @mock.patch("problem.llm_hint.requests.post") + def test_contest_stream_llm_hint(self, mocked_post): + """SSE stream works for an accessible contest problem.""" + mocked_post.return_value = self._mock_streaming_response([ + 'data: {"choices":[{"delta":{"content":"힌트"}}]}', + "data: [DONE]", + ]) + contest, problem = self._make_contest("C-301") + + resp = self.client.get(f"{self.url}?problem_id={problem._id}&contest_id={contest.id}") + body = self._streaming_body(resp) + + self.assertEqual(resp["Content-Type"], "text/event-stream") + self.assertIn('event: chunk', body) + self.assertIn('event: done', body) + self.assertNotIn('event: app-error', body) + mocked_post.assert_called_once() + + def test_contest_stream_llm_hint_ai_disabled(self): + """app-error when ai_assistant_enabled=False.""" + contest, problem = self._make_contest("C-302", ai_assistant_enabled=False) + + resp = self.client.get(f"{self.url}?problem_id={problem._id}&contest_id={contest.id}") + body = self._streaming_body(resp) + + self.assertIn('event: app-error', body) + self.assertIn("AI 조교를 사용할 수 없습니다", body) + + def test_contest_stream_llm_hint_invisible_contest(self): + """app-error (permission-denied) when contest has visible=False.""" + contest, problem = self._make_contest("C-303", visible=False) + + resp = self.client.get(f"{self.url}?problem_id={problem._id}&contest_id={contest.id}") + body = self._streaming_body(resp) + + self.assertIn('event: app-error', body) + self.assertIn("permission-denied", body) + + def test_contest_stream_llm_hint_password_no_session(self): + """app-error when contest is password-protected and no password is in session.""" + contest, problem = self._make_contest("C-304", password="secret") + + resp = self.client.get(f"{self.url}?problem_id={problem._id}&contest_id={contest.id}") + body = self._streaming_body(resp) + + self.assertIn('event: app-error', body) + self.assertIn("permission-denied", body) + + def test_contest_stream_llm_hint_password_wrong(self): + """app-error when session contains a wrong password.""" + contest, problem = self._make_contest("C-305", password="secret") + + session = self.client.session + session[CONTEST_PASSWORD_SESSION_KEY] = {contest.id: "wrong"} + session.save() + + resp = self.client.get(f"{self.url}?problem_id={problem._id}&contest_id={contest.id}") + body = self._streaming_body(resp) + + self.assertIn('event: app-error', body) + self.assertIn("permission-denied", body) + + @mock.patch("problem.llm_hint.requests.post") + def test_contest_stream_llm_hint_password_correct(self, mocked_post): + """SSE stream works when the correct password is stored in session.""" + mocked_post.return_value = self._mock_streaming_response([ + 'data: {"choices":[{"delta":{"content":"힌트"}}]}', + "data: [DONE]", + ]) + contest, problem = self._make_contest("C-306", password="secret") + + session = self.client.session + session[CONTEST_PASSWORD_SESSION_KEY] = {contest.id: "secret"} + session.save() + + resp = self.client.get(f"{self.url}?problem_id={problem._id}&contest_id={contest.id}") + body = self._streaming_body(resp) + + self.assertIn('event: chunk', body) + self.assertIn('event: done', body) + self.assertNotIn('event: app-error', body) + + def test_contest_stream_llm_hint_not_started(self): + """app-error (permission-denied) when the contest has not started yet.""" + contest, problem = self._make_contest( + "C-307", + start_time=timezone.localtime(timezone.now()) + timedelta(hours=1), + ) + + resp = self.client.get(f"{self.url}?problem_id={problem._id}&contest_id={contest.id}") + body = self._streaming_body(resp) + + self.assertIn('event: app-error', body) + self.assertIn("permission-denied", body) + + @mock.patch("problem.llm_hint.requests.post") + def test_contest_stream_llm_hint_admin_bypasses_restrictions(self, mocked_post): + """Contest creator gets SSE stream even for not-started password-protected contests.""" + mocked_post.return_value = self._mock_streaming_response([ + 'data: {"choices":[{"delta":{"content":"힌트"}}]}', + "data: [DONE]", + ]) + contest, problem = self._make_contest( + "C-308", + password="secret", + start_time=timezone.localtime(timezone.now()) + timedelta(hours=1), + ) + + self.client.login(username="admin@admin.com", password="admin1234!") + + resp = self.client.get(f"{self.url}?problem_id={problem._id}&contest_id={contest.id}") + body = self._streaming_body(resp) + + self.assertIn('event: chunk', body) + self.assertIn('event: done', body) + self.assertNotIn('event: app-error', body) + + def test_contest_stream_llm_hint_hidden_problem(self): + """app-error when the problem itself has visible=False within an accessible contest.""" + contest, problem = self._make_contest("C-309") + problem.visible = False + problem.save(update_fields=["visible"]) + + resp = self.client.get(f"{self.url}?problem_id={problem._id}&contest_id={contest.id}") + body = self._streaming_body(resp) + + self.assertIn('event: app-error', body) + self.assertIn("문제를 찾을 수 없습니다.", body) + @mock.patch("problem.llm_hint.requests.post", side_effect=requests.Timeout) def test_stream_llm_hint_handles_timeout(self, mocked_post): resp = self.client.get(f"{self.url}?problem_id={self.problem._id}") diff --git a/backend/problem/views/oj.py b/backend/problem/views/oj.py index 7d9e16bd3..e78b8adaa 100644 --- a/backend/problem/views/oj.py +++ b/backend/problem/views/oj.py @@ -7,9 +7,10 @@ from django.http import HttpResponseBadRequest, HttpResponseNotFound, StreamingHttpResponse from django.contrib.auth import get_user_model -from account.decorators import (check_contest_permission, scheduler_only) +from account.decorators import (check_contest_permission, check_contest_password, scheduler_only) +from utils.constants import CONTEST_PASSWORD_SESSION_KEY from account.models import UserProfile, UserScore -from contest.models import ContestRuleType +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 @@ -143,13 +144,38 @@ def get(self, request): return self._error_response("로그인이 필요합니다.", err="permission-denied") problem_id = request.GET.get("problem_id") + contest_id = request.GET.get("contest_id") if not problem_id: return self._error_response("문제 번호가 필요합니다.") - try: - problem = Problem.objects.get(_id=problem_id, contest_id__isnull=True, visible=True) - except Problem.DoesNotExist: - return self._error_response("문제를 찾을 수 없습니다.") + if contest_id: + try: + contest = Contest.objects.select_related("created_by").get(id=contest_id, visible=True) + except Contest.DoesNotExist: + return self._error_response("대회를 찾을 수 없습니다.", err="permission-denied") + + if not request.user.is_contest_admin(contest): + if contest.contest_type == ContestType.PASSWORD_PROTECTED_CONTEST: + if not check_contest_password( + request.session.get(CONTEST_PASSWORD_SESSION_KEY, {}).get(contest.id), + contest.password, + ): + return self._error_response("비밀번호가 올바르지 않거나 만료되었습니다.", err="permission-denied") + + if contest.status == ContestStatus.CONTEST_NOT_START: + return self._error_response("아직 시작하지 않은 대회입니다.", err="permission-denied") + + if not contest.ai_assistant_enabled: + return self._error_response("이 대회에서는 AI 조교를 사용할 수 없습니다.") + try: + problem = Problem.objects.get(_id=problem_id, contest_id=contest_id, visible=True) + except Problem.DoesNotExist: + return self._error_response("문제를 찾을 수 없습니다.") + else: + try: + problem = Problem.objects.get(_id=problem_id, contest_id__isnull=True, visible=True) + except Problem.DoesNotExist: + return self._error_response("문제를 찾을 수 없습니다.") hint_log = None diff --git a/frontend/src/i18n/admin/en-US.js b/frontend/src/i18n/admin/en-US.js index 1d54e8d3c..e887ba83e 100644 --- a/frontend/src/i18n/admin/en-US.js +++ b/frontend/src/i18n/admin/en-US.js @@ -275,7 +275,7 @@ export const m = { Allowed_IP_Ranges: "허용된 IP 범위", CIDR_Network: "CIDR 네트워크", Allow_Paste: "붙여넣기 허용", - Contest_Visible: "활성화하면 참가자에게 대회가 노출됩니다.", + AIAssistant_allow: "AI힌트 기능 허용", // ContestList.vue Contest_List_Page_Title: "대회 목록", diff --git a/frontend/src/pages/admin/views/contest/Contest.vue b/frontend/src/pages/admin/views/contest/Contest.vue index d87ef5ab9..ffd691f50 100644 --- a/frontend/src/pages/admin/views/contest/Contest.vue +++ b/frontend/src/pages/admin/views/contest/Contest.vue @@ -124,6 +124,7 @@ > +
{{ $t("m.Allow_Paste") }} @@ -146,6 +147,30 @@ >
+ +
+ + {{ $t("m.AIAssistant_allow") }} + + + + + + +
@@ -216,6 +241,7 @@ export default { real_time_rank: true, visible: true, allow_paste: true, + ai_assistant_enabled: true, allowed_ip_ranges: [{ value: "" }], }, } diff --git a/frontend/src/pages/oj/api.js b/frontend/src/pages/oj/api.js index 7f14ee412..fa7a8ee70 100644 --- a/frontend/src/pages/oj/api.js +++ b/frontend/src/pages/oj/api.js @@ -209,8 +209,10 @@ export default { }, }) }, - getProblemLLMHintUrl(problemID) { - return `/api/problem/llm_hint?problem_id=${encodeURIComponent(problemID)}` + getProblemLLMHintUrl(problemID, contestID) { + let url = `/api/problem/llm_hint?problem_id=${encodeURIComponent(problemID)}` + if (contestID) url += `&contest_id=${encodeURIComponent(contestID)}` + return url }, getAIHintHistory(problemID) { return ajax("problem/ai_hint_history", "get", { diff --git a/frontend/src/pages/oj/views/problem/problemSolving/Problem.vue b/frontend/src/pages/oj/views/problem/problemSolving/Problem.vue index 3f5f9c289..e84cc45c4 100644 --- a/frontend/src/pages/oj/views/problem/problemSolving/Problem.vue +++ b/frontend/src/pages/oj/views/problem/problemSolving/Problem.vue @@ -187,6 +187,7 @@ export default { rightPainActiveTab: "editor", lastSubmissionId: null, isInitialized: false, + contestData: null, } }, beforeRouteEnter(to, from, next) { @@ -215,6 +216,7 @@ export default { this.init() window.addEventListener("beforeunload", this.unLoadEvent) this.isInitialized = true + console.log("contest:", this.contest) }, beforeUnmount() { window.removeEventListener("beforeunload", this.unLoadEvent) @@ -233,6 +235,13 @@ export default { this.$Loading.start() this.contestID = this.$route.params.contestID this.problemID = this.$route.params.problemID + + this.$store.dispatch("getContest", this.contestID) + + api.getContest(this.contestID).then((res) => { + this.contestData = res.data.data + console.log("contestData: ", this.contestData) + }) let func = this.$route.name === "problem-details" ? "getProblem" diff --git a/frontend/src/pages/oj/views/problem/problemSolving/problemSolvingComponent/AIAssistantBtn.vue b/frontend/src/pages/oj/views/problem/problemSolving/problemSolvingComponent/AIAssistantBtn.vue index 6e2542a39..08cf5d29a 100644 --- a/frontend/src/pages/oj/views/problem/problemSolving/problemSolvingComponent/AIAssistantBtn.vue +++ b/frontend/src/pages/oj/views/problem/problemSolving/problemSolvingComponent/AIAssistantBtn.vue @@ -1,5 +1,10 @@