This commit is contained in:
2026-06-11 21:22:02 -06:00
parent a335eb22e8
commit 4f288a1fef
4 changed files with 113 additions and 90 deletions

View File

@@ -8,6 +8,7 @@ from django.contrib.auth.decorators import login_required
from django.db.models import Count, Prefetch from django.db.models import Count, Prefetch
from .models import Conversation, Message from .models import Conversation, Message
from .utils import get_preceding_user_message
from .schemas import ConversationOut, MessageOut, PromptHistoryItemOut from .schemas import ConversationOut, MessageOut, PromptHistoryItemOut
from account.models import RoleChoices from account.models import RoleChoices
@@ -166,16 +167,15 @@ def delete_message_pair(request, message_id: int):
if asst_msg.conversation.user != request.user and request.user.role != RoleChoices.SUPER: if asst_msg.conversation.user != request.user and request.user.role != RoleChoices.SUPER:
raise HttpError(403, "只能删除自己的消息") raise HttpError(403, "只能删除自己的消息")
if asst_msg.submission_id and request.user.role != RoleChoices.SUPER:
from submission.models import Rating, SubmissionAward
has_ratings = Rating.objects.filter(submission_id=asst_msg.submission_id).exists()
has_awards = SubmissionAward.objects.filter(submission_id=asst_msg.submission_id).exists()
if has_ratings or has_awards:
raise HttpError(400, "该消息关联的提交已被评分或获奖,无法删除")
# Find the preceding user message # Find the preceding user message
user_msg = ( user_msg = get_preceding_user_message(asst_msg)
Message.objects.filter(
conversation=asst_msg.conversation,
created__lt=asst_msg.created,
role="user",
)
.order_by("-created")
.first()
)
# Delete messages first, then submission # Delete messages first, then submission
submission_id = asst_msg.submission_id # capture before deletion nulls it submission_id = asst_msg.submission_id # capture before deletion nulls it
@@ -187,10 +187,7 @@ def delete_message_pair(request, message_id: int):
submission_deleted = False submission_deleted = False
if submission_id: if submission_id:
from submission.models import Submission as SubmissionModel from submission.models import Submission as SubmissionModel
try: SubmissionModel.objects.filter(id=submission_id).delete()
SubmissionModel.objects.filter(id=submission_id).delete() submission_deleted = True
submission_deleted = True
except Exception:
pass
return {"deleted": True, "submission_deleted": submission_deleted} return {"deleted": True, "submission_deleted": submission_deleted}

View File

@@ -1,8 +1,8 @@
import json import json
from channels.generic.websocket import AsyncWebsocketConsumer from channels.generic.websocket import AsyncWebsocketConsumer
from channels.db import database_sync_to_async from channels.db import database_sync_to_async
from django.db.models import Count from .models import Message
from .models import Conversation, Message from .utils import get_or_create_active_conversation
from .llm import stream_chat, extract_code, stream_guidance, parse_guidance_response from .llm import stream_chat, extract_code, stream_guidance, parse_guidance_response
@@ -79,15 +79,7 @@ class PromptConsumer(AsyncWebsocketConsumer):
@database_sync_to_async @database_sync_to_async
def get_or_create_conversation(self): def get_or_create_conversation(self):
conv = ( return get_or_create_active_conversation(self.user, self.task_id)
Conversation.objects.filter(user=self.user, task_id=self.task_id)
.annotate(msg_count=Count("messages"))
.order_by("-msg_count", "-created")
.first()
)
if not conv:
conv = Conversation.objects.create(user=self.user, task_id=self.task_id)
return conv
@database_sync_to_async @database_sync_to_async
def delete_message(self, message): def delete_message(self, message):
@@ -195,15 +187,7 @@ class GuidanceConsumer(AsyncWebsocketConsumer):
@database_sync_to_async @database_sync_to_async
def get_or_create_conversation(self): def get_or_create_conversation(self):
conv = ( return get_or_create_active_conversation(self.user, self.task_id)
Conversation.objects.filter(user=self.user, task_id=self.task_id)
.annotate(msg_count=Count("messages"))
.order_by("-msg_count", "-created")
.first()
)
if not conv:
conv = Conversation.objects.create(user=self.user, task_id=self.task_id)
return conv
@database_sync_to_async @database_sync_to_async
def delete_message(self, message): def delete_message(self, message):

32
prompt/utils.py Normal file
View File

@@ -0,0 +1,32 @@
from django.db.models import Count, Q
from .models import Conversation
def get_active_conversation(user, task_id):
"""Return the conversation with the most messages for this user+task, or None."""
return (
Conversation.objects.filter(user=user, task_id=task_id)
.annotate(msg_count=Count("messages"))
.order_by("-msg_count", "-created")
.first()
)
def get_or_create_active_conversation(user, task_id):
conv = get_active_conversation(user, task_id)
if not conv:
conv = Conversation.objects.create(user=user, task_id=task_id)
return conv
def get_preceding_user_message(asst_msg):
"""Return the user message immediately preceding an assistant message in its conversation."""
return (
asst_msg.conversation.messages.filter(role="user")
.filter(
Q(created__lt=asst_msg.created)
| Q(created=asst_msg.created, id__lt=asst_msg.id)
)
.order_by("-created", "-id")
.first()
)

View File

@@ -1,4 +1,5 @@
import csv import csv
import logging
import threading import threading
from typing import List, Literal, Optional from typing import List, Literal, Optional
from urllib.parse import quote from urllib.parse import quote
@@ -23,7 +24,8 @@ from django.db.models import (
from django.utils import timezone from django.utils import timezone
from account.decorators import admin_required, super_required from account.decorators import admin_required, super_required
from prompt.models import Conversation, Message from prompt.models import Conversation, Message
from .classifier import classify_conversation_messages from prompt.utils import get_active_conversation, get_preceding_user_message
from .classifier import classify_message
from .schemas import ( from .schemas import (
@@ -58,6 +60,7 @@ from task.models import Task
from account.models import RoleChoices, User from account.models import RoleChoices, User
router = Router() router = Router()
logger = logging.getLogger(__name__)
def _validate_item_ordering(value: str): def _validate_item_ordering(value: str):
@@ -131,20 +134,19 @@ def create_submission(request, payload: SubmissionIn):
task = get_object_or_404(Task, id=payload.task_id) task = get_object_or_404(Task, id=payload.task_id)
manual_asst_msg = None manual_asst_msg = None
linked_msg = None
new_user_msg_id = None
if payload.prompt: if payload.prompt:
conversation = ( conversation = get_active_conversation(request.user, task.id)
Conversation.objects.filter(user=request.user, task=task)
.annotate(msg_count=Count("messages"))
.order_by("-msg_count", "-created")
.first()
)
if not conversation: if not conversation:
conversation = Conversation.objects.create( conversation = Conversation.objects.create(
user=request.user, task=task, is_active=False user=request.user, task=task, is_active=False
) )
Message.objects.create( user_msg = Message.objects.create(
conversation=conversation, role="user", content=payload.prompt, source="manual" conversation=conversation, role="user", content=payload.prompt, source="manual"
) )
new_user_msg_id = user_msg.id
manual_asst_msg = Message.objects.create( manual_asst_msg = Message.objects.create(
conversation=conversation, conversation=conversation,
role="assistant", role="assistant",
@@ -154,48 +156,60 @@ def create_submission(request, payload: SubmissionIn):
code_js=payload.js, code_js=payload.js,
source="manual", source="manual",
) )
elif payload.message_id:
linked_msg = Message.objects.filter(
id=payload.message_id,
role="assistant",
conversation__user=request.user,
conversation__task=task,
).first()
if linked_msg is None:
logger.warning(
"create_submission: message_id %s not found for user %s task %s",
payload.message_id, request.user.id, task.id,
)
else:
user_msg = get_preceding_user_message(linked_msg)
if user_msg:
new_user_msg_id = user_msg.id
# Idempotent: re-submitting the same AI message updates its existing submission
# instead of creating an orphaned duplicate.
if linked_msg and linked_msg.submission_id:
submission = linked_msg.submission
submission.html = payload.html
submission.css = payload.css
submission.js = payload.js
submission.save(update_fields=["html", "css", "js"])
else: else:
conversation = ( submission = Submission.objects.create(
Conversation.objects.filter(user=request.user, task=task) user=request.user,
.annotate(msg_count=Count("messages")) task=task,
.order_by("-msg_count", "-created") html=payload.html,
.first() css=payload.css,
js=payload.js,
) )
if conversation: # Link assistant message to submission
threading.Thread(target=classify_conversation_messages, args=(conversation.id,), daemon=True).start() if manual_asst_msg:
manual_asst_msg.submission = submission
manual_asst_msg.save(update_fields=["submission"])
elif linked_msg:
linked_msg.submission = submission
linked_msg.save(update_fields=["submission"])
submission = Submission.objects.create( # Mark any showcased older submissions from same user+task as stale
user=request.user, SubmissionAward.objects.filter(
task=task, submission__user=request.user,
html=payload.html, submission__task=task,
css=payload.css, is_stale=False,
js=payload.js, ).exclude(submission=submission).update(is_stale=True)
)
# Link assistant message to submission # Classify only the newly-added prompt message, and only if not already classified
if manual_asst_msg: if new_user_msg_id is not None and not Message.objects.filter(
manual_asst_msg.submission = submission id=new_user_msg_id, prompt_level__isnull=False
manual_asst_msg.save(update_fields=["submission"]) ).exists():
elif payload.message_id: threading.Thread(target=classify_message, args=(new_user_msg_id,), daemon=True).start()
try:
msg = Message.objects.get(
id=payload.message_id,
role="assistant",
conversation__user=request.user,
conversation__task=task,
)
msg.submission = submission
msg.save(update_fields=["submission"])
except Message.DoesNotExist:
pass # invalid message_id — submission already created, silently skip
# Mark any showcased older submissions from same user+task as stale
SubmissionAward.objects.filter(
submission__user=request.user,
submission__task=task,
is_stale=False,
).exclude(submission=submission).update(is_stale=True)
return {"id": str(submission.id)} return {"id": str(submission.id)}
@@ -341,19 +355,15 @@ def delete_submission(request, submission_id: UUID):
if submission.user != request.user and request.user.role != RoleChoices.SUPER: if submission.user != request.user and request.user.role != RoleChoices.SUPER:
raise HttpError(403, "只能删除自己的提交") raise HttpError(403, "只能删除自己的提交")
if request.user.role != RoleChoices.SUPER:
has_ratings = Rating.objects.filter(submission=submission).exists()
has_awards = SubmissionAward.objects.filter(submission=submission).exists()
if has_ratings or has_awards:
raise HttpError(400, "该提交已被评分或获奖,无法删除")
# 找到关联的助手消息,再找前一条用户消息 # 找到关联的助手消息,再找前一条用户消息
asst_msg = Message.objects.filter(submission=submission).first() asst_msg = Message.objects.filter(submission=submission).first()
user_msg = None user_msg = get_preceding_user_message(asst_msg) if asst_msg else None
if asst_msg:
user_msg = (
Message.objects.filter(
conversation=asst_msg.conversation,
created__lt=asst_msg.created,
role="user",
)
.order_by("-created")
.first()
)
submission.delete() # CASCADE 自动删除关联的 asst_msg submission.delete() # CASCADE 自动删除关联的 asst_msg