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()
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.

EditConetestSeriaizer에서 ai_assistant_enabled를 필수(BooleanField())로 추가하면서, 구버전 프론트엔드/클라이언트가 해당 필드를 보내지 않으면 대회 수정 PUT이 바로 400으로 실패합니다(롤링 배포/호환성 리스크). Create serializer처럼 default=True를 주거나 required=False로 두고 뷰에서 기존 값 유지(fallback)하도록 처리하는 편이 안전합니다.

Suggested change
ai_assistant_enabled = serializers.BooleanField()
ai_assistant_enabled = serializers.BooleanField(required=False)

Copilot uses AI. Check for mistakes.


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 @@ -187,6 +187,7 @@ export default {
rightPainActiveTab: "editor",
lastSubmissionId: null,
isInitialized: false,
contestData: null,
}
},
beforeRouteEnter(to, from, next) {
Expand Down Expand Up @@ -215,6 +216,7 @@ export default {
this.init()
window.addEventListener("beforeunload", this.unLoadEvent)
this.isInitialized = true
console.log("contest:", this.contest)
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.

mounted()console.log("contest:", this.contest) 디버그 로그가 남아 있습니다. 프로덕션 콘솔 오염 및 개인정보 노출 가능성이 있으니 제거해 주세요.

Suggested change
console.log("contest:", this.contest)

Copilot uses AI. Check for mistakes.
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.

mounted()에 남아있는 console.log는 운영 환경에서 불필요한 로그/노이즈를 유발합니다. 디버깅 목적이라면 제거하거나(권장) 필요 시 개발 환경에서만 출력되도록 가드 처리해 주세요.

Suggested change
console.log("contest:", this.contest)

Copilot uses AI. Check for mistakes.
},
beforeUnmount() {
window.removeEventListener("beforeunload", this.unLoadEvent)
Expand All @@ -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)
})
Comment on lines +241 to +244
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.

contestData를 data에 추가하고 api.getContest()로 값을 세팅하지만, 이 컴포넌트 내에서 contestData가 사용되지 않고 console.log만 남아 있습니다. 불필요한 상태/네트워크 호출이므로 제거하거나, 실제로 필요한 UI/로직에서 사용하도록 연결해 주세요.

Suggested change
api.getContest(this.contestID).then((res) => {
this.contestData = res.data.data
console.log("contestData: ", this.contestData)
})

Copilot uses AI. Check for mistakes.
Comment on lines +239 to +244
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.

init()에서 this.contestID가 없는(일반 문제 풀이) 라우트에서도 getContest 디스패치와 api.getContest(this.contestID) 호출이 실행되어 /api/contest?id=undefined 요청이 발생할 수 있습니다. 또한 동일 데이터를 Vuex 액션과 직접 API 호출로 이중 조회하고 있어 중복입니다. contestID가 있을 때만 조회하도록 가드하고, 한쪽(Vuex 액션 또는 로컬 state)만 사용하도록 정리해 주세요.

Suggested change
this.$store.dispatch("getContest", this.contestID)
api.getContest(this.contestID).then((res) => {
this.contestData = res.data.data
console.log("contestData: ", this.contestData)
})
if (this.contestID) {
api.getContest(this.contestID).then((res) => {
this.contestData = res.data.data
console.log("contestData: ", this.contestData)
})
}

Copilot uses AI. Check for mistakes.
let func =
this.$route.name === "problem-details"
? "getProblem"
Expand Down
Loading