feat(aihr): switch interview and case flows to real-model-first pipeline
AI 面试 /start 动态出题、/finish 按真实回答结构化评分(未配模型用本地 Rubric 估分);案例沉淀 /upload 改 multipart 真实语音+ASR 转写, /organize 用真实 transcript 调 chat 模型整理;seed service 重构为 AihrInterviewService/AihrCaseService;前端面试/案例/对练页与移动端、 demo-check、验收文档同步真实链路口径。
This commit is contained in:
@@ -1,15 +1,16 @@
|
||||
<template>
|
||||
<div class="cases-page">
|
||||
<input ref="audioInput" class="hidden-input" type="file" accept="audio/*,.mp3,.wav,.m4a,.webm,.ogg" @change="handleAudioPicked" />
|
||||
<section class="page-head">
|
||||
<div>
|
||||
<h1>案例语音整理</h1>
|
||||
<p>语音上传或选择样例录音,完成 ASR 转写、AI 结构化整理、送审入库。</p>
|
||||
<p>上传真实语音,完成 ASR 转写、AI 结构化整理、送审入库。</p>
|
||||
</div>
|
||||
<div class="head-actions">
|
||||
<span class="status-pill" :class="apiPill.type">{{ apiPill.label }}</span>
|
||||
<span class="status-pill" :class="flowStatus.type">{{ flowStatus.label }}</span>
|
||||
<el-button @click="router.push('/index')">返回首页</el-button>
|
||||
<el-button type="danger" @click="selectSample">选择样例录音</el-button>
|
||||
<el-button type="danger" @click="audioInput?.click()">上传语音</el-button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -19,18 +20,18 @@
|
||||
<el-icon><Microphone /></el-icon>
|
||||
<h2>上传与转写</h2>
|
||||
</div>
|
||||
<div class="upload-box" @click="selectSample">
|
||||
<div class="upload-box" @click="audioInput?.click()">
|
||||
<el-icon><FolderChecked /></el-icon>
|
||||
<strong>{{ sampleSelected ? displayFileName : '拖入语音 / 选择样例录音' }}</strong>
|
||||
<span>{{ sampleSelected ? '已加载样例录音并完成 ASR 转写。' : 'Demo 暂用样例转写文本,ASR 接入后替换。' }}</span>
|
||||
<strong>{{ audioUploaded ? displayFileName : '选择语音文件' }}</strong>
|
||||
<span>{{ audioUploaded ? '真实语音已完成 ASR 转写。' : '支持 mp3/wav/m4a/webm/ogg,需先配置 ASR 模型。' }}</span>
|
||||
</div>
|
||||
<div v-if="sampleSelected" class="transcript-card">
|
||||
<div v-if="audioUploaded" class="transcript-card">
|
||||
<span>ASR 转写结果</span>
|
||||
<p>{{ displayTranscript }}</p>
|
||||
</div>
|
||||
<div v-else class="empty-state">先选择样例录音,页面会展示转写结果和后续整理动作。</div>
|
||||
<div v-else class="empty-state">先上传真实语音,页面会展示转写结果和后续整理动作。</div>
|
||||
<div class="action-row">
|
||||
<el-button type="danger" @click="organizeCase">AI 整理</el-button>
|
||||
<el-button type="danger" :disabled="!audioUploaded" @click="organizeCase">AI 整理</el-button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -105,74 +106,59 @@ import { curateKnowledgeCase, organizeKnowledgeCase, uploadCaseAudio, type CaseR
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const seedCase = {
|
||||
fileName: '车库积水投诉_20260702.wav',
|
||||
transcript: '业主反映地下车库长期积水,担心车辆受损。管家先确认车位号与积水时间,安排工程人员排查排水沟,并在业主群同步处理进度。',
|
||||
tags: [
|
||||
{ text: '投诉处理', type: 'pill-success' },
|
||||
{ text: '车库', type: 'pill-primary' },
|
||||
{ text: '高情绪', type: 'pill-warning' }
|
||||
],
|
||||
summary: [
|
||||
{ label: '背景', text: '雨后车库积水,业主担心车辆与安全问题。' },
|
||||
{ label: '处理', text: '确认位置、派工排查、群内同步、次日复盘。' },
|
||||
{ label: '结果', text: '排水沟堵塞已清理,业主确认问题解决。' },
|
||||
{ label: '亮点', text: '响应快、过程透明、主动同步进度。' }
|
||||
]
|
||||
} as const;
|
||||
|
||||
const sampleSelected = ref(false);
|
||||
const audioInput = ref<HTMLInputElement>();
|
||||
const audioUploaded = ref(false);
|
||||
const organized = ref(false);
|
||||
const reviewed = ref(false);
|
||||
const stored = ref(false);
|
||||
const sampleVisible = ref(false);
|
||||
const caseId = ref('');
|
||||
const apiMode = ref<'seed' | 'api' | 'fallback'>('seed');
|
||||
const apiMode = ref<'idle' | 'api' | 'error'>('idle');
|
||||
const apiFileName = ref('');
|
||||
const apiTranscript = ref('');
|
||||
const apiSummary = ref<CaseSummaryItem[] | null>(null);
|
||||
const apiTags = ref<CaseTag[] | null>(null);
|
||||
const apiSummary = ref<CaseSummaryItem[]>([]);
|
||||
const apiTags = ref<CaseTag[]>([]);
|
||||
const apiRecord = ref<CaseRecord | null>(null);
|
||||
const apiSampleHint = ref('');
|
||||
|
||||
const displayFileName = computed(() => apiFileName.value || seedCase.fileName);
|
||||
const displayTranscript = computed(() => apiTranscript.value || seedCase.transcript);
|
||||
const displaySummary = computed(() => apiSummary.value ?? seedCase.summary);
|
||||
const displayTags = computed(() => apiTags.value ?? seedCase.tags);
|
||||
const displayFileName = computed(() => apiFileName.value || '未选择文件');
|
||||
const displayTranscript = computed(() => apiTranscript.value || '暂无转写结果');
|
||||
const displaySummary = computed(() => apiSummary.value);
|
||||
const displayTags = computed(() => apiTags.value);
|
||||
const summary = computed(() => (organized.value ? displaySummary.value : []));
|
||||
|
||||
const apiPill = computed(() => {
|
||||
if (apiMode.value === 'api') return { label: '后端 API seed', type: 'pill-primary' };
|
||||
if (apiMode.value === 'fallback') return { label: 'API 失败回退', type: 'pill-warning' };
|
||||
return { label: '本地 seed 演示', type: 'pill-primary' };
|
||||
if (apiMode.value === 'api') return { label: '真实 ASR/API', type: 'pill-primary' };
|
||||
if (apiMode.value === 'error') return { label: '接口失败', type: 'pill-warning' };
|
||||
return { label: '待上传语音', type: 'pill-primary' };
|
||||
});
|
||||
|
||||
const flowStatus = computed(() => {
|
||||
if (stored.value) return { label: '已完成闭环', type: 'pill-success' };
|
||||
if (reviewed.value) return { label: '已送审待入库', type: 'pill-primary' };
|
||||
if (organized.value) return { label: '已生成案例稿', type: 'pill-warning' };
|
||||
if (sampleSelected.value) return { label: '已完成 ASR', type: 'pill-primary' };
|
||||
return { label: '样片兜底', type: 'pill-warning' };
|
||||
if (audioUploaded.value) return { label: '已完成 ASR', type: 'pill-primary' };
|
||||
return { label: '等待语音', type: 'pill-warning' };
|
||||
});
|
||||
|
||||
const pipeline = computed(() => [
|
||||
{
|
||||
name: '语音上传',
|
||||
count: sampleSelected.value ? '1 条' : '0 条',
|
||||
note: sampleSelected.value ? '样例录音已选择' : '等待上传',
|
||||
state: sampleSelected.value ? 'done' : 'todo'
|
||||
count: audioUploaded.value ? '1 条' : '0 条',
|
||||
note: audioUploaded.value ? '真实音频已上传' : '等待上传',
|
||||
state: audioUploaded.value ? 'done' : 'todo'
|
||||
},
|
||||
{
|
||||
name: 'ASR 转写',
|
||||
count: sampleSelected.value ? '1 条' : '0 条',
|
||||
note: sampleSelected.value ? '本地 seed 转写完成' : '等待语音',
|
||||
state: sampleSelected.value ? 'done' : 'todo'
|
||||
count: audioUploaded.value ? '1 条' : '0 条',
|
||||
note: audioUploaded.value ? '真实 ASR 已转写' : '等待语音',
|
||||
state: audioUploaded.value ? 'done' : 'todo'
|
||||
},
|
||||
{
|
||||
name: 'AI 整理',
|
||||
count: organized.value ? '1 条' : '0 条',
|
||||
note: organized.value ? '结构化案例稿已生成' : '等待整理',
|
||||
state: organized.value ? 'done' : sampleSelected.value ? 'active' : 'todo'
|
||||
state: organized.value ? 'done' : audioUploaded.value ? 'active' : 'todo'
|
||||
},
|
||||
{
|
||||
name: '审核入库',
|
||||
@@ -215,68 +201,64 @@ const baseRecords = [
|
||||
}
|
||||
];
|
||||
|
||||
const seedRecord = {
|
||||
title: '车库积水投诉处理 · seed入库',
|
||||
type: '投诉处理',
|
||||
status: '已入库',
|
||||
time: '07-02 10:50',
|
||||
owner: '培训组',
|
||||
summary: 'ASR 转写后自动整理为案例稿,完成送审与入库,可用于三角色对练复用。'
|
||||
};
|
||||
|
||||
const records = ref([...baseRecords]);
|
||||
|
||||
async function selectSample() {
|
||||
sampleSelected.value = true;
|
||||
async function handleAudioPicked(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
input.value = '';
|
||||
if (!file) return;
|
||||
await uploadAudio(file);
|
||||
}
|
||||
|
||||
async function uploadAudio(file: File) {
|
||||
audioUploaded.value = false;
|
||||
organized.value = false;
|
||||
reviewed.value = false;
|
||||
stored.value = false;
|
||||
sampleVisible.value = false;
|
||||
apiSummary.value = null;
|
||||
apiSummary.value = [];
|
||||
apiRecord.value = null;
|
||||
apiSampleHint.value = '';
|
||||
apiTranscript.value = '';
|
||||
apiTags.value = [];
|
||||
try {
|
||||
const { data } = await uploadCaseAudio({
|
||||
fileName: seedCase.fileName,
|
||||
projectExtOrgId: 'seed-project'
|
||||
});
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
form.append('projectExtOrgId', 'mobile-upload');
|
||||
const { data } = await uploadCaseAudio(form);
|
||||
caseId.value = data.caseId;
|
||||
apiMode.value = 'api';
|
||||
apiFileName.value = data.fileName;
|
||||
apiTranscript.value = data.transcript;
|
||||
apiTags.value = data.tags;
|
||||
ElMessage.success('已通过后端 API 加载样例录音并完成 ASR 转写');
|
||||
audioUploaded.value = true;
|
||||
ElMessage.success('真实语音已完成 ASR 转写');
|
||||
} catch {
|
||||
caseId.value = 'case-garage-water';
|
||||
apiMode.value = 'fallback';
|
||||
apiFileName.value = '';
|
||||
apiTranscript.value = '';
|
||||
apiTags.value = null;
|
||||
ElMessage.warning('后端案例上传 API 不可用,已回退本地 seed 转写');
|
||||
caseId.value = '';
|
||||
apiMode.value = 'error';
|
||||
apiFileName.value = file.name;
|
||||
ElMessage.error('语音转写失败,请检查 ASR 模型配置或音频格式');
|
||||
}
|
||||
}
|
||||
|
||||
async function organizeCase() {
|
||||
if (!sampleSelected.value) {
|
||||
await selectSample();
|
||||
}
|
||||
if (!audioUploaded.value || !caseId.value) return;
|
||||
if (apiMode.value === 'api') {
|
||||
try {
|
||||
const { data } = await organizeKnowledgeCase({ caseId: caseId.value });
|
||||
apiSummary.value = data.summary;
|
||||
apiTags.value = data.tags;
|
||||
organized.value = true;
|
||||
ElMessage.success('结构化案例稿已由后端 API 返回');
|
||||
ElMessage.success(data.source === 'real-llm' ? '结构化案例稿已由真实模型返回' : '结构化案例稿已按真实转写生成');
|
||||
return;
|
||||
} catch {
|
||||
apiMode.value = 'fallback';
|
||||
apiSummary.value = null;
|
||||
apiTags.value = null;
|
||||
ElMessage.warning('后端案例整理 API 不可用,已回退本地 seed 案例稿');
|
||||
apiMode.value = 'error';
|
||||
apiSummary.value = [];
|
||||
apiTags.value = [];
|
||||
ElMessage.error('后端案例整理 API 不可用');
|
||||
}
|
||||
}
|
||||
organized.value = true;
|
||||
ElMessage.success('AI 已生成结构化案例稿');
|
||||
}
|
||||
|
||||
function submitReview() {
|
||||
@@ -295,31 +277,24 @@ async function storeCase() {
|
||||
}
|
||||
reviewed.value = true;
|
||||
stored.value = true;
|
||||
if (apiMode.value === 'api') {
|
||||
try {
|
||||
const { data } = await curateKnowledgeCase({
|
||||
caseId: caseId.value,
|
||||
criteria: '可复用培训案例',
|
||||
limit: 1
|
||||
});
|
||||
apiRecord.value = data.records?.[0] ?? null;
|
||||
apiSampleHint.value = data.sampleHint;
|
||||
if (apiRecord.value && !records.value.some((item) => item.title === apiRecord.value?.title)) {
|
||||
records.value.unshift(apiRecord.value);
|
||||
}
|
||||
ElMessage.success('案例已由后端 API 入库,可用于训练复用');
|
||||
return;
|
||||
} catch {
|
||||
apiMode.value = 'fallback';
|
||||
apiRecord.value = null;
|
||||
apiSampleHint.value = '';
|
||||
ElMessage.warning('后端案例入库 API 不可用,已回退本地 seed 入库');
|
||||
try {
|
||||
const { data } = await curateKnowledgeCase({
|
||||
caseId: caseId.value,
|
||||
criteria: '可复用培训案例',
|
||||
limit: 1
|
||||
});
|
||||
apiRecord.value = data.records?.[0] ?? null;
|
||||
apiSampleHint.value = data.sampleHint;
|
||||
if (apiRecord.value && !records.value.some((item) => item.title === apiRecord.value?.title)) {
|
||||
records.value.unshift(apiRecord.value);
|
||||
}
|
||||
ElMessage.success('案例已由后端 API 入库,可用于训练复用');
|
||||
} catch {
|
||||
apiMode.value = 'error';
|
||||
apiRecord.value = null;
|
||||
apiSampleHint.value = '';
|
||||
ElMessage.error('后端案例入库 API 不可用');
|
||||
}
|
||||
if (!records.value.some((item) => item.title === seedRecord.title)) {
|
||||
records.value.unshift(seedRecord);
|
||||
}
|
||||
ElMessage.success('案例已入库,可用于训练复用');
|
||||
}
|
||||
|
||||
function viewSample() {
|
||||
@@ -336,6 +311,10 @@ function viewSample() {
|
||||
color: #162033;
|
||||
}
|
||||
|
||||
.hidden-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.page-head {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
|
||||
Reference in New Issue
Block a user