From 889380c58e6a6df925963ab678d55f7ffad83987 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Sun, 8 Mar 2026 21:15:46 +0800 Subject: [PATCH] add teaching feature --- CLAUDE.md | 112 +++++++++++++++++++++++++++++++++++++++++ ai/urls/oj.py | 2 + ai/views/oj.py | 80 +++++++++++++++++++++++++++++ problem/urls/admin.py | 2 + problem/urls/oj.py | 2 + problem/views/admin.py | 34 ++++++++++++- problem/views/oj.py | 50 ++++++++++++++++++ 7 files changed, 281 insertions(+), 1 deletion(-) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..f36b1a9 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,112 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +**OnlineJudge** is the backend for an Online Judge platform. Built with Django 5 + Django REST Framework, PostgreSQL, Redis, Django Channels (WebSocket), and Dramatiq (async task queue). Python 3.12+, managed with `uv`. + +## Commands + +```bash +# Development +python dev.py # Start dev server (Daphne ASGI + Django runserver) +python manage.py runserver # HTTP only (no WebSocket support) +python manage.py migrate # Apply database migrations +python manage.py makemigrations # Create new migrations + +# Dependencies +uv sync # Install dependencies from uv.lock +uv add # Add a dependency + +# Testing +python manage.py test # Run Django test suite +coverage run manage.py test # Run with coverage +coverage report # Show coverage report +``` + +## Architecture + +### App Modules + +Each Django app follows the same structure: +``` +/ +├── models.py # Django models +├── serializers.py # DRF serializers +├── views/ +│ ├── oj.py # User-facing API views +│ └── admin.py # Admin API views +└── urls/ + ├── oj.py # User-facing URL patterns + └── admin.py # Admin URL patterns +``` + +Apps: `account`, `problem`, `submission`, `contest`, `ai`, `flowchart`, `problemset`, `class_pk`, `announcement`, `tutorial`, `message`, `comment`, `conf`, `options`, `judge` + +### URL Routing + +All routes are registered in `oj/urls.py`: +- `api/` — user-facing endpoints +- `api/admin/` — admin-only endpoints + +WebSocket routing is in `oj/routing.py`. + +### Settings Structure + +- `oj/settings.py` — base configuration +- `oj/dev_settings.py` — development overrides (imported when `OJ_ENV != "production"`) +- `oj/production_settings.py` — production overrides + +### Base APIView + +`utils/api/api.py` provides a custom `APIView` base class used by all views. It provides: +- `self.success(data)` — returns `{"error": null, "data": data}` +- `self.error(msg)` — returns `{"error": "error", "data": msg}` + +All views inherit from this, not Django's generic views or DRF's `APIView` directly. + +### Authentication & Permissions + +`account/decorators.py` provides decorators used on view methods: +- `@login_required` +- `@super_admin_required` +- `@check_contest_permission` + +### Judge System + +- `judge/dispatcher.py` — dispatches submissions to the judge sandbox +- `judge/tasks.py` — Dramatiq async tasks for judging +- `judge/languages.py` — language configurations (compile/run commands, limits) + +Judge status codes are defined in `utils/constants.py` and must match the frontend's `utils/constants.ts`. + +### WebSocket (Channels) + +`submission/consumers.py` — WebSocket consumer for real-time submission status updates. Uses `channels-redis` as the channel layer backend. + +### Caching + +Redis-backed via `django-redis`. Cache keys use MD5 hashing for consistency. See `utils/cache.py`. + +### AI Integration + +`utils/openai.py` — OpenAI client wrapper configured to work with OpenAI-compatible APIs (e.g., DeepSeek). Used by `ai/` app for submission analysis. + +### Data Directory + +Test cases and submission outputs are stored in a separate data directory (configured in settings, not in the repo). The `data/` directory in the repo contains configuration templates. + +## Key Domain Concepts + +| Concept | Details | +|---|---| +| Problem types | ACM (binary accept/reject) vs OI (partial scoring) | +| Judge statuses | Numeric codes -2 to 9 (defined in `utils/constants.py`) | +| User roles | Regular / Admin / Super Admin | +| Contest types | Public vs Password Protected | +| Supported languages | C, C++, Python2, Python3, Java, JavaScript, Golang, Flowchart | + +## Related Repository + +The frontend is at `D:\Projects\ojnext` — a Vue 3 + Rsbuild project. See its CLAUDE.md for frontend details. diff --git a/ai/urls/oj.py b/ai/urls/oj.py index 26cd13b..8bf02fe 100644 --- a/ai/urls/oj.py +++ b/ai/urls/oj.py @@ -5,6 +5,7 @@ from ..views.oj import ( AIDetailDataAPI, AIDurationDataAPI, AIHeatmapDataAPI, + AIHintAPI, AILoginSummaryAPI, ) @@ -12,6 +13,7 @@ urlpatterns = [ path("ai/detail", AIDetailDataAPI.as_view()), path("ai/duration", AIDurationDataAPI.as_view()), path("ai/analysis", AIAnalysisAPI.as_view()), + path("ai/hint", AIHintAPI.as_view()), path("ai/heatmap", AIHeatmapDataAPI.as_view()), path("ai/login_summary", AILoginSummaryAPI.as_view()), ] diff --git a/ai/views/oj.py b/ai/views/oj.py index 8e3b79f..68c9c58 100644 --- a/ai/views/oj.py +++ b/ai/views/oj.py @@ -715,6 +715,86 @@ class AIAnalysisAPI(APIView): return response +class AIHintAPI(APIView): + @login_required + def post(self, request): + submission_id = request.data.get("submission_id") + if not submission_id: + return self.error("submission_id is required") + + try: + submission = Submission.objects.get(id=submission_id, user_id=request.user.id) + except Submission.DoesNotExist: + return self.error("Submission not found") + + problem = submission.problem + client = get_ai_client() + + # 获取参考答案(同语言优先,否则取第一个) + answers = problem.answers or [] + ref_answer = next( + (a["code"] for a in answers if a["language"] == submission.language), + answers[0]["code"] if answers else "", + ) + + system_prompt = ( + "你是编程助教。你知道题目的参考答案,但【绝对禁止】把参考答案或其中任何代码" + "直接告诉学生,也不能以任何形式暗示完整解法。" + "你的任务是:对照参考答案,找出学生代码中的问题," + "给出方向性提示(例如:指出哪类边界情况需要考虑、" + "哪个算法思路更合适、哪行代码逻辑可能有问题等)。" + "语气鼓励,回复简洁(3-5句话),使用 Markdown 格式。" + ) + user_prompt = ( + f"题目:{problem.title}\n" + f"题目描述:{problem.description[:500]}\n" + f"参考答案(仅供你分析,不可透露给学生):\n```\n{ref_answer[:2000]}\n```\n" + f"学生提交语言:{submission.language}\n" + f"判题结果:{submission.result}\n" + f"错误信息:{submission.statistic_info.get('err_info', '无')}\n" + f"学生代码:\n```\n{submission.code[:2000]}\n```" + ) + + def stream_generator(): + try: + stream = client.chat.completions.create( + model="deepseek-chat", + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ], + stream=True, + ) + except Exception as exc: + yield f"data: {json.dumps({'type': 'error', 'message': str(exc)})}\n\n" + yield "event: end\n\n" + return + + yield "event: start\n\n" + try: + for chunk in stream: + if not chunk.choices: + continue + choice = chunk.choices[0] + if choice.finish_reason: + yield f"data: {json.dumps({'type': 'done'})}\n\n" + break + content = choice.delta.content + if content: + yield f"data: {json.dumps({'type': 'delta', 'content': content})}\n\n" + except Exception as exc: + yield f"data: {json.dumps({'type': 'error', 'message': str(exc)})}\n\n" + finally: + yield "event: end\n\n" + + response = StreamingHttpResponse( + streaming_content=stream_generator(), + content_type="text/event-stream", + ) + response["Cache-Control"] = "no-cache" + return response + + class AIHeatmapDataAPI(APIView): @login_required def get(self, request): diff --git a/problem/urls/admin.py b/problem/urls/admin.py index aaf58fa..d7953a7 100644 --- a/problem/urls/admin.py +++ b/problem/urls/admin.py @@ -4,6 +4,7 @@ from ..views.admin import ( ContestProblemAPI, ProblemAPI, ProblemFlowchartAIGen, + StuckProblemsAPI, TestCaseAPI, MakeContestProblemPublicAPIView, AddContestProblemAPI, @@ -14,6 +15,7 @@ urlpatterns = [ path("test_case", TestCaseAPI.as_view()), path("problem", ProblemAPI.as_view()), path("problem/visible", ProblemVisibleAPI.as_view()), + path("problem/stuck", StuckProblemsAPI.as_view()), path("problem/flowchart", ProblemFlowchartAIGen.as_view()), path("contest/problem", ContestProblemAPI.as_view()), path("contest_problem/make_public", MakeContestProblemPublicAPIView.as_view()), diff --git a/problem/urls/oj.py b/problem/urls/oj.py index 5fd1460..b91d861 100644 --- a/problem/urls/oj.py +++ b/problem/urls/oj.py @@ -7,12 +7,14 @@ from ..views.oj import ( ContestProblemAPI, PickOneAPI, ProblemAuthorAPI, + SimilarProblemAPI, ) urlpatterns = [ path("problem/tags", ProblemTagAPI.as_view()), path("problem", ProblemAPI.as_view()), path("problem/beat_count", ProblemSolvedPeopleCount.as_view()), + path("problem/similar", SimilarProblemAPI.as_view()), path("problem/author", ProblemAuthorAPI.as_view()), path("pickone", PickOneAPI.as_view()), path("contest/problem", ContestProblemAPI.as_view()), diff --git a/problem/views/admin.py b/problem/views/admin.py index 3ec9e80..4f9761e 100644 --- a/problem/views/admin.py +++ b/problem/views/admin.py @@ -10,7 +10,9 @@ from django.conf import settings from django.db.models import Q from django.http import StreamingHttpResponse -from account.decorators import problem_permission_required, ensure_created_by +from django.db.models import Count + +from account.decorators import problem_permission_required, ensure_created_by, super_admin_required from contest.models import Contest, ContestStatus from submission.models import Submission from utils.api import APIView, CSRFExemptAPIView, validate_serializer, APIError @@ -509,3 +511,33 @@ class ProblemFlowchartAIGen(APIView): mermaid_code = response.choices[0].message.content return self.success({"flowchart": mermaid_code}) + + +class StuckProblemsAPI(APIView): + @super_admin_required + def get(self, request): + rows = ( + Submission.objects.values("problem_id", "problem___id", "problem__title") + .annotate( + total=Count("id"), + accepted=Count("id", filter=Q(result=0)), + failed=Count("id", filter=Q(result__lt=0)), + failed_users=Count("user_id", filter=Q(result__lt=0), distinct=True), + ) + .filter(failed_users__gt=0) + .order_by("-failed_users")[:30] + ) + result = [ + { + "problem_id": r["problem___id"], + "problem_title": r["problem__title"], + "total": r["total"], + "failed": r["failed"], + "failed_users": r["failed_users"], + "ac_rate": round(r["accepted"] / r["total"] * 100, 1) + if r["total"] + else 0, + } + for r in rows + ] + return self.success(result) diff --git a/problem/views/oj.py b/problem/views/oj.py index 67322fa..d8c17a6 100644 --- a/problem/views/oj.py +++ b/problem/views/oj.py @@ -63,6 +63,22 @@ class ProblemAPI(APIView): ) problem_data = ProblemSerializer(problem).data self._add_problem_status(request, problem_data) + if request.user.is_authenticated: + failed_statuses = [ + JudgeStatus.WRONG_ANSWER, + JudgeStatus.CPU_TIME_LIMIT_EXCEEDED, + JudgeStatus.REAL_TIME_LIMIT_EXCEEDED, + JudgeStatus.MEMORY_LIMIT_EXCEEDED, + JudgeStatus.RUNTIME_ERROR, + JudgeStatus.COMPILE_ERROR, + ] + problem_data["my_failed_count"] = Submission.objects.filter( + user_id=request.user.id, + problem_id=problem.id, + result__in=failed_statuses, + ).count() + else: + problem_data["my_failed_count"] = 0 return self.success(problem_data) except Problem.DoesNotExist: return self.error("Problem does not exist") @@ -187,6 +203,40 @@ class ProblemSolvedPeopleCount(APIView): return self.success(rate) +class SimilarProblemAPI(APIView): + def get(self, request): + problem_display_id = request.GET.get("problem_id") + if not problem_display_id: + return self.error("problem_id is required") + + try: + problem = Problem.objects.get(_id=problem_display_id, contest__isnull=True) + except Problem.DoesNotExist: + return self.error("Problem not found") + + tag_ids = list(problem.tags.values_list("id", flat=True)) + if not tag_ids: + return self.success([]) + + exclude_ids = [problem_display_id] + if request.user.is_authenticated: + profile = request.user.userprofile + ac_display_ids = [ + v["_id"] + for v in profile.acm_problems_status.get("problems", {}).values() + if v.get("status") == JudgeStatus.ACCEPTED + ] + exclude_ids.extend(ac_display_ids) + + similar = ( + Problem.objects.filter(tags__in=tag_ids, visible=True, contest__isnull=True) + .exclude(_id__in=exclude_ids) + .distinct() + .order_by("difficulty")[:5] + ) + return self.success(ProblemListSerializer(similar, many=True).data) + + class ProblemAuthorAPI(APIView): def get(self, request): show_all = request.GET.get("all", "0") == "1"