feat: Allow users to provide a reason when giving a thumbs-up or thumbs-down to a response

This commit is contained in:
zhangzhanwei 2025-12-24 17:57:39 +08:00 committed by zhanweizhang7
parent 12c66ae135
commit cfb488e705
12 changed files with 307 additions and 38 deletions

View File

@ -35,7 +35,6 @@ def _write_context(node_variable: Dict, workflow_variable: Dict, node: INode, wo
node.context['message_tokens'] = message_tokens
node.context['answer_tokens'] = answer_tokens
node.context['answer'] = answer
node.context['history_message'] = node_variable['history_message']
node.context['question'] = node_variable['question']
node.context['run_time'] = time.time() - node.context['start_time']
node.context['reasoning_content'] = reasoning_content

View File

@ -0,0 +1,23 @@
# Generated by Django 5.2.8 on 2025-12-23 10:28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('application', '0004_application_application_enable_and_more'),
]
operations = [
migrations.AddField(
model_name='chatrecord',
name='vote_other_content',
field=models.CharField(default='', max_length=1024, verbose_name='其他原因'),
),
migrations.AddField(
model_name='chatrecord',
name='vote_reason',
field=models.CharField(blank=True, choices=[('accurate', '内容准确'), ('complete', '内容完善'), ('inaccurate', '内容不准确'), ('incomplete', '内容不完善'), ('other', '其他')], max_length=50, null=True, verbose_name='投票原因'),
),
]

View File

@ -54,6 +54,12 @@ class VoteChoices(models.TextChoices):
STAR = "0", '赞同'
TRAMPLE = "1", '反对'
class VoteReasonChoices(models.TextChoices):
ACCURATE = 'accurate', '内容准确'
COMPLETE = 'complete', '内容完善'
INACCURATE = 'inaccurate', '内容不准确'
INCOMPLETE = 'incomplete', '内容不完善'
OTHER = 'other', '其他'
class ChatRecord(AppModelMixin):
"""
@ -63,6 +69,8 @@ class ChatRecord(AppModelMixin):
chat = models.ForeignKey(Chat, on_delete=models.CASCADE)
vote_status = models.CharField(verbose_name='投票', max_length=10, choices=VoteChoices.choices,
default=VoteChoices.UN_VOTE)
vote_reason =models.CharField(verbose_name='投票原因', max_length=50,choices=VoteReasonChoices.choices, null=True, blank=True)
vote_other_content = models.CharField(verbose_name='其他原因', max_length=1024, default='')
problem_text = models.CharField(max_length=10240, verbose_name="问题")
answer_text = models.CharField(max_length=40960, verbose_name="答案")
answer_text_list = ArrayField(verbose_name="改进标注列表",

View File

@ -34,7 +34,7 @@ from knowledge.task.embedding import embedding_by_paragraph, embedding_by_paragr
class ChatRecordSerializerModel(serializers.ModelSerializer):
class Meta:
model = ChatRecord
fields = ['id', 'chat_id', 'vote_status', 'problem_text', 'answer_text',
fields = ['id', 'chat_id', 'vote_status','vote_reason','vote_other_content', 'problem_text', 'answer_text',
'message_tokens', 'answer_tokens', 'const', 'improve_paragraph_id_list', 'run_time', 'index',
'answer_text_list',
'create_time', 'update_time']

View File

@ -13,7 +13,7 @@ from django.db.models import QuerySet
from django.utils.translation import gettext_lazy as _, gettext
from rest_framework import serializers
from application.models import VoteChoices, ChatRecord, Chat, ApplicationAccessToken
from application.models import VoteChoices, ChatRecord, Chat, ApplicationAccessToken, VoteReasonChoices
from application.serializers.application_chat import ChatCountSerializer
from application.serializers.application_chat_record import ChatRecordSerializerModel, \
ApplicationChatRecordQuerySerializers
@ -25,7 +25,9 @@ from common.utils.lock import RedisLock
class VoteRequest(serializers.Serializer):
vote_status = serializers.ChoiceField(choices=VoteChoices.choices,
label=_("Bidding Status"))
vote_reason = serializers.ChoiceField(choices=VoteReasonChoices.choices, label=_("Vote Reason"), required=False, allow_null=True)
vote_other_content = serializers.CharField(required=False, allow_blank=True,label=_("Vote other content"))
class HistoryChatModel(serializers.ModelSerializer):
class Meta:
@ -59,19 +61,33 @@ class VoteSerializer(serializers.Serializer):
if chat_record_details_model is None:
raise AppApiException(500, gettext("Non-existent conversation chat_record_id"))
vote_status = instance.get("vote_status")
# 未投票状态,可以进行投票
if chat_record_details_model.vote_status == VoteChoices.UN_VOTE:
# 投票时获取字段
vote_reason = instance.get("vote_reason")
vote_other_content = instance.get("vote_other_content") or ''
if vote_status == VoteChoices.STAR:
# 点赞
chat_record_details_model.vote_status = VoteChoices.STAR
chat_record_details_model.vote_reason = vote_reason
chat_record_details_model.vote_other_content = vote_other_content
if vote_status == VoteChoices.TRAMPLE:
# 点踩
chat_record_details_model.vote_status = VoteChoices.TRAMPLE
chat_record_details_model.vote_reason = vote_reason
chat_record_details_model.vote_other_content = vote_other_content
chat_record_details_model.save()
# 已投票状态
else:
if vote_status == VoteChoices.UN_VOTE:
# 取消点赞
chat_record_details_model.vote_status = VoteChoices.UN_VOTE
chat_record_details_model.vote_reason = None
chat_record_details_model.vote_other_content = ''
chat_record_details_model.save()
else:
raise AppApiException(500, gettext("Already voted, please cancel first and then vote again"))

View File

@ -182,13 +182,19 @@ const vote: (
chat_id: string,
chat_record_id: string,
vote_status: string,
vote_reason?: string,
vote_other_content?: string,
loading?: Ref<boolean>,
) => Promise<Result<boolean>> = (chat_id, chat_record_id, vote_status, loading) => {
) => Promise<Result<boolean>> = (chat_id, chat_record_id, vote_status, vote_reason, vote_other_content, loading) => {
const data = {
vote_status,
...(vote_reason !== undefined && { vote_reason }),
...(vote_other_content !== undefined && { vote_other_content })
}
return put(
`/vote/chat/${chat_id}/chat_record/${chat_record_id}`,
{
vote_status,
},
data,
undefined,
loading,
)

View File

@ -52,44 +52,73 @@
</el-button>
</el-tooltip>
<el-divider direction="vertical" />
<el-tooltip
effect="dark"
:content="$t('chat.operation.like')"
placement="top"
v-if="buttonData?.vote_status === '-1'"
>
<el-button text @click="voteHandle('0')" :disabled="loading">
<AppIcon iconName="app-like"></AppIcon>
</el-button>
</el-tooltip>
<el-popover ref="likePopoverRef" trigger="click" placement="bottom-start" :width="400">
<template #reference>
<span>
<el-tooltip
effect="dark"
:content="$t('chat.operation.like')"
placement="top"
v-if="buttonData?.vote_status === '-1'"
>
<el-button text :disabled="loading">
<AppIcon iconName="app-like"></AppIcon>
</el-button>
</el-tooltip>
</span>
</template>
<VoteReasonContent
vote-type="0"
:chat-id="props.chatId"
:record-id="props.data.record_id"
@success="handleVoteSuccess"
@close="closePopover"
>
</VoteReasonContent>
</el-popover>
<el-tooltip
effect="dark"
:content="$t('chat.operation.cancelLike')"
placement="top"
v-if="buttonData?.vote_status === '0'"
>
<el-button text @click="voteHandle('-1')" :disabled="loading">
<el-button text @click="cancelVoteHandle('-1')" :disabled="loading">
<AppIcon iconName="app-like-color"></AppIcon>
</el-button>
</el-tooltip>
<el-divider direction="vertical" v-if="buttonData?.vote_status === '-1'" />
<el-tooltip
effect="dark"
:content="$t('chat.operation.oppose')"
placement="top"
v-if="buttonData?.vote_status === '-1'"
>
<el-button text @click="voteHandle('1')" :disabled="loading">
<AppIcon iconName="app-oppose"></AppIcon>
</el-button>
</el-tooltip>
<el-popover ref="opposePopoverRef" trigger="click" placement="bottom-start" :width="400">
<template #reference>
<span>
<el-tooltip
effect="dark"
:content="$t('chat.operation.oppose')"
placement="top"
v-if="buttonData?.vote_status === '-1'"
>
<el-button text :disabled="loading">
<AppIcon iconName="app-oppose"></AppIcon>
</el-button>
</el-tooltip>
</span>
</template>
<VoteReasonContent
vote-type="1"
:chat-id="props.chatId"
:record-id="props.data.record_id"
@success="handleVoteSuccess"
@close="closePopover"
>
</VoteReasonContent>
</el-popover>
<el-tooltip
effect="dark"
:content="$t('chat.operation.cancelOppose')"
placement="top"
v-if="buttonData?.vote_status === '1'"
>
<el-button text @click="voteHandle('-1')" :disabled="loading">
<el-button text @click="cancelVoteHandle('-1')" :disabled="loading">
<AppIcon iconName="app-oppose-color"></AppIcon>
</el-button>
</el-tooltip>
@ -106,6 +135,7 @@ import applicationApi from '@/api/application/application'
import chatAPI from '@/api/chat/chat'
import { datetimeFormat } from '@/utils/time'
import { MsgError } from '@/utils/message'
import VoteReasonContent from '@/components/ai-chat/component/operation-button/VoteReasonContent.vue'
import bus from '@/bus'
const copy = (data: any) => {
try {
@ -117,6 +147,12 @@ const copy = (data: any) => {
copyClick(removeFormRander(data?.answer_text.trim()))
}
}
const likePopoverRef = ref()
const opposePopoverRef = ref()
const closePopover = () => {
likePopoverRef.value.hide()
opposePopoverRef.value.hide()
}
const route = useRoute()
const {
params: { id },
@ -145,15 +181,20 @@ const audioPlayer = ref<HTMLAudioElement[] | null>([])
const audioCiontainer = ref<HTMLDivElement>()
const buttonData = ref(props.data)
const loading = ref(false)
const audioList = ref<string[]>([])
function regeneration() {
emit('regeneration')
}
function voteHandle(val: string) {
chatAPI.vote(props.chatId, props.data.record_id, val, loading).then(() => {
function handleVoteSuccess(voteStatus: string) {
buttonData.value['vote_status'] = voteStatus
emit('update:data', buttonData.value)
closePopover()
}
function cancelVoteHandle(val: string) {
chatAPI.vote(props.chatId, props.data.record_id, val, undefined, '', loading).then(() => {
buttonData.value['vote_status'] = val
emit('update:data', buttonData.value)
})

View File

@ -8,7 +8,12 @@
<div>
<!-- 语音播放 -->
<span v-if="tts">
<el-tooltip effect="dark" :content="$t('chat.operation.play')" placement="top" v-if="!audioPlayerStatus">
<el-tooltip
effect="dark"
:content="$t('chat.operation.play')"
placement="top"
v-if="!audioPlayerStatus"
>
<el-button text @click="playAnswerText(data?.answer_text)">
<AppIcon iconName="app-video-play"></AppIcon>
</el-button>
@ -27,8 +32,12 @@
</el-tooltip>
<el-divider direction="vertical" />
<template v-if="permissionPrecise.chat_log_add_knowledge(id)">
<el-tooltip v-if="buttonData.improve_paragraph_id_list.length === 0" effect="dark"
:content="$t('views.chatLog.editContent')" placement="top">
<el-tooltip
v-if="buttonData.improve_paragraph_id_list.length === 0"
effect="dark"
:content="$t('views.chatLog.editContent')"
placement="top"
>
<el-button text @click="editContent(data)">
<AppIcon iconName="app-edit"></AppIcon>
</el-button>
@ -52,9 +61,32 @@
<EditContentDialog ref="EditContentDialogRef" @refresh="refreshContent" />
<EditMarkDialog ref="EditMarkDialogRef" @refresh="refreshMark" />
<!-- 先渲染不然不能播放 -->
<audio ref="audioPlayer" v-for="item in audioList" :key="item" controls hidden="hidden"></audio>
<audio
ref="audioPlayer"
v-for="item in audioList"
:key="item"
controls
hidden="hidden"
></audio>
</div>
</div>
<div>
<el-card
class="mt-16"
shadow="always"
v-if="buttonData?.vote_status !== '-1' && data.vote_reason"
>
<VoteReasonContent
:vote-type="buttonData?.vote_status"
:chat-id="buttonData?.chat_id"
:record-id="buttonData?.id"
readonly
:default-reason="data.vote_reason"
:default-other-content="data.vote_other_content"
>
</VoteReasonContent>
</el-card>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
@ -67,16 +99,16 @@ import { useRoute } from 'vue-router'
import permissionMap from '@/permission'
import { MsgError } from '@/utils/message'
import { t } from '@/locales'
import VoteReasonContent from '@/components/ai-chat/component/operation-button/VoteReasonContent.vue'
const route = useRoute()
const {
params: { id },
} = route as any
const props = defineProps({
data: {
type: Object,
default: () => { },
default: () => {},
},
applicationId: {
type: String,

View File

@ -0,0 +1,113 @@
<template>
<div class="p-4">
<h3>{{ title }}</h3>
</div>
<div class="p-4 mt-16">
<el-button
v-for="reason in reasons"
:key="reason.value"
:type="selectedReason === reason.value ? 'primary' : 'info'"
@click="selectReason(reason.value)"
:disabled="readonly"
plain
bg
>
{{ reason.label }}
</el-button>
</div>
<div v-if="selectedReason === 'other'" class="mt-16">
<el-input
v-model="feedBack"
type="textarea"
:autosize="{ minRows: 4, maxRows: 20 }"
:placeholder="$t('chat.operation.vote.placeholder')"
:readonly="readonly"
>
</el-input>
</div>
<div v-if="!readonly" class="dialog-footer mt-24 text-right">
<el-button @click="emit('close')"> {{ $t('common.cancel') }}</el-button>
<el-button :disabled="isSubmitDisabled" type="primary" @click="voteHandle()">
{{ $t('common.confirm') }}</el-button
>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { t } from '@/locales'
import chatAPI from '@/api/chat/chat'
const props = defineProps<{
voteType: '0' | '1'
chatId: string
recordId: string
readonly?: boolean
defaultReason?: string
defaultOtherContent?: string
}>()
const selectedReason = ref<string>(props.readonly ? props.defaultReason || '' : '')
const feedBack = ref<string>(props.readonly ? props.defaultOtherContent || '' : '')
const loading = ref(false)
const selectReason = (value: string) => {
if (props.readonly) {
return
}
selectedReason.value = value
}
const isSubmitDisabled = computed(() => {
if (!selectedReason.value) {
return true
}
if (selectedReason.value === 'other' && !feedBack.value.trim()) {
return true
}
return false
})
const LIKE_REASONS = [
{ label: t('chat.operation.vote.accurate'), value: 'accurate' },
{ label: t('chat.operation.vote.complete'), value: 'complete' },
{ label: t('chat.operation.vote.other'), value: 'other' },
]
const OPPOSE_REASONS = [
{ label: t('chat.operation.vote.inaccurate'), value: 'inaccurate' },
{ label: t('chat.operation.vote.irrelevantAnswer'), value: 'incomplete' },
{ label: t('chat.operation.vote.other'), value: 'other' },
]
const title = computed(() => {
return props.voteType === '0' ? t('chat.operation.likeTitle') : t('chat.operation.opposeTitle')
})
const reasons = computed(() => {
return props.voteType === '0' ? LIKE_REASONS : OPPOSE_REASONS
})
function voteHandle() {
chatAPI
.vote(
props.chatId,
props.recordId,
props.voteType,
selectedReason.value,
feedBack.value,
loading,
)
.then(() => {
emit('success', props.voteType)
emit('close')
})
}
const emit = defineEmits<{
success: [voteStatus: string]
close: []
}>()
</script>
<style lang="scss" scoped></style>

View File

@ -36,6 +36,17 @@ export default {
continue: 'Continue',
stopChat: 'Stop Response',
startChat: 'Start Response',
likeTitle: 'What do you think makes you satisfied?',
opposeTitle: 'Please tell us the reason for your dissatisfaction.',
vote: {
accurate: 'Content is accurate',
inaccurate: 'Answer is inaccurate',
complete: 'Content is complete',
irrelevantAnswer: 'Answer is irrelevant',
other: 'Other',
placeholder: 'Tell us more about your relevant experiences',
},
},
tip: {
error500Message: 'Sorry, the service is currently under maintenance. Please try again later!',

View File

@ -36,6 +36,16 @@ export default {
continue: '继续',
stopChat: '停止回答',
startChat: '开始对话',
likeTitle: '你觉得什么让你满意?',
opposeTitle: '请告诉我们不满意的原因',
vote: {
accurate: '内容准确',
inaccurate: '回答不准确',
complete: '内容完善',
irrelevantAnswer: '回答不相关',
other: '其他',
placeholder: '告诉我们更多关于你的相关体验',
},
},
tip: {
error500Message: '抱歉,当前正在维护,无法提供服务,请稍后再试!',

View File

@ -36,6 +36,16 @@ export default {
continue: '繼續',
stopChat: '停止回答',
startChat: '開始回答',
likeTitle: '你覺得什麼讓你滿意?',
opposeTitle: '請告訴我們不滿意的原因',
vote: {
accurate: '內容準確',
inaccurate: '回答不準確',
complete: '內容完善',
irrelevantAnswer: '回答不相關',
other: '其他',
placeholder: '告訴我們更多關於你的相關體驗',
},
},
tip: {
error500Message: '抱歉,當前正在維護,無法提供服務,請稍後再試!',