feat(data_process): 问答对数据评测体系与质量分雷达图

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

View File

@@ -18,6 +18,7 @@ import {
previewAffectingOptionsFor,
} from './create/dataProcessCreateState'
import { useDataProcessGeneration } from './create/useDataProcessGeneration'
import { useDataProcessEvaluation } from './create/useDataProcessEvaluation'
import { useDataProcessPreviewBuild } from './create/useDataProcessPreviewBuild'
import { useDataProcessRegeneration } from './create/useDataProcessRegeneration'
import { createDefaultExternalSource, externalSourcePayload, restoreExternalSourceConfig, sourceConfigForBackend } from './create/externalSourceConfig'
@@ -126,6 +127,19 @@ const {
outputType: activeOutputType,
beforeGenerate: beforeStartGeneration,
})
const {
evaluation,
evaluateAllResults,
resetEvaluation,
} = useDataProcessEvaluation({
taskId,
results,
selectedResultId,
})
// 生成结果被重置(重新切分/上传/重新生成配置)时同步清空评测进度。
watch(results, (items) => {
if (!items.length) resetEvaluation()
})
const { enqueueSourceUpload, sourceUploading } = useDataProcessSourceUpload({
taskId,
uploadedFiles,
@@ -1156,10 +1170,12 @@ onMounted(() => {
:preview-items="previewItems"
:regenerating-result-id="regeneratingResultId"
:bulk-regeneration="bulkRegeneration"
:evaluation="evaluation"
:output-type="activeOutputType"
@update:field="updateResultField"
@regenerate:all="regenerateAllResults"
@regenerate:item="regenerateResult"
@evaluate:all="evaluateAllResults"
/>
</div>
</div>

View File

@@ -5,6 +5,7 @@ import { ElMessage, ElMessageBox } from 'element-plus'
import PageCard from '@/components/PageCard.vue'
import { usePolling } from '@/composables/usePolling'
import {
evaluateDataProcessResults,
getDataProcessProgress,
getDataProcessResults,
getDataProcessTask,
@@ -13,6 +14,7 @@ import {
restoreDataProcessResult,
updateDataProcessResult,
} from '@/api/modules/dataProcess'
import QualityRadarPopover from './create/QualityRadarPopover.vue'
import type {
DataProcessDatasetSplit,
DataProcessPublishPayload,
@@ -456,13 +458,101 @@ function resultStatusType(status: DataProcessResultStatus) {
}
function qualityScoreLabel(value: DataProcessResult['quality_score']) {
if (value == null) return '-'
if (value == null || !value.evaluated) return '-'
const score = value.overall
return Number.isFinite(score) ? Number(score).toFixed(1) : '-'
}
function qualityFlagsLabel(value: DataProcessResult['quality_score']) {
return value?.flags?.length ? value.flags.join('、') : '未命中质量规则'
function qualityScoreTone(value: DataProcessResult['quality_score']) {
const score = Number(value?.overall)
if (!value?.evaluated || !Number.isFinite(score)) return ''
return score >= 80 ? 'is-success' : score >= 60 ? 'is-warning' : 'is-danger'
}
function qualityScoreEvaluated(value: DataProcessResult['quality_score']) {
return Boolean(value?.evaluated && Number.isFinite(Number(value?.overall)))
}
const evaluationRunning = ref(false)
const evaluationProgress = reactive({
visible: false,
total: 0,
completed: 0,
succeeded: 0,
failed: 0,
})
// 与批量重生成一致的分块大小,单批在接口 240 秒超时预算内。
const EVALUATION_CHUNK_SIZE = 12
const canEvaluate = computed(() => (
detail.value?.status === 'completed' && !hasCurrentPublishedDataset.value
))
const evaluationPercentage = computed(() => (
evaluationProgress.total
? Math.round((evaluationProgress.completed / evaluationProgress.total) * 100)
: 0
))
async function loadAllResultIds() {
const first = await getDataProcessResults(taskId.value, { page: 1, page_size: 500 })
const items = [...first.items]
const pages = Math.ceil(first.total / first.page_size)
for (let page = 2; page <= pages; page += 1) {
const next = await getDataProcessResults(taskId.value, { page, page_size: 500 })
items.push(...next.items)
}
return items
}
async function runResultEvaluation() {
if (evaluationRunning.value || !canEvaluate.value) return
evaluationRunning.value = true
Object.assign(evaluationProgress, {
visible: true,
total: 0,
completed: 0,
succeeded: 0,
failed: 0,
})
try {
const candidates = (await loadAllResultIds()).filter((item) => item.updated_at)
if (!candidates.length) {
ElMessage.info('当前没有可评测的结果')
return
}
evaluationProgress.total = candidates.length
for (let offset = 0; offset < candidates.length; offset += EVALUATION_CHUNK_SIZE) {
const chunk = candidates.slice(offset, offset + EVALUATION_CHUNK_SIZE)
try {
const evaluated = await evaluateDataProcessResults(taskId.value, {
items: chunk.map((item) => ({
result_id: String(item.id),
expected_updated_at: item.updated_at as string,
})),
})
evaluationProgress.completed += evaluated.total
evaluationProgress.succeeded += evaluated.succeeded
evaluationProgress.failed += evaluated.failed
} catch {
evaluationProgress.completed = evaluationProgress.total
evaluationProgress.failed += candidates.length - offset
break
}
}
await loadResults()
if (evaluationProgress.failed === 0) {
ElMessage.success(`数据评测完成:成功 ${evaluationProgress.succeeded}`)
} else if (evaluationProgress.succeeded > 0) {
ElMessage.warning(
`数据评测完成:成功 ${evaluationProgress.succeeded} 条,失败 ${evaluationProgress.failed}`,
)
} else {
ElMessage.error(`数据评测失败:${evaluationProgress.failed} 条结果未完成评测`)
}
} catch {
ElMessage.error('数据评测中断,已完成的评分保持不变')
} finally {
evaluationRunning.value = false
}
}
function replaceResult(updated: DataProcessResult) {
@@ -824,10 +914,33 @@ onBeforeUnmount(() => {
<el-option label="已修改" value="modified" />
<el-option label="无效" value="invalid" />
</el-select>
<el-button
v-if="canEvaluate"
type="primary"
plain
:loading="evaluationRunning"
:disabled="resultLoading"
@click="runResultEvaluation"
>
<i v-if="!evaluationRunning" class="fa fa-check-square-o" aria-hidden="true" /> 数据评测
</el-button>
<el-button :loading="resultLoading" @click="loadResults"><i class="fa fa-refresh" /></el-button>
</div>
</div>
<div v-if="evaluationProgress.visible" class="evaluation-progress">
<span>
数据评测 {{ evaluationProgress.completed }} / {{ evaluationProgress.total }}
· 成功 {{ evaluationProgress.succeeded }} · 失败 {{ evaluationProgress.failed }}
</span>
<el-progress
:percentage="evaluationPercentage"
:show-text="false"
:stroke-width="5"
:color="evaluationProgress.failed > 0 ? '#d97706' : '#5b50f2'"
/>
</div>
<el-table
v-if="results.length"
:data="results"
@@ -845,9 +958,26 @@ onBeforeUnmount(() => {
</template>
<el-table-column label="质量分" width="88" align="center">
<template #default="{ row }">
<el-tooltip :content="qualityFlagsLabel((row as DataProcessResult).quality_score)">
<span>{{ qualityScoreLabel((row as DataProcessResult).quality_score) }}</span>
</el-tooltip>
<el-popover
v-if="qualityScoreEvaluated((row as DataProcessResult).quality_score)"
placement="top"
:width="296"
trigger="hover"
:show-after="150"
popper-class="quality-radar-popper"
>
<template #reference>
<span
class="detail-quality-score"
:class="qualityScoreTone((row as DataProcessResult).quality_score)"
>{{ qualityScoreLabel((row as DataProcessResult).quality_score) }}</span>
</template>
<QualityRadarPopover
:quality="(row as DataProcessResult).quality_score!"
:score="Number((row as DataProcessResult).quality_score?.overall)"
/>
</el-popover>
<span v-else class="detail-quality-empty">{{ qualityScoreLabel((row as DataProcessResult).quality_score) }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="90" align="center">
@@ -1099,6 +1229,35 @@ onBeforeUnmount(() => {
.result-filters :deep(.el-select) { width: 120px; }
.result-section :deep(.el-table) { border-radius: 0; }
.evaluation-progress {
display: grid;
grid-template-columns: 1fr 220px;
align-items: center;
gap: 14px;
padding: 10px 18px;
color: #667085;
background: #f8f9fc;
font-size: 12px;
}
.detail-quality-score {
display: inline-block;
min-width: 44px;
padding: 2px 8px;
border-radius: 10px;
color: #475467;
background: #f2f4f7;
font-weight: 700;
font-variant-numeric: tabular-nums;
cursor: default;
&.is-success { color: #067647; background: #e6f4ee; }
&.is-warning { color: #b54708; background: #fef0c7; }
&.is-danger { color: #b42318; background: #fee4e2; }
}
.detail-quality-empty { color: #98a2b3; }
:global(.data-process-result-tooltip) {
box-sizing: border-box;
max-width: min(520px, calc(100vw - 32px));

View File

@@ -0,0 +1,252 @@
<script setup lang="ts">
import { computed } from 'vue'
import VChart from 'vue-echarts'
import '@/plugins/echarts'
import type { EChartsOption } from 'echarts'
import type { ResultQualityDetails } from './types'
const props = defineProps<{
quality: ResultQualityDetails
score?: number
}>()
// 轴标签按词意预置断行,避免长中文标签把雷达网格挤偏或被机械切词。
const JUDGE_DIMENSION_LABELS: Record<string, string> = {
faithfulness: '忠实度',
correctness: '正确性',
clarity: '问题\n清晰度',
completeness: '回答\n完整性',
alignment: '指令\n对齐',
reasoning_validity: '推理\n有效性',
chosen_quality: 'chosen\n质量',
rejected_quality: 'rejected\n质量',
preference_reasonableness: '偏好\n区分',
}
const SEMANTIC_DIMENSION_LABELS: Record<string, string> = {
question_answer: '问答\n相关',
answer_source: '来源\n覆盖',
}
interface RadarDimension {
name: string
value: number
}
const radarDimensions = computed<RadarDimension[]>(() => {
const dimensions: RadarDimension[] = []
for (const [key, value] of Object.entries(props.quality?.judge?.scores ?? {})) {
dimensions.push({
name: JUDGE_DIMENSION_LABELS[key] ?? key,
value: Math.round(value * 20),
})
}
for (const [key, value] of Object.entries(props.quality?.semantic ?? {})) {
if (key === 'overall' || typeof value !== 'number') continue
dimensions.push({
name: SEMANTIC_DIMENSION_LABELS[key] ?? key,
value: Math.round(value),
})
}
return dimensions
})
// 可用维度太少时雷达图失去意义,降级为分层分数展示。
const showRadar = computed(() => radarDimensions.value.length >= 3)
const radarOption = computed<EChartsOption>(() => ({
radar: {
indicator: radarDimensions.value.map((dimension) => ({
name: dimension.name,
max: 100,
})),
radius: '56%',
center: ['50%', '50%'],
splitNumber: 4,
axisName: {
color: '#667085',
fontSize: 10,
lineHeight: 13,
},
splitArea: { areaStyle: { color: ['#fbfbfd', '#f2f4f8'] } },
splitLine: { lineStyle: { color: '#e4e7ec' } },
axisLine: { lineStyle: { color: '#e4e7ec' } },
},
series: [{
type: 'radar',
symbol: 'circle',
symbolSize: 3,
data: [{
value: radarDimensions.value.map((dimension) => dimension.value),
name: '质量维度',
areaStyle: { color: 'rgba(91, 80, 242, 0.18)' },
lineStyle: { color: '#5b50f2', width: 1.5 },
itemStyle: { color: '#5b50f2' },
}],
}],
}))
const layerScores = computed(() => {
const layers = props.quality?.layers ?? {}
return [
{ label: '规则层', value: layers.rule },
{ label: '语义层', value: layers.semantic },
{ label: '评审层', value: layers.judge },
].filter((layer): layer is { label: string; value: number } => (
typeof layer.value === 'number'
))
})
const displayScore = computed(() => {
if (typeof props.score === 'number' && !isNaN(props.score)) {
return props.score.toFixed(1)
}
return null
})
const scoreTone = computed(() => {
const numScore = props.score ?? 0
return numScore >= 80 ? 'is-success' : numScore >= 60 ? 'is-warning' : 'is-danger'
})
</script>
<template>
<div class="quality-popover">
<div v-if="displayScore !== null" class="popover-header">
<strong>质量评测</strong>
<span class="popover-score" :class="scoreTone">{{ displayScore }}</span>
</div>
<VChart
v-if="showRadar"
class="quality-radar"
:option="radarOption"
autoresize
/>
<div v-else class="radar-fallback">
维度数据不足已评测维度少于 3 个时以分层分数为准
</div>
<div class="layer-scores">
<div v-for="layer in layerScores" :key="layer.label" class="layer-item">
<span>{{ layer.label }}</span>
<el-progress
:percentage="Math.round(layer.value)"
:stroke-width="6"
:show-text="false"
:color="layer.value >= 80 ? '#12b76a' : layer.value >= 60 ? '#f0b429' : '#d92d20'"
/>
<em>{{ layer.value.toFixed(0) }}</em>
</div>
</div>
<p v-if="quality?.judge?.reason" class="judge-reason">{{ quality.judge.reason }}</p>
<div v-if="quality?.judge?.issues?.length" class="judge-issues">
<span v-for="issue in quality.judge.issues" :key="issue" class="issue-tag">{{ issue }}</span>
</div>
<div v-if="quality?.judge?.model" class="judge-model">评审模型{{ quality.judge.model }}</div>
</div>
</template>
<style scoped lang="scss">
.quality-popover {
display: flex;
flex-direction: column;
gap: 10px;
width: 100%;
box-sizing: border-box;
}
.popover-header {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: 6px;
border-bottom: 1px solid #f2f4f7;
strong {
color: #344054;
font-size: 13px;
}
}
.popover-score {
color: #344054;
font-size: 18px;
font-weight: 700;
font-variant-numeric: tabular-nums;
&.is-success { color: #12b76a; }
&.is-warning { color: #d99b0b; }
&.is-danger { color: #d92d20; }
}
.quality-radar {
width: 100%;
height: 220px;
box-sizing: border-box;
}
.radar-fallback {
padding: 18px 10px;
color: #98a2b3;
font-size: 12px;
text-align: center;
}
.layer-scores {
display: flex;
flex-direction: column;
gap: 6px;
}
.layer-item {
display: grid;
grid-template-columns: 44px 1fr 28px;
align-items: center;
gap: 8px;
color: #667085;
font-size: 11px;
em {
color: #344054;
font-style: normal;
font-weight: 600;
text-align: right;
font-variant-numeric: tabular-nums;
}
}
.judge-reason {
margin: 0;
color: #475467;
font-size: 12px;
line-height: 1.6;
}
.judge-issues {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.issue-tag {
padding: 2px 8px;
color: #b54708;
background: #fef0c7;
border-radius: 3px;
font-size: 11px;
}
.judge-model {
color: #98a2b3;
font-size: 11px;
}
</style>
<style lang="scss">
.quality-radar-popper {
padding: 14px 16px !important;
}
</style>

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { BulkResultRegenerationState, PreviewItem, ResultItem } from './types'
import QualityRadarPopover from './QualityRadarPopover.vue'
import type { BulkResultRegenerationState, PreviewItem, ResultEvaluationState, ResultItem } from './types'
import type { DataProcessOutputType } from '@/types/dataProcess'
const props = defineProps<{
@@ -9,6 +10,7 @@ const props = defineProps<{
selectedId: string | null
regeneratingResultId: string | null
bulkRegeneration: BulkResultRegenerationState
evaluation: ResultEvaluationState
outputType: DataProcessOutputType
}>()
@@ -17,6 +19,7 @@ const emit = defineEmits<{
'update:field': [id: string, field: 'instruction' | 'input' | 'output' | 'chosen' | 'rejected', value: string]
'regenerate:item': [id: string]
'regenerate:all': []
'evaluate:all': []
}>()
const search = ref('')
@@ -30,6 +33,15 @@ const bulkRegenerationActive = computed(() => props.bulkRegeneration.status ===
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)
@@ -98,21 +110,47 @@ function selectRelative(offset: number) {
<template>
<section class="result-step">
<div class="result-workspace">
<aside class="result-list-pane" :class="{ 'has-bulk-progress': bulkRegenerationVisible }">
<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 }} </span></div>
<el-button
v-if="invalidCount > 0"
size="small"
plain
type="primary"
:loading="bulkRegenerationActive"
:disabled="Boolean(regeneratingResultId) || bulkRegenerationActive"
@click="emit('regenerate:all')"
>
<i v-if="!bulkRegenerationActive" class="fa fa-refresh" style="margin-right: 4px;" />
{{ bulkRegenerationActive ? '重新生成中' : `全部重新生成(${invalidCount}` }}
</el-button>
<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>
@@ -146,6 +184,23 @@ function selectRelative(offset: number) {
<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
@@ -303,6 +358,42 @@ function selectRelative(offset: number) {
}
}
.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;

View File

@@ -1,6 +1,9 @@
import type {
DataProcessOutputType,
DataProcessPreviewFileStatus,
DataProcessQualityJudge,
DataProcessQualityLayers,
DataProcessQualitySemantic,
DataProcessReasoningDetail,
} from '@/types/dataProcess'
@@ -165,6 +168,14 @@ export interface GenerationState {
message: string
}
export interface ResultQualityDetails {
semantic?: DataProcessQualitySemantic | null
judge?: DataProcessQualityJudge | null
layers?: DataProcessQualityLayers | null
evaluated?: boolean
flags?: string[]
}
export interface ResultItem {
id: string
previewItemId: string | null
@@ -188,7 +199,7 @@ export interface ResultItem {
error?: string
split?: 'train' | 'validation' | 'test'
qualityScore?: number
qualityDetails?: Record<string, number>
qualityDetails?: ResultQualityDetails
updatedAt?: string
}
@@ -201,3 +212,11 @@ export interface BulkResultRegenerationState {
targetIds: string[]
failedIds: string[]
}
export interface ResultEvaluationState {
status: 'idle' | 'running' | 'completed' | 'partial' | 'failed'
total: number
completed: number
succeeded: number
failed: number
}

View File

@@ -0,0 +1,126 @@
import { computed, reactive, type Ref } from 'vue'
import { ElMessage } from 'element-plus'
import { evaluateDataProcessResults } from '@/api/modules/dataProcess'
import { mapResult } from './useDataProcessGeneration'
import type { ResultEvaluationState, ResultItem } from './types'
interface EvaluationBindings {
taskId: Ref<string | null>
results: Ref<ResultItem[]>
selectedResultId: Ref<string | null>
}
// 与批量重新生成一致的分块大小4 个后端 worker 消费三轮,
// 单条评测最长 60 秒12 条在批量接口 240 秒超时预算内。
const EVALUATION_CHUNK_SIZE = 12
function hasUnsavedChanges(item: ResultItem) {
return item.instruction !== item.savedInstruction
|| item.input !== item.savedInput
|| item.output !== item.savedOutput
|| item.chosen !== item.savedChosen
|| item.rejected !== item.savedRejected
}
export function useDataProcessEvaluation(bindings: EvaluationBindings) {
const evaluation = reactive<ResultEvaluationState>({
status: 'idle',
total: 0,
completed: 0,
succeeded: 0,
failed: 0,
})
const evaluationBusy = computed(() => evaluation.status === 'running')
function resetEvaluation() {
Object.assign(evaluation, {
status: 'idle',
total: 0,
completed: 0,
succeeded: 0,
failed: 0,
})
}
async function evaluateAllResults() {
const taskId = bindings.taskId.value
if (!taskId) return false
if (evaluationBusy.value) {
ElMessage.warning('请等待当前数据评测完成')
return false
}
const candidates = bindings.results.value.filter((item) => item.updatedAt)
if (!candidates.length) {
ElMessage.info('当前没有可评测的结果')
return false
}
const unsaved = candidates.find(hasUnsavedChanges)
if (unsaved) {
bindings.selectedResultId.value = unsaved.id
ElMessage.warning('存在未保存的修改,请先保存后再进行数据评测')
return false
}
Object.assign(evaluation, {
status: 'running',
total: candidates.length,
completed: 0,
succeeded: 0,
failed: 0,
})
let interrupted = false
try {
for (let offset = 0; offset < candidates.length; offset += EVALUATION_CHUNK_SIZE) {
const chunk = candidates.slice(offset, offset + EVALUATION_CHUNK_SIZE)
try {
const evaluated = await evaluateDataProcessResults(taskId, {
items: chunk.map((item) => ({
result_id: item.id,
expected_updated_at: item.updatedAt as string,
})),
})
for (const item of evaluated.items) {
const index = bindings.results.value.findIndex((entry) => entry.id === String(item.id))
if (index >= 0) bindings.results.value[index] = mapResult(item)
}
evaluation.completed += evaluated.total
evaluation.succeeded += evaluated.succeeded
evaluation.failed += evaluated.failed
} catch {
const remaining = candidates.slice(offset)
evaluation.completed = evaluation.total
evaluation.failed += remaining.length
interrupted = true
break
}
}
if (!interrupted && evaluation.failed === 0) {
evaluation.status = 'completed'
ElMessage.success(`数据评测完成:成功 ${evaluation.succeeded}`)
} else if (evaluation.succeeded > 0) {
evaluation.status = 'partial'
ElMessage.warning(
`数据评测完成:成功 ${evaluation.succeeded} 条,失败 ${evaluation.failed}`,
)
} else {
evaluation.status = 'failed'
ElMessage.error(`数据评测失败:${evaluation.failed} 条结果未完成评测`)
}
return evaluation.failed === 0
} catch {
evaluation.status = 'failed'
ElMessage.error('批量数据评测意外中断,已完成的评分保持不变')
return false
}
}
return {
evaluation,
evaluationBusy,
evaluateAllResults,
resetEvaluation,
}
}

View File

@@ -28,6 +28,7 @@ const POLL_INTERVAL_MS = 1500
const BULK_REGENERATION_CHUNK_SIZE = 12
function mapResult(item: DataProcessResult): ResultItem {
const quality = item.quality_score
return {
id: String(item.id),
previewItemId: item.preview_item_id == null ? null : String(item.preview_item_id),
@@ -50,11 +51,21 @@ function mapResult(item: DataProcessResult): ResultItem {
status: item.status,
error: item.error || undefined,
split: item.split || undefined,
qualityScore: item.quality_score?.overall,
// 生成阶段只有内部规则分,界面不展示;数据评测完成后才显示组合分。
qualityScore: quality?.evaluated ? quality.overall : undefined,
qualityDetails: {
semantic: quality?.semantic ?? null,
judge: quality?.judge ?? null,
layers: quality?.layers ?? null,
evaluated: Boolean(quality?.evaluated),
flags: quality?.flags ?? [],
},
updatedAt: item.updated_at,
}
}
export { mapResult }
export function useDataProcessGeneration(bindings: GenerationBindings) {
const results = ref<ResultItem[]>([])
const selectedResultId = ref<string | null>(null)