Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions backend/contest/migrations/0003_contest_ai_assistant_enabled.py
Original file line number Diff line number Diff line change
@@ -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),
),
]
2 changes: 2 additions & 0 deletions backend/contest/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
2 changes: 2 additions & 0 deletions backend/contest/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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(required=False)


class ContestAdminSerializer(serializers.ModelSerializer):
Expand Down
163 changes: 160 additions & 3 deletions backend/problem/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}")
Expand Down
38 changes: 32 additions & 6 deletions backend/problem/views/oj.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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("문제를 찾을 수 없습니다.")
Comment on lines +151 to +173
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

contest_id가 주어졌을 때 대회 접근 권한 검증이 빠져 있습니다. 현재 구현은 Contest.objects.get(id=contest_id)로 대회를 가져온 뒤 ai_assistant_enabled만 확인하므로, 로그인한 사용자가 contest_id/problem_id를 추측하기만 하면 비공개(비밀번호) 대회/시작 전 대회/visible=False 대회 및 visible=False 문제에 대해서도 AI 힌트를 요청할 수 있습니다. ContestProblemAPI가 사용하는 check_contest_permission(check_type="problems")와 동일한 기준(visible=True, 비밀번호 세션, 시작 전 차단 등)으로 검증하고, 문제 조회도 visible=True 조건을 포함하도록 수정해 주세요.

Copilot uses AI. Check for mistakes.
Comment on lines +151 to +173
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

contest_id가 전달된 경우 Contest.objects.get(id=contest_id, ...) / Problem.objects.get(... contest_id=contest_id ...)에서 contest_id가 숫자가 아니면 Django가 ValueError(“expected a number”)를 발생시켜 500으로 터질 수 있습니다(DoesNotExist로는 잡히지 않음). contest_id를 정수로 파싱/검증(check_is_id 등)한 뒤 조회하거나 ValueError/TypeError까지 함께 catch해서 SSE app-error로 내려주세요.

Copilot uses AI. Check for mistakes.
else:
try:
problem = Problem.objects.get(_id=problem_id, contest_id__isnull=True, visible=True)
except Problem.DoesNotExist:
return self._error_response("문제를 찾을 수 없습니다.")
Comment on lines 146 to +178
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

contest_id 경로가 새로 추가되었는데 이에 대한 테스트가 보이지 않습니다. 기존 ProblemLLMHintAPITest에 (1) contest_id를 넘기면 대회 문제에 대해 정상 SSE 응답이 오는지, (2) ai_assistant_enabled=False일 때 app-error가 내려오는지, (3) 비밀번호/시작 전 등 대회 권한 조건이 지켜지는지 케이스를 추가해 주세요.

Copilot uses AI. Check for mistakes.

hint_log = None

Expand Down
2 changes: 1 addition & 1 deletion frontend/src/i18n/admin/en-US.js
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,7 @@ export const m = {
Allowed_IP_Ranges: "허용된 IP 범위",
CIDR_Network: "CIDR 네트워크",
Allow_Paste: "붙여넣기 허용",
Contest_Visible: "활성화하면 참가자에게 대회가 노출됩니다.",
AIAssistant_allow: "AI힌트 기능 허용",
Comment on lines 275 to +278
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

m.AIAssistant_allow 키가 en-US에만 추가되어 있고 frontend/src/i18n/admin/zh-CN.js, zh-TW.js에는 동일 키가 없습니다. 다국어 지원을 위해 다른 로케일 파일에도 동일 키를 추가(번역 값 포함)해 주세요. 그렇지 않으면 해당 언어에서 키 문자열이 그대로 노출됩니다.

Copilot uses AI. Check for mistakes.

Comment on lines 275 to 279
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

관리자 i18n 키(m.AIAssistant_allow)를 en-US에만 추가하면, zh-CN/zh-TW 로케일에서는 해당 토글 라벨이 누락(키 그대로 노출)될 수 있습니다. 동일 키를 다른 admin 로케일 파일에도 추가해 주세요.

Copilot uses AI. Check for mistakes.
// ContestList.vue
Contest_List_Page_Title: "대회 목록",
Expand Down
26 changes: 26 additions & 0 deletions frontend/src/pages/admin/views/contest/Contest.vue
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@
></span>
</label>
</div>

<div class="toggle-item">
<span id="allow_paste_label" class="toggle-label">
{{ $t("m.Allow_Paste") }}
Expand All @@ -146,6 +147,30 @@
></span>
</label>
</div>

<div class="toggle-item">
<span id="allow_ai_label" class="toggle-label">
{{ $t("m.AIAssistant_allow") }}
<el-tooltip
content="대회 진행 중 AI힌트 기능을 허용합니다."
placement="top"
>
<i class="el-icon-question help-icon"></i>
</el-tooltip>
</span>
Comment on lines +151 to +160
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

새로 추가한 AI 허용 토글의 라벨 span이 class="toggle_label"로 되어 있는데, 이 파일 내에서 정의/사용하는 클래스는 .toggle-label입니다. 현재 상태면 스타일이 적용되지 않으므로 기존 토글들과 동일하게 toggle-label로 수정해 주세요.

Copilot uses AI. Check for mistakes.

<label class="spj-toggle">
<input
type="checkbox"
v-model="contest.ai_assistant_enabled"
aria-labelledby="allow_ai_label"
/>
<span
class="spj-toggle-track"
:class="{ 'is-on': contest.ai_assistant_enabled }"
></span>
</label>
</div>
</div>
</div>

Expand Down Expand Up @@ -216,6 +241,7 @@ export default {
real_time_rank: true,
visible: true,
allow_paste: true,
ai_assistant_enabled: true,
allowed_ip_ranges: [{ value: "" }],
},
}
Expand Down
6 changes: 4 additions & 2 deletions frontend/src/pages/oj/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -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", {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,11 @@ export default {
this.$Loading.start()
this.contestID = this.$route.params.contestID
this.problemID = this.$route.params.problemID

if (this.contestID) {
this.$store.dispatch("getContest", this.contestID)
}

let func =
this.$route.name === "problem-details"
? "getProblem"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
<template>
<button type="button" class="submissionBtnWrapper" @click="onClick">
<button
type="button"
class="submissionBtnWrapper"
@click="onClick"
v-if="!$route.params.contestID || (contest && contest.ai_assistant_enabled)"
>
<svg class="icon" viewBox="0 0 24 24">
<path
d="m20 24h-16c-.553 0-1-.447-1-1 0-3.309 2.691-6 6-6h6c3.309 0 6 2.691 6 6 0 .553-.447 1-1 1zm-10.013-14.6h1.067l-.519-2.494zm10.513-3.4h-.551c-.252-2.244-2.139-4-4.449-4h-2.5v-1c0-.552-.447-1-1-1s-1 .448-1 1v1h-2.5c-2.31 0-4.197 1.756-4.449 4h-.551c-.827 0-1.5.673-1.5 1.5v2c0 .827.673 1.5 1.5 1.5h.551c.252 2.244 2.139 4 4.449 4h7c2.31 0 4.197-1.756 4.449-4h.551c.827 0 1.5-.673 1.5-1.5v-2c0-.827-.673-1.5-1.5-1.5zm-8.227 6c-.385.017-.73-.246-.808-.625l-.078-.375h-1.751l-.084.383c-.079.36-.398.617-.767.617-.499 0-.872-.46-.768-.948l.993-4.668c.146-.755.76-1.383 1.523-1.383h.002c.782.004 1.368.603 1.534 1.423l.973 4.63c.102.488-.27.946-.768.946zm3.727-.8c0 .442-.358.8-.8.8s-.8-.358-.8-.8v-5.399c0-.442.358-.8.8-.8s.8.358.8.8z"
Expand All @@ -14,6 +19,11 @@ import { defineComponent } from "vue"

export default defineComponent({
emits: ["open-ai"],
computed: {
contest() {
return this.$store.state.contest.contest
},
},
methods: {
onClick() {
this.$emit("open-ai")
Expand Down
Loading
Loading