feat(data_process): 问答对数据评测体系与质量分雷达图
- 三层评测:规则层沿用原五维规则分,语义层用本地 BGE 向量算问答/来源 相关性,评审层复用生成模型按 rubric 打分(忠实度/正确性/清晰度等, 区分 standard/reasoning/dpo 输出类型),任一层失败自动降级 - 组合分 = 规则 35% + 语义 20% + 评审 45%,缺层自动重归一 - 新增 results/evaluate-batch 批量评测接口,镜像批量重生成的并发、 乐观锁与部分成功语义;生成阶段不再展示质量分 - 详情页与结果编辑页新增"数据评测"按钮和批量进度;质量分列悬停弹出 雷达图浮窗(评审 5 维 + 语义 2 维、三层分项、评审理由) - 手动编辑/恢复后重算规则与语义层并丢弃过期评审分,雷达图不再展示 失效数据
This commit is contained in:
@@ -20,6 +20,8 @@ import type {
|
||||
DataProcessPublishResult,
|
||||
DataProcessQualityScore,
|
||||
DataProcessResult,
|
||||
DataProcessResultBatchEvaluatePayload,
|
||||
DataProcessResultBatchEvaluateResult,
|
||||
DataProcessResultBatchRegeneratePayload,
|
||||
DataProcessResultBatchRegenerateResult,
|
||||
DataProcessResultRegeneratePayload,
|
||||
@@ -320,5 +322,14 @@ export const regenerateDataProcessResults = (
|
||||
{ timeout: 240_000 },
|
||||
)
|
||||
|
||||
export const evaluateDataProcessResults = (
|
||||
taskId: string | number,
|
||||
payload: DataProcessResultBatchEvaluatePayload,
|
||||
) => post<DataProcessResultBatchEvaluateResult>(
|
||||
`/data-process/${encodeURIComponent(taskId)}/results/evaluate-batch`,
|
||||
payload,
|
||||
{ timeout: 240_000 },
|
||||
)
|
||||
|
||||
export const publishDataProcess = (taskId: string | number, payload: DataProcessPublishPayload) =>
|
||||
post<DataProcessPublishResult>(`/data-process/${encodeURIComponent(taskId)}/publish`, payload)
|
||||
|
||||
@@ -3,18 +3,21 @@
|
||||
*/
|
||||
import { use } from 'echarts/core'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
import { BarChart, PieChart } from 'echarts/charts'
|
||||
import { BarChart, PieChart, RadarChart } from 'echarts/charts'
|
||||
import {
|
||||
GridComponent,
|
||||
TooltipComponent,
|
||||
LegendComponent,
|
||||
RadarComponent,
|
||||
} from 'echarts/components'
|
||||
|
||||
use([
|
||||
CanvasRenderer,
|
||||
BarChart,
|
||||
PieChart,
|
||||
RadarChart,
|
||||
GridComponent,
|
||||
TooltipComponent,
|
||||
LegendComponent,
|
||||
RadarComponent,
|
||||
])
|
||||
|
||||
@@ -398,6 +398,52 @@ export interface DataProcessResultBatchRegenerateResult {
|
||||
failures: DataProcessResultBatchRegenerateFailure[]
|
||||
}
|
||||
|
||||
export interface DataProcessResultBatchEvaluateItem {
|
||||
result_id: string
|
||||
expected_updated_at: string
|
||||
}
|
||||
|
||||
export interface DataProcessResultBatchEvaluatePayload {
|
||||
items: DataProcessResultBatchEvaluateItem[]
|
||||
}
|
||||
|
||||
export interface DataProcessResultBatchEvaluateFailure {
|
||||
result_id: string
|
||||
code: 'conflict' | 'skipped' | 'evaluation_failed' | 'internal_error'
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface DataProcessResultBatchEvaluateResult {
|
||||
batch_id: string
|
||||
total: number
|
||||
succeeded: number
|
||||
failed: number
|
||||
duration_ms: number
|
||||
items: DataProcessResult[]
|
||||
failures: DataProcessResultBatchEvaluateFailure[]
|
||||
}
|
||||
|
||||
export interface DataProcessQualitySemantic {
|
||||
question_answer?: number
|
||||
answer_source?: number
|
||||
overall?: number
|
||||
}
|
||||
|
||||
export interface DataProcessQualityJudge {
|
||||
scores?: Record<string, number>
|
||||
overall?: number
|
||||
reason?: string
|
||||
issues?: string[]
|
||||
model?: string
|
||||
output_type?: string
|
||||
}
|
||||
|
||||
export interface DataProcessQualityLayers {
|
||||
rule?: number | null
|
||||
semantic?: number | null
|
||||
judge?: number | null
|
||||
}
|
||||
|
||||
export interface DataProcessQualityScore {
|
||||
overall?: number
|
||||
completeness?: number
|
||||
@@ -408,6 +454,11 @@ export interface DataProcessQualityScore {
|
||||
is_valid?: boolean
|
||||
flags?: string[]
|
||||
fingerprint?: string
|
||||
semantic?: DataProcessQualitySemantic | null
|
||||
judge?: DataProcessQualityJudge | null
|
||||
layers?: DataProcessQualityLayers | null
|
||||
evaluated?: boolean
|
||||
evaluated_at?: string | null
|
||||
source_pages?: number[]
|
||||
heading_path?: string[]
|
||||
source_locator?: DataProcessSourceLocator
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
|
||||
252
frontend/src/views/data-process/create/QualityRadarPopover.vue
Normal file
252
frontend/src/views/data-process/create/QualityRadarPopover.vue
Normal 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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user