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:
2026-07-03 23:45:20 +08:00
parent 4fa23617a4
commit 77aa992872
18 changed files with 1475 additions and 389 deletions
+79 -100
View File
@@ -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;