Files
YG_FT/frontend/src/views/data-process/create/ResultEditorStep.vue
caoxiaozhu 81c2f85c3a feat(data_process): 问答对数据评测体系与质量分雷达图
- 三层评测:规则层沿用原五维规则分,语义层用本地 BGE 向量算问答/来源
  相关性,评审层复用生成模型按 rubric 打分(忠实度/正确性/清晰度等,
  区分 standard/reasoning/dpo 输出类型),任一层失败自动降级
- 组合分 = 规则 35% + 语义 20% + 评审 45%,缺层自动重归一
- 新增 results/evaluate-batch 批量评测接口,镜像批量重生成的并发、
  乐观锁与部分成功语义;生成阶段不再展示质量分
- 详情页与结果编辑页新增"数据评测"按钮和批量进度;质量分列悬停弹出
  雷达图浮窗(评审 5 维 + 语义 2 维、三层分项、评审理由)
- 手动编辑/恢复后重算规则与语义层并丢弃过期评审分,雷达图不再展示
  失效数据
2026-08-19 14:22:27 +08:00

669 lines
19 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, ref } from 'vue'
import QualityRadarPopover from './QualityRadarPopover.vue'
import type { BulkResultRegenerationState, PreviewItem, ResultEvaluationState, ResultItem } from './types'
import type { DataProcessOutputType } from '@/types/dataProcess'
const props = defineProps<{
items: ResultItem[]
previewItems: PreviewItem[]
selectedId: string | null
regeneratingResultId: string | null
bulkRegeneration: BulkResultRegenerationState
evaluation: ResultEvaluationState
outputType: DataProcessOutputType
}>()
const emit = defineEmits<{
'update:selectedId': [value: string]
'update:field': [id: string, field: 'instruction' | 'input' | 'output' | 'chosen' | 'rejected', value: string]
'regenerate:item': [id: string]
'regenerate:all': []
'evaluate:all': []
}>()
const search = ref('')
const invalidOnly = ref(false)
const selectedItem = computed(() => props.items.find((item) => item.id === props.selectedId) ?? props.items[0])
const selectedIndex = computed(() => props.items.findIndex((item) => item.id === selectedItem.value?.id))
const invalidCount = computed(() => (
props.items.filter((item) => item.savedStatus === 'invalid').length
))
const bulkRegenerationActive = computed(() => props.bulkRegeneration.status === 'running')
const bulkRegenerationVisible = computed(() => (
props.bulkRegeneration.status !== 'idle' && props.bulkRegeneration.total > 0
))
const evaluationActive = computed(() => props.evaluation.status === 'running')
const evaluationVisible = computed(() => (
props.evaluation.status !== 'idle' && props.evaluation.total > 0
))
const evaluationPercentage = computed(() => {
if (!props.evaluation.total) return 0
return Math.round((props.evaluation.completed / props.evaluation.total) * 100)
})
const evaluatedCount = computed(() => props.items.filter((item) => item.qualityDetails?.evaluated).length)
const bulkRegenerationPercentage = computed(() => (
props.bulkRegeneration.total > 0
? Math.round((props.bulkRegeneration.completed / props.bulkRegeneration.total) * 100)
: 0
))
const itemRegenerating = (id: string) => (
props.regeneratingResultId === id
|| props.bulkRegeneration.targetIds.includes(id)
)
const selectedItemRegenerating = computed(() => (
selectedItem.value ? itemRegenerating(selectedItem.value.id) : false
))
const selectedSource = computed(() => {
const previewItemId = selectedItem.value?.previewItemId
if (!previewItemId) return null
return props.previewItems.find((item) => item.id === previewItemId) ?? null
})
const selectedSourceContent = computed(() => (
selectedSource.value?.editedContent.trim()
|| selectedSource.value?.originalContent.trim()
|| ''
))
const selectedSourceWasPreprocessed = computed(() => Boolean(
selectedSource.value
&& selectedSource.value.editedContent.trim()
&& selectedSource.value.editedContent !== selectedSource.value.originalContent
))
const selectedSourceMeta = computed(() => {
const source = selectedSource.value
if (!source) return ''
const parts: string[] = []
if (source.sourcePages?.length) {
const first = source.sourcePages[0]
const last = source.sourcePages[source.sourcePages.length - 1]
parts.push(first === last ? `${first}` : `${first}${last}`)
}
if (source.sourceStartLine != null) {
parts.push(
source.sourceEndLine != null && source.sourceEndLine !== source.sourceStartLine
? `${source.sourceStartLine}${source.sourceEndLine}`
: `${source.sourceStartLine}`,
)
}
if (source.tokenCount > 0) parts.push(`${source.tokenCount} Token`)
return parts.join(' · ')
})
const filteredItems = computed(() => props.items.filter((item, index) => {
const keyword = search.value.trim().toLowerCase()
const matchesSearch = !keyword
|| item.instruction.toLowerCase().includes(keyword)
|| item.output.toLowerCase().includes(keyword)
|| item.chosen.toLowerCase().includes(keyword)
|| item.rejected.toLowerCase().includes(keyword)
|| String(index + 1).includes(keyword)
return matchesSearch && (!invalidOnly.value || item.status === 'invalid')
}))
function selectRelative(offset: number) {
if (!props.items.length) return
const nextIndex = Math.min(Math.max(selectedIndex.value + offset, 0), props.items.length - 1)
emit('update:selectedId', props.items[nextIndex].id)
}
</script>
<template>
<section class="result-step">
<div class="result-workspace">
<aside class="result-list-pane" :class="{ 'has-bulk-progress': bulkRegenerationVisible || evaluationVisible }">
<div class="pane-header result-list-header">
<div class="result-list-title">
<strong>生成结果</strong><span> {{ items.length }} <template v-if="evaluatedCount"> · 已评测 {{ evaluatedCount }}</template></span>
</div>
<div class="result-list-actions">
<el-button
size="small"
plain
:loading="evaluationActive"
:disabled="!items.length || bulkRegenerationActive || Boolean(regeneratingResultId)"
@click="emit('evaluate:all')"
>
<i v-if="!evaluationActive" class="fa fa-check-square-o" style="margin-right: 4px;" />
数据评测
</el-button>
<el-button
v-if="invalidCount > 0"
size="small"
plain
type="primary"
:loading="bulkRegenerationActive"
:disabled="Boolean(regeneratingResultId) || bulkRegenerationActive || evaluationActive"
@click="emit('regenerate:all')"
>
<i v-if="!bulkRegenerationActive" class="fa fa-refresh" style="margin-right: 4px;" />
{{ bulkRegenerationActive ? '重新生成中' : `全部重新生成(${invalidCount}` }}
</el-button>
</div>
</div>
<div v-if="evaluationVisible" class="bulk-regeneration-progress">
<div>
<span>数据评测 {{ evaluation.completed }} / {{ evaluation.total }}</span>
<span>成功 {{ evaluation.succeeded }} · 失败 {{ evaluation.failed }}</span>
</div>
<el-progress
:percentage="evaluationPercentage"
:show-text="false"
:stroke-width="5"
:color="evaluation.failed > 0 ? '#d97706' : '#5b50f2'"
/>
</div>
<div v-if="bulkRegenerationVisible" class="bulk-regeneration-progress">
<div>
<span>已处理 {{ bulkRegeneration.completed }} / {{ bulkRegeneration.total }}</span>
<span>成功 {{ bulkRegeneration.succeeded }} · 失败 {{ bulkRegeneration.failed }}</span>
</div>
<el-progress
:percentage="bulkRegenerationPercentage"
:show-text="false"
:stroke-width="5"
:color="bulkRegeneration.failed > 0 ? '#d97706' : '#5b50f2'"
/>
</div>
<div class="result-toolbar">
<el-input v-model="search" clearable size="small" placeholder="搜索结果">
<template #prefix><i class="fa fa-search" /></template>
</el-input>
<el-checkbox v-model="invalidOnly">仅看错误</el-checkbox>
</div>
<div class="result-list">
<button
v-for="item in filteredItems"
:key="item.id"
type="button"
class="result-item"
:class="{ 'is-active': item.id === selectedItem?.id }"
@click="emit('update:selectedId', item.id)"
>
<span class="result-index">#{{ String(items.findIndex((entry) => entry.id === item.id) + 1).padStart(3, '0') }}</span>
<span class="result-copy">
<strong>{{ item.instruction || '未填写指令' }}</strong>
<small>{{ outputType === 'dpo' ? (item.chosen || '未填写 Chosen') : (item.output || '未填写输出') }}</small>
</span>
<el-popover
v-if="item.qualityScore != null && item.qualityDetails"
placement="right"
:width="296"
trigger="hover"
:show-after="150"
popper-class="quality-radar-popper"
>
<template #reference>
<span
class="result-score"
:class="item.qualityScore >= 80 ? 'is-success' : item.qualityScore >= 60 ? 'is-warning' : 'is-danger'"
@click.stop
>{{ item.qualityScore.toFixed(0) }}</span>
</template>
<QualityRadarPopover :quality="item.qualityDetails" :score="item.qualityScore" />
</el-popover>
<i v-if="itemRegenerating(item.id)" class="css-spinner" />
<i
v-else
class="fa"
:class="item.status === 'invalid' ? 'fa-exclamation-circle is-error' : 'fa-check-circle is-valid'"
/>
</button>
</div>
</aside>
<div v-if="selectedItem" class="result-editor-pane">
<div class="pane-header">
<div>
<strong>结果 #{{ String(selectedIndex + 1).padStart(3, '0') }}</strong>
<span v-if="selectedItem.status === 'modified'" class="modified-label">已修改</span>
<el-tag v-if="selectedItem.split" size="small" effect="plain">{{ selectedItem.split }}</el-tag>
<el-tag
v-if="selectedItem.qualityScore != null"
size="small"
:type="selectedItem.qualityScore >= 80 ? 'success' : selectedItem.qualityScore >= 60 ? 'warning' : 'danger'"
>质量 {{ selectedItem.qualityScore.toFixed(1) }}</el-tag>
</div>
<div class="result-header-actions">
<el-button
v-if="selectedItem.savedStatus === 'invalid'"
size="small"
plain
type="primary"
:loading="selectedItemRegenerating"
:disabled="bulkRegenerationActive || (Boolean(regeneratingResultId) && !selectedItemRegenerating)"
@click="emit('regenerate:item', selectedItem.id)"
>
<i v-if="!selectedItemRegenerating" class="fa fa-refresh" style="margin-right: 4px;" /> 重新生成
</el-button>
</div>
</div>
<article class="source-reference" aria-labelledby="source-reference-title">
<div class="source-reference-heading">
<div>
<strong id="source-reference-title">原文参照</strong>
<span v-if="selectedSourceMeta">{{ selectedSourceMeta }}</span>
</div>
<el-tag v-if="selectedSourceWasPreprocessed" size="small" effect="plain">已智能预处理</el-tag>
</div>
<pre v-if="selectedSourceContent">{{ selectedSourceContent }}</pre>
<p v-else>当前结果没有关联到可用的原文切片</p>
<small v-if="selectedSourceWasPreprocessed">这里展示的是实际送入模型的预处理后原文便于核对问题和答案是否有依据</small>
</article>
<div class="field-editor">
<label>Instruction <em>必填</em></label>
<el-input
:model-value="selectedItem.instruction"
:disabled="selectedItemRegenerating"
type="textarea"
:rows="3"
@update:model-value="emit('update:field', selectedItem.id, 'instruction', $event)"
/>
</div>
<div class="field-editor">
<label>Input <span>选填</span></label>
<el-input
:model-value="selectedItem.input"
:disabled="selectedItemRegenerating"
type="textarea"
:rows="2"
@update:model-value="emit('update:field', selectedItem.id, 'input', $event)"
/>
</div>
<div v-if="outputType !== 'dpo'" class="field-editor">
<label>Output <em>必填</em></label>
<el-input
:model-value="selectedItem.output"
:disabled="selectedItemRegenerating"
type="textarea"
:rows="7"
@update:model-value="emit('update:field', selectedItem.id, 'output', $event)"
/>
</div>
<template v-else>
<div class="field-editor dpo-field is-chosen">
<label>Chosen <em>优选回答必填</em></label>
<el-input
:model-value="selectedItem.chosen"
:disabled="selectedItemRegenerating"
type="textarea"
:rows="6"
@update:model-value="emit('update:field', selectedItem.id, 'chosen', $event)"
/>
</div>
<div class="field-editor dpo-field is-rejected">
<label>Rejected <em>拒选回答必填</em></label>
<el-input
:model-value="selectedItem.rejected"
:disabled="selectedItemRegenerating"
type="textarea"
:rows="6"
@update:model-value="emit('update:field', selectedItem.id, 'rejected', $event)"
/>
</div>
</template>
<div v-if="selectedItem.error" class="validation-error">
<i class="fa fa-exclamation-circle" /> {{ selectedItem.error }}
</div>
<div v-else class="validation-success">
<i class="fa fa-check-circle" /> 字段校验通过
</div>
<div class="editor-pagination">
<el-button :disabled="selectedIndex <= 0" @click="selectRelative(-1)">上一条</el-button>
<span>{{ selectedIndex + 1 }} / {{ items.length }}</span>
<el-button :disabled="selectedIndex >= items.length - 1" @click="selectRelative(1)">下一条</el-button>
</div>
</div>
</div>
</section>
</template>
<style scoped lang="scss">
.result-step {
min-width: 0;
}
.result-workspace {
display: grid;
grid-template-columns: minmax(280px, 34fr) minmax(480px, 66fr);
min-height: clamp(420px, calc(100vh - 500px), 590px);
overflow: hidden;
border: 1px solid #e2e5ec;
border-radius: 9px;
}
.result-list-pane {
min-width: 0;
border-right: 1px solid #e5e8ee;
}
.result-list-title {
min-width: 0;
span {
margin-left: 8px;
color: #8a93a3;
font-size: 11px;
font-weight: 400;
}
}
.result-list-header {
gap: 10px;
:deep(.el-button) {
flex: none;
}
}
.result-list-actions {
display: flex;
flex: none;
align-items: center;
gap: 8px;
}
.result-score {
flex: none;
min-width: 34px;
padding: 2px 8px;
border-radius: 10px;
color: #475467;
background: #f2f4f7;
font-size: 12px;
font-weight: 700;
font-variant-numeric: tabular-nums;
text-align: center;
cursor: default;
&.is-success {
color: #067647;
background: #e6f4ee;
}
&.is-warning {
color: #b54708;
background: #fef0c7;
}
&.is-danger {
color: #b42318;
background: #fee4e2;
}
}
.pane-header {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 52px;
padding: 0 15px;
color: #344054;
background: #fff;
border-bottom: 1px solid #e8ebf0;
font-size: 13px;
> div {
display: flex;
align-items: center;
gap: 8px;
}
> span,
.modified-label {
color: #8a93a3;
font-size: 11px;
}
}
.result-toolbar {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
padding: 10px;
border-bottom: 1px solid #edf0f5;
}
.bulk-regeneration-progress {
padding: 9px 12px 10px;
background: #fafaff;
border-bottom: 1px solid #e8e7ff;
> div {
display: flex;
justify-content: space-between;
gap: 10px;
margin-bottom: 7px;
color: #667085;
font-size: 10px;
}
}
.result-list {
height: 476px;
padding: 7px;
overflow: auto;
}
.has-bulk-progress .result-list {
height: 420px;
}
.result-header-actions {
display: flex;
align-items: center;
gap: 8px;
}
.result-item {
display: grid;
grid-template-columns: 46px minmax(0, 1fr) 18px;
align-items: center;
gap: 8px;
width: 100%;
min-height: 62px;
padding: 9px;
text-align: left;
background: #fff;
border: 1px solid transparent;
border-bottom-color: #edf0f5;
cursor: pointer;
&:hover,
&.is-active {
background: #f7f6ff;
}
&.is-active {
border-color: #5b50f2;
border-radius: 6px;
}
}
.result-index {
color: #667085;
font-size: 11px;
}
.result-copy {
min-width: 0;
strong,
small {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
strong {
color: #344054;
font-size: 12px;
}
small {
margin-top: 5px;
color: #98a2b3;
font-size: 10px;
}
}
.is-valid {
color: #2ca66a;
}
.is-error {
color: #d97706;
}
.css-spinner {
width: 14px;
height: 14px;
border: 2px solid rgba(91, 80, 242, 0.2);
border-top-color: #5b50f2;
border-radius: 50%;
animation: css-spin 0.8s linear infinite;
display: inline-block;
}
@keyframes css-spin {
to { transform: rotate(360deg); }
}
.result-editor-pane {
min-width: 0;
}
.source-reference {
margin: 14px 18px 2px;
padding: 12px 14px;
border: 1px solid #dfe4ec;
border-radius: 7px;
background: #f8fafc;
pre {
max-height: 168px;
margin: 10px 0 0;
overflow: auto;
color: #344054;
font-family: inherit;
font-size: 12px;
line-height: 1.7;
white-space: pre-wrap;
word-break: break-word;
}
p {
margin: 10px 0 0;
color: #98a2b3;
font-size: 12px;
}
> small {
display: block;
margin-top: 8px;
color: #8a93a3;
font-size: 10px;
}
}
.source-reference-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
> div {
display: flex;
align-items: baseline;
gap: 8px;
}
strong {
color: #344054;
font-size: 12px;
}
span {
color: #8a93a3;
font-size: 10px;
}
}
.field-editor {
padding: 13px 18px 0;
label {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 7px;
color: #344054;
font-size: 12px;
font-weight: 650;
}
em {
color: #e05252;
font-size: 10px;
font-style: normal;
font-weight: 400;
}
span {
color: #98a2b3;
font-size: 10px;
font-weight: 400;
}
}
.validation-error,
.validation-success {
margin: 12px 18px 0;
padding: 9px 11px;
font-size: 11px;
border-radius: 6px;
}
.validation-error {
color: #b45309;
background: #fff7e8;
}
.validation-success {
color: #25895c;
background: #edf9f3;
}
.editor-pagination {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
padding: 12px 18px;
span {
color: #8a93a3;
font-size: 11px;
}
}
@media (max-width: 900px) {
.result-workspace {
grid-template-columns: minmax(0, 1fr);
}
.result-list-pane {
border-right: 0;
border-bottom: 1px solid #e5e8ee;
}
.result-list {
height: 260px;
}
}
</style>