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
+36 -24
View File
@@ -3,7 +3,7 @@
<section class="page-head">
<div>
<h1>AI 面试出题</h1>
<p>岗位画像驱动出题、候选人作答、Rubric 评分、面试记录沉淀的本地 seed 演示闭环。</p>
<p>岗位画像驱动出题、候选人作答、Rubric 评分、面试记录沉淀。</p>
</div>
<div class="head-actions">
<span class="status-pill" :class="apiPill.className">{{ apiPill.text }}</span>
@@ -79,7 +79,7 @@
<div v-if="!generated" class="empty-state">
<strong>等待生成面试题</strong>
<p>点击左侧“生成题目”,系统会按当前岗位画像加载 3 道 seed 面试题。</p>
<p>点击左侧“生成题目”,系统会按当前岗位画像生成 3 道面试题。</p>
<el-button type="danger" :icon="DocumentChecked" @click="generateQuestions">生成题目</el-button>
</div>
@@ -105,21 +105,21 @@
<div class="answer-box">
<div class="answer-head">
<span>候选人回答 · {{ activeQuestion.scenario }}</span>
<el-button text type="danger" @click="fillSampleAnswer">填入样例回答</el-button>
<el-button text type="danger" @click="fillSampleAnswer">填入参考回答</el-button>
</div>
<el-input
v-model="answers[activeQuestion.id]"
type="textarea"
:rows="6"
resize="none"
placeholder="输入候选人回答,或点击“填入样例回答”快速推进演示"
placeholder="输入候选人真实回答,或点击“填入参考回答”快速推进测试"
/>
</div>
<div class="action-row question-actions">
<el-button @click="moveQuestion(-1)">上一题</el-button>
<el-button @click="moveQuestion(1)">下一题</el-button>
<el-button @click="fillAllSampleAnswers">填满 seed 回答</el-button>
<el-button @click="fillAllSampleAnswers">填满参考回答</el-button>
<el-button @click="saveDraft">保存草稿</el-button>
<el-button type="danger" :disabled="!canScore" @click="finishScore">完成评分</el-button>
</div>
@@ -225,7 +225,7 @@ const candidates: CandidateSeed[] = [
id: 'zhang-ming',
name: '张明',
project: '银城花园 / 住宅',
mode: '文本演示,语音待接入',
mode: '文本面试',
experience: '3 年客服与案场接待',
risk: '催缴冲突经验不足',
job: {
@@ -280,7 +280,7 @@ const candidates: CandidateSeed[] = [
id: 'li-na',
name: '李娜',
project: '铂悦府 / 高端住宅',
mode: '文本演示,语音待接入',
mode: '文本面试',
experience: '2 年呼叫中心经验',
risk: '现场协调经验偏弱',
job: {
@@ -362,13 +362,14 @@ const scored = ref(false);
const showRubric = ref(false);
const activeQuestionId = ref('');
const sessionId = ref('');
const apiMode = ref<'seed' | 'api' | 'fallback'>('seed');
const apiMode = ref<'idle' | 'api' | 'fallback'>('idle');
const apiQuestions = ref<InterviewQuestion[]>([]);
const apiScoreResult = ref<CandidateSeed['scoreResult'] | null>(null);
const answers = reactive<Record<string, string>>({});
const records = ref([...baseRecords]);
const selectedCandidate = computed(() => candidates.find((candidate) => candidate.id === candidateId.value) ?? candidates[0]);
const questions = computed(() => selectedCandidate.value.questions);
const questions = computed(() => (apiQuestions.value.length > 0 ? apiQuestions.value : selectedCandidate.value.questions));
const activeQuestion = computed(() => questions.value.find((question) => question.id === activeQuestionId.value) ?? questions.value[0]);
const scoreResult = computed(() => apiScoreResult.value ?? selectedCandidate.value.scoreResult);
@@ -385,12 +386,12 @@ const answeredCount = computed(() => questions.value.filter((question) => answer
const canScore = computed(() => generated.value && answeredCount.value === questions.value.length);
const apiPill = computed(() => {
if (apiMode.value === 'api') {
return { text: '后端 API seed', className: 'pill-info' };
return { text: '真实 API', className: 'pill-info' };
}
if (apiMode.value === 'fallback') {
return { text: 'API 失败回退', className: 'pill-warning' };
return { text: '本地 Rubric', className: 'pill-warning' };
}
return { text: '本地 seed 演示', className: 'pill-info' };
return { text: '待调用 API', className: 'pill-info' };
});
const flowPill = computed(() => {
if (!generated.value) {
@@ -425,7 +426,8 @@ const resetFlow = () => {
showRubric.value = false;
activeQuestionId.value = '';
sessionId.value = '';
apiMode.value = 'seed';
apiMode.value = 'idle';
apiQuestions.value = [];
apiScoreResult.value = null;
resetAnswers();
records.value = [...baseRecords];
@@ -434,23 +436,33 @@ const resetFlow = () => {
const generateQuestions = async () => {
resetAnswers();
scored.value = false;
apiQuestions.value = [];
apiScoreResult.value = null;
records.value = [...baseRecords];
try {
const { data } = await startInterview({
candidateId: selectedCandidate.value.id,
positionCode: selectedCandidate.value.job.title,
mode: 'seed'
mode: 'real'
});
sessionId.value = data.sessionId;
apiMode.value = 'api';
activeQuestionId.value = data.questions?.[0]?.questionId ?? questions.value[0].id;
ElMessage.success(`已通过后端 API 生成 ${questions.value.length} 道 seed 面试题`);
apiQuestions.value = (data.questions || []).map((question) => ({
id: question.questionId,
seq: question.seq,
text: question.questionText,
point: question.point,
scenario: question.scenario,
sampleAnswer: question.sampleAnswer
}));
activeQuestionId.value = apiQuestions.value[0]?.id ?? questions.value[0].id;
ElMessage.success(data.source === 'real-llm' ? '已由真实模型生成面试题' : '模型未配置,已使用本地 Rubric 题库');
} catch {
sessionId.value = '';
apiMode.value = 'fallback';
apiQuestions.value = [];
activeQuestionId.value = questions.value[0].id;
ElMessage.warning('后端 API 不可用,已回退本地 seed 题库');
ElMessage.warning('后端 API 不可用,已使用本地 Rubric 题库');
}
generated.value = true;
};
@@ -463,7 +475,7 @@ const fillAllSampleAnswers = () => {
questions.value.forEach((question) => {
answers[question.id] = question.sampleAnswer;
});
ElMessage.success('已填入全部 seed 样例回答');
ElMessage.success('已填入全部参考回答');
};
const moveQuestion = (offset: number) => {
@@ -485,11 +497,11 @@ const saveDraft = async () => {
questionId: activeQuestion.value.id,
answerText
});
ElMessage.success('回答已提交后端 seed API');
ElMessage.success('回答已提交后端 API');
return;
} catch {
apiMode.value = 'fallback';
ElMessage.warning('后端暂存 API 不可用,已回退本地草稿');
ElMessage.warning('后端暂存 API 不可用,已保留本地草稿');
}
}
ElMessage.success('草稿已暂存到本地演示状态');
@@ -519,14 +531,14 @@ const finishScore = async () => {
};
if (apiMode.value === 'api' && sessionId.value) {
try {
const { data } = await finishInterview({ sessionId: sessionId.value });
const { data } = await finishInterview({ sessionId: sessionId.value, answers: { ...answers } });
apiScoreResult.value = mapApiScoreResult(data);
nextRecord = data.records?.[0] ?? nextRecord;
ElMessage.success('AI 面试评分已由后端 API 返回');
ElMessage.success(data.source === 'real-llm' ? 'AI 面试评分已由真实模型返回' : 'AI 面试评分已由本地 Rubric 返回');
} catch {
apiMode.value = 'fallback';
apiScoreResult.value = null;
ElMessage.warning('后端评分 API 不可用,已回退本地 seed 评分');
ElMessage.warning('后端评分 API 不可用,已回退本地 Rubric 评分');
}
} else {
apiScoreResult.value = null;
@@ -534,7 +546,7 @@ const finishScore = async () => {
scored.value = true;
records.value = [nextRecord, ...baseRecords];
if (apiMode.value !== 'api') {
ElMessage.success('AI 面试评分已生成,并写入本地演示记录');
ElMessage.success('AI 面试评分已生成,并写入本地记录');
}
};