feat(aihr): close phase two practice pilot gates
This commit is contained in:
@@ -12,6 +12,43 @@ export type PracticeStartRequest = {
|
||||
mode: string;
|
||||
};
|
||||
|
||||
export type PracticeScenarioResponse = {
|
||||
id: string;
|
||||
name: string;
|
||||
position: string;
|
||||
scenarioType: string;
|
||||
projectType: string;
|
||||
difficulty: number;
|
||||
customerPersona: string;
|
||||
openingText: string;
|
||||
explicitNeed: string;
|
||||
hiddenNeed: string;
|
||||
irritants: string;
|
||||
delights: string;
|
||||
sopRefs: string;
|
||||
successCriteria: string;
|
||||
strategy: string;
|
||||
goal: string;
|
||||
enabled: boolean;
|
||||
rubric?: {
|
||||
code: string;
|
||||
name: string;
|
||||
version: string;
|
||||
dimensions: Array<{ code: string; name: string; weight: number; description: string }>;
|
||||
};
|
||||
};
|
||||
|
||||
export type PracticeScenarioEnabledRequest = {
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
export type PracticePrepCardResponse = {
|
||||
keyPoints: string[];
|
||||
redLines: string[];
|
||||
scripts: string[];
|
||||
promptVersion: string;
|
||||
};
|
||||
|
||||
export type PracticeStartResponse = {
|
||||
sessionId: string;
|
||||
scenarioId: string;
|
||||
@@ -34,6 +71,7 @@ export type PracticeTurnRequest = {
|
||||
roundIndex: number;
|
||||
traineeText: string;
|
||||
traineeAudioUrl?: string;
|
||||
traineeAudioOssId?: number;
|
||||
};
|
||||
|
||||
export type PracticeTurnResponse = {
|
||||
@@ -42,6 +80,7 @@ export type PracticeTurnResponse = {
|
||||
emotion: number;
|
||||
trust: number;
|
||||
coachHint: string;
|
||||
redFlag: boolean;
|
||||
roundIndex: number;
|
||||
finished: boolean;
|
||||
};
|
||||
@@ -70,6 +109,78 @@ export type PracticeFinishResponse = {
|
||||
}>;
|
||||
};
|
||||
|
||||
export type PracticeCalibrationRequest = {
|
||||
reviewer?: string;
|
||||
correctedTotal?: number;
|
||||
correctedCompliance?: number;
|
||||
correctedEmotion?: number;
|
||||
correctedCommunication?: number;
|
||||
correctedMarketing?: number;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
export type PracticeCalibrationResponse = {
|
||||
id: number;
|
||||
sessionId: string;
|
||||
originalScore: number;
|
||||
correctedScore: number;
|
||||
reviewer: string;
|
||||
reason: string;
|
||||
status: string;
|
||||
createTime: string;
|
||||
};
|
||||
|
||||
export type PromptTemplateResponse = {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
category: string;
|
||||
outputFormat: string;
|
||||
systemPrompt: string;
|
||||
template: string;
|
||||
variables: string;
|
||||
version: string;
|
||||
enabled: boolean;
|
||||
description: string;
|
||||
updateTime: string;
|
||||
};
|
||||
|
||||
export type PromptTemplateRequest = {
|
||||
code: string;
|
||||
name: string;
|
||||
category: string;
|
||||
outputFormat: string;
|
||||
systemPrompt: string;
|
||||
template: string;
|
||||
variables: string;
|
||||
version: string;
|
||||
enabled: boolean;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export function listPracticeScenarios(params?: { position?: string; scenarioType?: string }): Promise<ApiResult<PracticeScenarioResponse[]>> {
|
||||
return request({
|
||||
url: '/api/train/practice/scenarios',
|
||||
method: 'get',
|
||||
params
|
||||
});
|
||||
}
|
||||
|
||||
export function updatePracticeScenarioEnabled(id: string, data: PracticeScenarioEnabledRequest): Promise<ApiResult<null>> {
|
||||
return request({
|
||||
url: `/api/train/practice/scenarios/${id}/enabled`,
|
||||
method: 'patch',
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
export function getPracticePrepCard(id: string): Promise<ApiResult<PracticePrepCardResponse>> {
|
||||
return request({
|
||||
url: `/api/train/practice/scenarios/${id}/prep-card`,
|
||||
method: 'get'
|
||||
});
|
||||
}
|
||||
|
||||
export function startPracticeSession(data: PracticeStartRequest): Promise<ApiResult<PracticeStartResponse>> {
|
||||
return request({
|
||||
url: '/api/train/practice/start',
|
||||
@@ -96,3 +207,44 @@ export function finishPracticeSession(data: PracticeFinishRequest): Promise<ApiR
|
||||
headers: { repeatSubmit: false }
|
||||
});
|
||||
}
|
||||
|
||||
export function calibratePracticeSession(sessionId: string, data: PracticeCalibrationRequest): Promise<ApiResult<PracticeCalibrationResponse>> {
|
||||
return request({
|
||||
url: `/api/train/practice/sessions/${sessionId}/calibration`,
|
||||
method: 'post',
|
||||
data,
|
||||
headers: { repeatSubmit: false }
|
||||
});
|
||||
}
|
||||
|
||||
export function exportPracticePilotCsv(): Promise<Blob> {
|
||||
return request({
|
||||
url: '/api/train/practice/export',
|
||||
method: 'get',
|
||||
responseType: 'blob'
|
||||
}) as unknown as Promise<Blob>;
|
||||
}
|
||||
|
||||
export function listPromptTemplates(params?: { category?: string; code?: string; enabled?: boolean }): Promise<ApiResult<PromptTemplateResponse[]>> {
|
||||
return request({
|
||||
url: '/api/aihr/prompt-templates',
|
||||
method: 'get',
|
||||
params
|
||||
});
|
||||
}
|
||||
|
||||
export function createPromptTemplate(data: PromptTemplateRequest): Promise<ApiResult<PromptTemplateResponse>> {
|
||||
return request({
|
||||
url: '/api/aihr/prompt-templates',
|
||||
method: 'post',
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
export function updatePromptTemplate(id: number, data: PromptTemplateRequest): Promise<ApiResult<PromptTemplateResponse>> {
|
||||
return request({
|
||||
url: `/api/aihr/prompt-templates/${id}`,
|
||||
method: 'put',
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
@@ -36,6 +36,16 @@ export type SopSearchResponse = {
|
||||
snippets: SopSnippet[];
|
||||
training: string[];
|
||||
records: SopRecord[];
|
||||
keyPoints?: string[];
|
||||
cautions?: string[];
|
||||
reviewId?: number;
|
||||
};
|
||||
|
||||
export type SopReviewResponse = {
|
||||
id: number;
|
||||
usable: boolean | null;
|
||||
status: string;
|
||||
reviewedAt: string;
|
||||
};
|
||||
|
||||
export type SopUploadResponse = {
|
||||
@@ -49,7 +59,7 @@ export type SopUploadResponse = {
|
||||
snippets: SopSnippet[];
|
||||
};
|
||||
|
||||
export function searchKnowledgeSop(data: { queryText: string; category: string; limit: number }): Promise<ApiResult<SopSearchResponse>> {
|
||||
export function searchKnowledgeSop(data: { queryText: string; category: string; position?: string; source?: string; limit: number }): Promise<ApiResult<SopSearchResponse>> {
|
||||
return request({
|
||||
url: '/api/knowledge/search',
|
||||
method: 'post',
|
||||
@@ -68,6 +78,15 @@ export function uploadKnowledgeDoc(data: FormData): Promise<ApiResult<SopUploadR
|
||||
});
|
||||
}
|
||||
|
||||
export function reviewKnowledgeSop(id: number, data: { usable: boolean; reviewer?: string; note?: string }): Promise<ApiResult<SopReviewResponse>> {
|
||||
return request({
|
||||
url: `/api/knowledge/reviews/${id}`,
|
||||
method: 'post',
|
||||
data,
|
||||
headers: { repeatSubmit: false }
|
||||
});
|
||||
}
|
||||
|
||||
export type UploadEnqueueResponse = {
|
||||
itemId: number;
|
||||
batchId: string;
|
||||
|
||||
@@ -50,6 +50,13 @@
|
||||
<strong>AI 回答</strong>
|
||||
<p>{{ answerText }}</p>
|
||||
<span>引用:{{ referenceText }}</span>
|
||||
<div class="review-row">
|
||||
<small>{{ reviewMessage }}</small>
|
||||
<div>
|
||||
<el-button size="small" type="success" :loading="reviewSaving" :disabled="!canReview" @click="markReview(true)">可用</el-button>
|
||||
<el-button size="small" :loading="reviewSaving" :disabled="!canReview" @click="markReview(false)">不可用</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-state">点击检索后展示命中文档、原文片段和带引用的 AI 回答。</div>
|
||||
<div v-if="searched" class="doc-list">
|
||||
@@ -105,7 +112,7 @@ import { ElMessage } from 'element-plus';
|
||||
import type { UploadFile } from 'element-plus';
|
||||
import { computed, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { searchKnowledgeSop, uploadKnowledgeDoc, type SopRecord, type SopSearchResponse } from '@/api/aihr/sop';
|
||||
import { reviewKnowledgeSop, searchKnowledgeSop, uploadKnowledgeDoc, type SopRecord, type SopSearchResponse } from '@/api/aihr/sop';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
@@ -203,6 +210,8 @@ const apiMode = ref<'seed' | 'api' | 'fallback'>('seed');
|
||||
const apiResult = ref<SopSearchResponse | null>(null);
|
||||
const uploading = ref(false);
|
||||
const uploadedRecords = ref<SopRecord[]>([]);
|
||||
const reviewSaving = ref(false);
|
||||
const reviewStatus = ref('');
|
||||
|
||||
const activeSeed = computed(() => sopSeeds[selectedCategory.value]);
|
||||
const answerText = computed(() => apiResult.value?.answer ?? activeSeed.value.answer);
|
||||
@@ -220,6 +229,12 @@ const flowStatus = computed(() => {
|
||||
if (searched.value) return { label: `已命中 ${snippets.value.length} 段`, type: 'pill-primary' };
|
||||
return { label: '可引用 SOP 内容', type: 'pill-success' };
|
||||
});
|
||||
const canReview = computed(() => apiMode.value === 'api' && Boolean(apiResult.value?.reviewId));
|
||||
const reviewMessage = computed(() => {
|
||||
if (reviewStatus.value) return reviewStatus.value;
|
||||
if (canReview.value) return `待评审样本 #${apiResult.value?.reviewId}`;
|
||||
return '后端检索后可评审';
|
||||
});
|
||||
|
||||
const seedRecords = [
|
||||
{
|
||||
@@ -257,10 +272,12 @@ function selectCategory(item: Category) {
|
||||
trainingGenerated.value = false;
|
||||
apiMode.value = 'seed';
|
||||
apiResult.value = null;
|
||||
reviewStatus.value = '';
|
||||
}
|
||||
|
||||
async function runSearch() {
|
||||
trainingGenerated.value = false;
|
||||
reviewStatus.value = '';
|
||||
try {
|
||||
const { data } = await searchKnowledgeSop({
|
||||
queryText: queryText.value,
|
||||
@@ -279,6 +296,28 @@ async function runSearch() {
|
||||
}
|
||||
}
|
||||
|
||||
async function markReview(usable: boolean) {
|
||||
const reviewId = apiResult.value?.reviewId;
|
||||
if (!reviewId) {
|
||||
ElMessage.warning('请先完成一次后端 SOP 检索');
|
||||
return;
|
||||
}
|
||||
reviewSaving.value = true;
|
||||
try {
|
||||
const { data } = await reviewKnowledgeSop(reviewId, {
|
||||
usable,
|
||||
reviewer: '管理端评审',
|
||||
note: usable ? '回答可用于试点问答' : '回答需补充或修正'
|
||||
});
|
||||
reviewStatus.value = `${data.status}:${data.usable ? '可用' : '不可用'} ${data.reviewedAt}`;
|
||||
ElMessage.success('SOP 问答评审已记录');
|
||||
} catch {
|
||||
ElMessage.error('SOP 问答评审失败');
|
||||
} finally {
|
||||
reviewSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDocSelected(uploadFile: UploadFile) {
|
||||
const raw = uploadFile.raw;
|
||||
if (!raw) return;
|
||||
@@ -442,6 +481,19 @@ async function generateTraining() {
|
||||
}
|
||||
}
|
||||
|
||||
.review-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
|
||||
small {
|
||||
color: #657184;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state,
|
||||
.training-card {
|
||||
padding: 14px;
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
<div class="head-actions">
|
||||
<span class="status-pill" :class="apiPill.className">{{ apiPill.text }}</span>
|
||||
<span class="status-pill" :class="flowPill.className">{{ flowPill.text }}</span>
|
||||
<el-button :loading="exportingPilot" @click="exportPilotCsv">导出试点CSV</el-button>
|
||||
<el-button @click="router.push('/index')">返回首页</el-button>
|
||||
<el-button type="danger" :icon="Refresh" @click="resetPractice">重置演示</el-button>
|
||||
</div>
|
||||
@@ -30,7 +31,7 @@
|
||||
|
||||
<div class="select-card">
|
||||
<span>训练场景</span>
|
||||
<el-select v-model="scenarioId" class="scenario-select" @change="resetPractice">
|
||||
<el-select v-model="scenarioId" class="scenario-select" @change="handleScenarioChange">
|
||||
<el-option v-for="scenario in scenarios" :key="scenario.id" :label="scenario.name" :value="scenario.id" />
|
||||
</el-select>
|
||||
</div>
|
||||
@@ -42,6 +43,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rubric-card">
|
||||
<div class="rubric-head">
|
||||
<strong>Rubric 预览</strong>
|
||||
<small>{{ selectedScenario.scores.length }} 个维度</small>
|
||||
</div>
|
||||
<p v-for="score in selectedScenario.scores" :key="score.label">
|
||||
<span>{{ score.label }}</span>
|
||||
<b>{{ score.value }}</b>
|
||||
<small>{{ score.note }}</small>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="trust-card">
|
||||
<div>
|
||||
<span>业主信任度</span>
|
||||
@@ -50,6 +63,14 @@
|
||||
<div class="meter"><i :style="{ width: `${trustScore}%` }"></i></div>
|
||||
</div>
|
||||
|
||||
<div class="trust-card emotion-card">
|
||||
<div>
|
||||
<span>情绪波动</span>
|
||||
<strong>{{ emotionScore }}%</strong>
|
||||
</div>
|
||||
<div class="meter"><i :style="{ width: `${emotionScore}%` }"></i></div>
|
||||
</div>
|
||||
|
||||
<div class="action-row config-actions">
|
||||
<el-button type="danger" :disabled="started" :icon="Microphone" @click="startPractice">开始对练</el-button>
|
||||
<el-button :icon="Tickets" @click="showGoal = !showGoal">{{ showGoal ? '收起目标' : '查看目标' }}</el-button>
|
||||
@@ -59,6 +80,16 @@
|
||||
<strong>训练目标</strong>
|
||||
<p>{{ selectedScenario.goal }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="prepCard || prepLoading" class="prep-card">
|
||||
<strong>训练前预习卡</strong>
|
||||
<small>{{ prepLoading ? '加载中' : prepCard?.promptVersion }}</small>
|
||||
<template v-if="prepCard">
|
||||
<p>要点:{{ prepCard.keyPoints.join(' / ') }}</p>
|
||||
<p>红线:{{ prepCard.redLines.join(' / ') }}</p>
|
||||
<p>话术:{{ prepCard.scripts[0] }}</p>
|
||||
</template>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel chat-panel">
|
||||
@@ -101,8 +132,16 @@
|
||||
|
||||
<div class="action-row">
|
||||
<el-button :disabled="finished" @click="continueRound">继续一轮</el-button>
|
||||
<el-button :disabled="sopHelpLoading" @click="askSopHelp">{{ sopHelpLoading ? '查询中' : '求助 SOP' }}</el-button>
|
||||
<el-button type="danger" :disabled="!canScore" @click="finishScore">结束并评分</el-button>
|
||||
</div>
|
||||
|
||||
<div v-if="sopHelp || sopHelpMessage" class="help-card">
|
||||
<strong>SOP 求助</strong>
|
||||
<p v-if="sopHelp">{{ sopHelp.answer }}</p>
|
||||
<p v-else>{{ sopHelpMessage }}</p>
|
||||
<small v-if="sopHelp?.reference">{{ sopHelp.reference }}</small>
|
||||
</div>
|
||||
</template>
|
||||
</article>
|
||||
|
||||
@@ -129,6 +168,24 @@
|
||||
<strong>导师改写</strong>
|
||||
<p>{{ displayRewrite }}</p>
|
||||
</div>
|
||||
<div class="calibration-card">
|
||||
<div class="calibration-head">
|
||||
<strong>人工校准</strong>
|
||||
<small>{{ calibrationMessage || (canCalibrate ? '用于 M5 分档一致率样本' : '真实 API 评分后可记录') }}</small>
|
||||
</div>
|
||||
<el-input-number v-model="calibrationScore" :min="0" :max="100" :disabled="!canCalibrate" />
|
||||
<el-input
|
||||
v-model="calibrationReason"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
resize="none"
|
||||
:disabled="!canCalibrate"
|
||||
placeholder="校准原因"
|
||||
/>
|
||||
<div class="action-row">
|
||||
<el-button type="danger" :loading="calibrationSaving" :disabled="!canCalibrate" @click="saveCalibration">记录校准样本</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</article>
|
||||
</section>
|
||||
@@ -147,15 +204,130 @@
|
||||
<el-table-column prop="summary" label="AI 摘要" min-width="260" />
|
||||
</el-table>
|
||||
</section>
|
||||
|
||||
<section class="panel prompt-panel">
|
||||
<div class="panel-title title-with-extra">
|
||||
<div>
|
||||
<el-icon><Tickets /></el-icon>
|
||||
<h2>提示词模板</h2>
|
||||
</div>
|
||||
<span>{{ promptTemplates.length }} 个模板</span>
|
||||
</div>
|
||||
|
||||
<div class="prompt-layout">
|
||||
<div class="prompt-list">
|
||||
<el-table v-loading="promptLoading" :data="promptTemplates" size="small" class="record-table" row-key="id">
|
||||
<el-table-column prop="code" label="Code" min-width="150" />
|
||||
<el-table-column prop="name" label="名称" min-width="130" />
|
||||
<el-table-column prop="category" label="分类" width="110" />
|
||||
<el-table-column prop="outputFormat" label="输出" width="118" />
|
||||
<el-table-column prop="version" label="版本" width="82" />
|
||||
<el-table-column label="状态" width="92">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.enabled ? 'success' : 'info'" effect="plain">{{ row.enabled ? '启用' : '停用' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="editPromptTemplate(row)">编辑</el-button>
|
||||
<el-button link :type="row.enabled ? 'warning' : 'success'" @click="togglePromptTemplate(row)">
|
||||
{{ row.enabled ? '停用' : '启用' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<el-form label-position="top" class="prompt-form">
|
||||
<div class="prompt-form-head">
|
||||
<strong>{{ promptFormMode }}</strong>
|
||||
<el-button text type="primary" @click="resetPromptTemplateForm">新建模板</el-button>
|
||||
</div>
|
||||
<el-row :gutter="10">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="Code">
|
||||
<el-input v-model="promptForm.code" placeholder="mentor_rewrite" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="名称">
|
||||
<el-input v-model="promptForm.name" placeholder="导师改写" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="10">
|
||||
<el-col :span="8">
|
||||
<el-form-item label="分类">
|
||||
<el-select v-model="promptForm.category" class="full-control" filterable allow-create>
|
||||
<el-option v-for="category in promptCategoryOptions" :key="category" :label="category" :value="category" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="输出格式">
|
||||
<el-input v-model="promptForm.outputFormat" placeholder="json_card / text" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="版本">
|
||||
<el-input v-model="promptForm.version" placeholder="v1" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="系统提示词">
|
||||
<el-input v-model="promptForm.systemPrompt" type="textarea" :rows="3" resize="none" />
|
||||
</el-form-item>
|
||||
<el-form-item label="模板正文">
|
||||
<el-input v-model="promptForm.template" type="textarea" :rows="5" resize="none" />
|
||||
</el-form-item>
|
||||
<el-row :gutter="10">
|
||||
<el-col :span="16">
|
||||
<el-form-item label="变量">
|
||||
<el-input v-model="promptForm.variables" placeholder="scenarioName,goal,traineeText 或 JSON" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="启用状态">
|
||||
<el-switch v-model="promptForm.enabled" active-text="启用" inactive-text="停用" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="说明">
|
||||
<el-input v-model="promptForm.description" type="textarea" :rows="2" resize="none" />
|
||||
</el-form-item>
|
||||
<div class="action-row prompt-actions">
|
||||
<el-button @click="loadPromptTemplates">刷新</el-button>
|
||||
<el-button type="danger" :loading="promptSaving" @click="savePromptTemplate">保存模板</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup name="TrainPractice" lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { Microphone, Refresh, Tickets, TrendCharts, UserFilled } from '@element-plus/icons-vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { finishPracticeSession, startPracticeSession, submitPracticeTurn } from '@/api/aihr/practice';
|
||||
import FileSaver from 'file-saver';
|
||||
import {
|
||||
calibratePracticeSession,
|
||||
createPromptTemplate,
|
||||
exportPracticePilotCsv,
|
||||
finishPracticeSession,
|
||||
getPracticePrepCard,
|
||||
listPromptTemplates,
|
||||
listPracticeScenarios,
|
||||
startPracticeSession,
|
||||
submitPracticeTurn,
|
||||
updatePromptTemplate,
|
||||
type PromptTemplateRequest,
|
||||
type PromptTemplateResponse,
|
||||
type PracticePrepCardResponse,
|
||||
type PracticeScenarioResponse
|
||||
} from '@/api/aihr/practice';
|
||||
import { searchKnowledgeSop, type SopSearchResponse } from '@/api/aihr/sop';
|
||||
|
||||
type ChatRole = 'customer' | 'trainee' | 'coach';
|
||||
|
||||
@@ -175,6 +347,7 @@ type PracticeRound = {
|
||||
type ScenarioSeed = {
|
||||
id: string;
|
||||
name: string;
|
||||
position?: string;
|
||||
trainee: string;
|
||||
customer: string;
|
||||
project: string;
|
||||
@@ -193,9 +366,13 @@ type ScenarioSeed = {
|
||||
summary: string;
|
||||
};
|
||||
|
||||
type PromptTemplateForm = PromptTemplateRequest & {
|
||||
id: number | null;
|
||||
};
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const scenarios: ScenarioSeed[] = [
|
||||
const fallbackScenarios: ScenarioSeed[] = [
|
||||
{
|
||||
id: 'complaint-water',
|
||||
name: '客服应对投诉',
|
||||
@@ -268,7 +445,8 @@ const baseRecords = [
|
||||
{ time: '07-02 08:47', trainee: '王五', scene: '突发事件处理', score: 72, status: '进行中', summary: '多方协调表达不够清晰,建议继续一轮。' }
|
||||
];
|
||||
|
||||
const scenarioId = ref(scenarios[0].id);
|
||||
const scenarios = ref<ScenarioSeed[]>([...fallbackScenarios]);
|
||||
const scenarioId = ref(fallbackScenarios[0].id);
|
||||
const started = ref(false);
|
||||
const finished = ref(false);
|
||||
const scored = ref(false);
|
||||
@@ -282,12 +460,43 @@ const apiTotal = ref<number | null>(null);
|
||||
const apiRewrite = ref('');
|
||||
const apiSummary = ref('');
|
||||
const apiTrust = ref<number | null>(null);
|
||||
const apiEmotion = ref<number | null>(null);
|
||||
const prepCard = ref<PracticePrepCardResponse | null>(null);
|
||||
const prepLoading = ref(false);
|
||||
const sopHelp = ref<SopSearchResponse | null>(null);
|
||||
const sopHelpLoading = ref(false);
|
||||
const sopHelpMessage = ref('');
|
||||
const conversation = ref<ChatTurn[]>([]);
|
||||
const records = ref([...baseRecords]);
|
||||
const promptTemplates = ref<PromptTemplateResponse[]>([]);
|
||||
const promptLoading = ref(false);
|
||||
const promptSaving = ref(false);
|
||||
const calibrationScore = ref(0);
|
||||
const calibrationReason = ref('管理端人工校准');
|
||||
const calibrationSaving = ref(false);
|
||||
const calibrationMessage = ref('');
|
||||
const exportingPilot = ref(false);
|
||||
const promptCategoryOptions = ['practice', 'knowledge', 'supervision'];
|
||||
const defaultPromptForm = (): PromptTemplateForm => ({
|
||||
id: null,
|
||||
code: 'mentor_rewrite',
|
||||
name: '导师改写',
|
||||
category: 'practice',
|
||||
outputFormat: 'text',
|
||||
systemPrompt: '',
|
||||
template: '',
|
||||
variables: 'scenarioName,goal,traineeText',
|
||||
version: 'v1',
|
||||
enabled: true,
|
||||
description: ''
|
||||
});
|
||||
const promptForm = ref<PromptTemplateForm>(defaultPromptForm());
|
||||
|
||||
const selectedScenario = computed(() => scenarios.find((scenario) => scenario.id === scenarioId.value) ?? scenarios[0]);
|
||||
const selectedScenario = computed(() => scenarios.value.find((scenario) => scenario.id === scenarioId.value) ?? scenarios.value[0] ?? fallbackScenarios[0]);
|
||||
const promptFormMode = computed(() => (promptForm.value.id ? '编辑模板' : '新增模板'));
|
||||
const completedRounds = computed(() => conversation.value.filter((turn) => turn.role === 'trainee').length);
|
||||
const canScore = computed(() => finished.value && !scored.value);
|
||||
const canCalibrate = computed(() => scored.value && apiMode.value === 'api' && Boolean(sessionId.value));
|
||||
const displayScores = computed(() => apiScores.value ?? selectedScenario.value.scores);
|
||||
const displayTotal = computed(() => apiTotal.value ?? selectedScenario.value.total);
|
||||
const displayRewrite = computed(() => apiRewrite.value || selectedScenario.value.rewrite);
|
||||
@@ -304,6 +513,15 @@ const trustScore = computed(() => {
|
||||
}
|
||||
return selectedScenario.value.trustStart + completedRounds.value * 7;
|
||||
});
|
||||
const emotionScore = computed(() => {
|
||||
if (apiEmotion.value !== null) {
|
||||
return apiEmotion.value;
|
||||
}
|
||||
if (!started.value) {
|
||||
return 70;
|
||||
}
|
||||
return Math.max(45, 76 - completedRounds.value * 8);
|
||||
});
|
||||
const apiPill = computed(() => {
|
||||
if (apiMode.value === 'api') {
|
||||
return { text: '真实 API', className: 'pill-info' };
|
||||
@@ -341,6 +559,67 @@ const scenarioProfile = computed(() => [
|
||||
{ label: '学员', value: selectedScenario.value.trainee }
|
||||
]);
|
||||
|
||||
const mapApiScenario = (item: PracticeScenarioResponse): ScenarioSeed => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
position: item.position,
|
||||
trainee: '张三',
|
||||
customer: item.customerPersona,
|
||||
project: item.projectType || item.scenarioType,
|
||||
strategy: item.strategy,
|
||||
goal: item.goal || item.successCriteria,
|
||||
trustStart: Math.max(50, 70 - item.difficulty * 4),
|
||||
trustEnd: 80 + Math.min(item.difficulty, 3),
|
||||
rounds: [
|
||||
{
|
||||
customer: item.openingText || item.explicitNeed,
|
||||
sampleReply: '我先确认您的诉求和现场情况,再给您明确的责任人和首次反馈时间。',
|
||||
coachHint: item.successCriteria || '补上责任人、处理节点和回访方式。'
|
||||
},
|
||||
{
|
||||
customer: '那接下来谁负责、什么时候给我反馈?',
|
||||
sampleReply: '我会把责任人、处理节点和回访时间同步给您,过程有变化也会及时说明。',
|
||||
coachHint: '说明闭环节点,不要做未经确认的承诺。'
|
||||
}
|
||||
],
|
||||
scores:
|
||||
item.rubric?.dimensions?.map((dimension) => ({
|
||||
label: dimension.name,
|
||||
value: Math.round(dimension.weight * 100),
|
||||
note: dimension.description
|
||||
})) || fallbackScenarios[0].scores,
|
||||
total: 82,
|
||||
rewrite: '建议先承接情绪,再说明责任人、处理节点和反馈方式。',
|
||||
summary: item.successCriteria || '能按SOP给出明确闭环。'
|
||||
});
|
||||
|
||||
const loadPrepCard = async () => {
|
||||
prepLoading.value = true;
|
||||
try {
|
||||
const { data } = await getPracticePrepCard(scenarioId.value);
|
||||
prepCard.value = data;
|
||||
} catch {
|
||||
prepCard.value = null;
|
||||
} finally {
|
||||
prepLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadScenarioOptions = async () => {
|
||||
try {
|
||||
const { data } = await listPracticeScenarios({ position: '生活顾问' });
|
||||
if (data?.length) {
|
||||
scenarios.value = data.map(mapApiScenario);
|
||||
if (!scenarios.value.some((scenario) => scenario.id === scenarioId.value)) {
|
||||
scenarioId.value = scenarios.value[0].id;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
scenarios.value = [...fallbackScenarios];
|
||||
}
|
||||
await loadPrepCard();
|
||||
};
|
||||
|
||||
const appendTurn = (role: ChatRole, text: string) => {
|
||||
const nameMap = { customer: 'AI 业主', trainee: '学员', coach: 'AI 教练' };
|
||||
conversation.value.push({ id: `${role}-${conversation.value.length}`, role, name: nameMap[role], text });
|
||||
@@ -360,10 +639,21 @@ const resetPractice = () => {
|
||||
apiRewrite.value = '';
|
||||
apiSummary.value = '';
|
||||
apiTrust.value = null;
|
||||
apiEmotion.value = null;
|
||||
sopHelp.value = null;
|
||||
sopHelpMessage.value = '';
|
||||
calibrationScore.value = 0;
|
||||
calibrationReason.value = '管理端人工校准';
|
||||
calibrationMessage.value = '';
|
||||
conversation.value = [];
|
||||
records.value = [...baseRecords];
|
||||
};
|
||||
|
||||
const handleScenarioChange = () => {
|
||||
resetPractice();
|
||||
void loadPrepCard();
|
||||
};
|
||||
|
||||
const startPractice = async () => {
|
||||
resetPractice();
|
||||
const fallbackCustomer = selectedScenario.value.rounds[0].customer;
|
||||
@@ -376,6 +666,7 @@ const startPractice = async () => {
|
||||
sessionId.value = data.sessionId;
|
||||
apiMode.value = 'api';
|
||||
apiTrust.value = data.trust;
|
||||
apiEmotion.value = data.emotion;
|
||||
appendTurn('customer', data.customerText || fallbackCustomer);
|
||||
ElMessage.success(`已通过后端 API 进入 ${selectedScenario.value.name} 对练`);
|
||||
} catch {
|
||||
@@ -408,8 +699,9 @@ const continueRound = async () => {
|
||||
roundIndex: activeRound.value,
|
||||
traineeText
|
||||
});
|
||||
appendTurn('coach', data.coachHint || round.coachHint);
|
||||
appendTurn('coach', data.redFlag ? data.coachHint || '红线纠偏:先承接情绪,再给责任人与反馈节点。' : data.coachHint || round.coachHint);
|
||||
apiTrust.value = data.trust;
|
||||
apiEmotion.value = data.emotion;
|
||||
activeRound.value = Math.min(data.roundIndex ?? activeRound.value + 1, selectedScenario.value.rounds.length);
|
||||
if (data.finished || activeRound.value >= selectedScenario.value.rounds.length) {
|
||||
finished.value = true;
|
||||
@@ -434,6 +726,29 @@ const continueRound = async () => {
|
||||
appendTurn('customer', selectedScenario.value.rounds[activeRound.value].customer);
|
||||
};
|
||||
|
||||
const askSopHelp = async () => {
|
||||
sopHelpLoading.value = true;
|
||||
sopHelpMessage.value = '';
|
||||
sopHelp.value = null;
|
||||
try {
|
||||
const { data } = await searchKnowledgeSop({
|
||||
queryText: `${selectedScenario.value.name} ${selectedScenario.value.goal}`,
|
||||
category: 'sop',
|
||||
position: selectedScenario.value.position || '生活顾问',
|
||||
source: 'practice_help',
|
||||
limit: 3
|
||||
});
|
||||
if (!data?.answer) {
|
||||
throw new Error('SOP 未返回可用答案');
|
||||
}
|
||||
sopHelp.value = data;
|
||||
} catch (error) {
|
||||
sopHelpMessage.value = error instanceof Error ? error.message : 'SOP 求助失败,请稍后重试';
|
||||
} finally {
|
||||
sopHelpLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const finishScore = async () => {
|
||||
let nextRecord = {
|
||||
time: '07-02 10:42',
|
||||
@@ -470,11 +785,167 @@ const finishScore = async () => {
|
||||
}
|
||||
|
||||
scored.value = true;
|
||||
calibrationScore.value = Math.max(0, Math.min(100, Math.round(Number(displayTotal.value) || 0)));
|
||||
records.value = [nextRecord, ...baseRecords];
|
||||
if (apiMode.value !== 'api') {
|
||||
ElMessage.success('考官评分已生成,并写入本地对练记录');
|
||||
}
|
||||
};
|
||||
|
||||
const saveCalibration = async () => {
|
||||
if (!canCalibrate.value) {
|
||||
ElMessage.warning('请先完成一次真实 API 对练评分');
|
||||
return;
|
||||
}
|
||||
calibrationSaving.value = true;
|
||||
try {
|
||||
const { data } = await calibratePracticeSession(sessionId.value, {
|
||||
reviewer: '管理端校准',
|
||||
correctedTotal: calibrationScore.value,
|
||||
reason: calibrationReason.value.trim() || '管理端人工校准'
|
||||
});
|
||||
calibrationMessage.value = `已记录:AI ${data.originalScore} / 人工 ${data.correctedScore}`;
|
||||
ElMessage.success('校准样本已记录');
|
||||
} catch {
|
||||
ElMessage.error('校准样本记录失败,请检查权限或会话状态');
|
||||
} finally {
|
||||
calibrationSaving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const exportPilotCsv = async () => {
|
||||
exportingPilot.value = true;
|
||||
try {
|
||||
const data = await exportPracticePilotCsv();
|
||||
FileSaver.saveAs(new Blob([data], { type: 'text/csv;charset=utf-8' }), 'aihr-practice-pilot.csv');
|
||||
ElMessage.success('试点数据 CSV 已导出');
|
||||
} catch {
|
||||
ElMessage.error('试点数据导出失败,请检查权限');
|
||||
} finally {
|
||||
exportingPilot.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const promptTemplateToForm = (template: PromptTemplateResponse): PromptTemplateForm => ({
|
||||
id: template.id,
|
||||
code: template.code || '',
|
||||
name: template.name || '',
|
||||
category: template.category || 'practice',
|
||||
outputFormat: template.outputFormat || 'text',
|
||||
systemPrompt: template.systemPrompt || '',
|
||||
template: template.template || '',
|
||||
variables: template.variables || '',
|
||||
version: template.version || 'v1',
|
||||
enabled: Boolean(template.enabled),
|
||||
description: template.description || ''
|
||||
});
|
||||
|
||||
const promptFormToRequest = (form: PromptTemplateForm): PromptTemplateRequest => ({
|
||||
code: form.code.trim(),
|
||||
name: form.name.trim(),
|
||||
category: form.category.trim() || 'practice',
|
||||
outputFormat: form.outputFormat.trim() || 'text',
|
||||
systemPrompt: form.systemPrompt.trim(),
|
||||
template: form.template.trim(),
|
||||
variables: form.variables.trim(),
|
||||
version: form.version.trim() || 'v1',
|
||||
enabled: form.enabled,
|
||||
description: form.description.trim()
|
||||
});
|
||||
|
||||
const validatePromptVariables = (variables: string) => {
|
||||
const value = variables.trim();
|
||||
if (!value || !/^[{[]/.test(value)) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
JSON.parse(value);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const validatePromptForm = () => {
|
||||
if (!promptForm.value.code.trim() || !promptForm.value.name.trim()) {
|
||||
ElMessage.warning('请填写模板 Code 和名称');
|
||||
return false;
|
||||
}
|
||||
if (!promptForm.value.template.trim()) {
|
||||
ElMessage.warning('请填写模板正文');
|
||||
return false;
|
||||
}
|
||||
if (!validatePromptVariables(promptForm.value.variables)) {
|
||||
ElMessage.warning('变量使用 JSON 时必须是合法 JSON;逗号分隔变量可直接填写');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const loadPromptTemplates = async () => {
|
||||
promptLoading.value = true;
|
||||
try {
|
||||
const { data } = await listPromptTemplates();
|
||||
promptTemplates.value = data || [];
|
||||
} catch {
|
||||
promptTemplates.value = [];
|
||||
ElMessage.warning('提示词模板加载失败');
|
||||
} finally {
|
||||
promptLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const editPromptTemplate = (template: PromptTemplateResponse) => {
|
||||
promptForm.value = promptTemplateToForm(template);
|
||||
};
|
||||
|
||||
const resetPromptTemplateForm = () => {
|
||||
promptForm.value = defaultPromptForm();
|
||||
};
|
||||
|
||||
const savePromptTemplate = async () => {
|
||||
if (!validatePromptForm()) {
|
||||
return;
|
||||
}
|
||||
promptSaving.value = true;
|
||||
try {
|
||||
const requestData = promptFormToRequest(promptForm.value);
|
||||
if (promptForm.value.id) {
|
||||
const { data } = await updatePromptTemplate(promptForm.value.id, requestData);
|
||||
promptForm.value = promptTemplateToForm(data);
|
||||
ElMessage.success('提示词模板已更新');
|
||||
} else {
|
||||
const { data } = await createPromptTemplate(requestData);
|
||||
promptForm.value = promptTemplateToForm(data);
|
||||
ElMessage.success('提示词模板已创建');
|
||||
}
|
||||
await loadPromptTemplates();
|
||||
} catch {
|
||||
ElMessage.error('提示词模板保存失败,请检查权限或字段唯一性');
|
||||
} finally {
|
||||
promptSaving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const togglePromptTemplate = async (template: PromptTemplateResponse) => {
|
||||
promptSaving.value = true;
|
||||
try {
|
||||
const nextForm = promptTemplateToForm(template);
|
||||
nextForm.enabled = !template.enabled;
|
||||
await updatePromptTemplate(template.id, promptFormToRequest(nextForm));
|
||||
await loadPromptTemplates();
|
||||
ElMessage.success(template.enabled ? '提示词模板已停用' : '提示词模板已启用');
|
||||
} catch {
|
||||
ElMessage.error('提示词模板状态更新失败');
|
||||
} finally {
|
||||
promptSaving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
void loadScenarioOptions();
|
||||
void loadPromptTemplates();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -613,10 +1084,14 @@ const finishScore = async () => {
|
||||
|
||||
.select-card,
|
||||
.field-item,
|
||||
.rubric-card,
|
||||
.reply-box,
|
||||
.coach-card,
|
||||
.calibration-card,
|
||||
.trust-card,
|
||||
.goal-card {
|
||||
.goal-card,
|
||||
.prep-card,
|
||||
.help-card {
|
||||
padding: 12px;
|
||||
border: 1px solid #e5eaf3;
|
||||
border-radius: 8px;
|
||||
@@ -657,8 +1132,47 @@ const finishScore = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
.rubric-card {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.rubric-head,
|
||||
.rubric-card p {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 4px 10px;
|
||||
align-items: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.rubric-head small,
|
||||
.rubric-card p small {
|
||||
grid-column: 1 / -1;
|
||||
color: #7a8597;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.rubric-card p {
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid #e5eaf3;
|
||||
|
||||
span {
|
||||
color: #22314a;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
b {
|
||||
color: #d70f20;
|
||||
}
|
||||
}
|
||||
|
||||
.trust-card,
|
||||
.goal-card {
|
||||
.goal-card,
|
||||
.prep-card,
|
||||
.help-card {
|
||||
margin-top: 12px;
|
||||
|
||||
p {
|
||||
@@ -666,6 +1180,13 @@ const finishScore = async () => {
|
||||
color: #46566e;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
small {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
color: #7a8597;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.trust-card div:first-child {
|
||||
@@ -692,6 +1213,10 @@ const finishScore = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
.emotion-card .meter i {
|
||||
background: linear-gradient(90deg, #31b367, #ffb020, #f65f61);
|
||||
}
|
||||
|
||||
.chat-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
@@ -818,6 +1343,23 @@ const finishScore = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
.calibration-card {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.calibration-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
|
||||
small {
|
||||
color: #647084;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -857,10 +1399,65 @@ const finishScore = async () => {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.prompt-panel {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.prompt-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(520px, 1.2fr) minmax(360px, 0.8fr);
|
||||
gap: 14px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.prompt-list {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.prompt-form {
|
||||
padding: 14px;
|
||||
border: 1px solid #e5eaf3;
|
||||
border-radius: 8px;
|
||||
background: #f9fbfe;
|
||||
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
:deep(.el-textarea__inner) {
|
||||
line-height: 1.65;
|
||||
}
|
||||
}
|
||||
|
||||
.prompt-form-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
strong {
|
||||
color: #22314a;
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.full-control {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.prompt-actions {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
@media (max-width: 1360px) {
|
||||
.practice-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.prompt-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
@@ -877,6 +1474,15 @@ const finishScore = async () => {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.prompt-form {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.prompt-form :deep(.el-col) {
|
||||
max-width: 100%;
|
||||
flex: 0 0 100%;
|
||||
}
|
||||
|
||||
.score-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user