feat(practice): server-owned realtime personas with knowledge tool calling
- AihrRealtimePersonaRegistry whitelist: owner-calm (default), owner-impatient, digital-mentor (search_knowledge); persona binds default voice - /realtime/session assembles session.update server-side (instructions/tools); browser forwards payload verbatim; Redis session record 30min with sliding renewal - /realtime/tools/invoke validates login identity + Redis session ownership + persona tool whitelist, then read-only searchAuthorized; per-account rate limits - source=realtime skips sop review and knowledge-gap persistence (Beta no-persist) - frontend: persona cards replace 56-voice picker; serialized tool-call chain with soft-landing fallback
This commit is contained in:
@@ -1,71 +1,73 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
import vm from 'node:vm';
|
||||
import ts from 'typescript';
|
||||
|
||||
const source = (path) => readFile(new URL(path, import.meta.url), 'utf8');
|
||||
|
||||
const realtimeVoiceRuntime = async () => {
|
||||
const service = await source('../src/services/realtime-practice.ts');
|
||||
const compiled = ts.transpileModule(service, {
|
||||
compilerOptions: {
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
target: ts.ScriptTarget.ES2020
|
||||
}
|
||||
});
|
||||
const runtimeModule = { exports: {} };
|
||||
vm.runInNewContext(compiled.outputText, {
|
||||
Date,
|
||||
exports: runtimeModule.exports,
|
||||
module: runtimeModule,
|
||||
require: (specifier) => {
|
||||
if (specifier === './api') return { apiRequest: async () => ({}) };
|
||||
if (specifier === './speech-capture') return { isNativeAppRuntime: () => false };
|
||||
throw new Error(`Unexpected module: ${specifier}`);
|
||||
}
|
||||
});
|
||||
return runtimeModule.exports;
|
||||
};
|
||||
|
||||
test('实时陪练通过受认证 SDP 代理接入,前端不携带供应商密钥', async () => {
|
||||
const [service, page, backendService] = await Promise.all([
|
||||
test('实时陪练通过受认证代理接入,前端不携带供应商密钥、不自组会话配置', async () => {
|
||||
const [service, page, backendService, backendSessionService, backendPersonaRegistry] = await Promise.all([
|
||||
source('../src/services/realtime-practice.ts'),
|
||||
source('../src/pages/user/practice/index.vue'),
|
||||
source('../../backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrRealtimePracticeService.java')
|
||||
source('../../backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrRealtimePracticeService.java'),
|
||||
source('../../backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrRealtimeSessionService.java'),
|
||||
source('../../backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrRealtimePersonaRegistry.java')
|
||||
]);
|
||||
|
||||
// 传输与事件协议
|
||||
assert.match(service, /\/api\/train\/practice\/realtime\/sdp/);
|
||||
assert.match(service, /semantic_vad/);
|
||||
assert.match(service, /conversation\.item\.input_audio_transcription\.completed/);
|
||||
assert.match(service, /response\.audio_transcript\.done/);
|
||||
assert.match(service, /session\.created/);
|
||||
assert.match(service, /session\.updated/);
|
||||
assert.match(service, /REALTIME_PRACTICE_VOICES/);
|
||||
assert.match(service, /createRealtimePracticeSessionUpdate/);
|
||||
assert.match(service, /activeAttempt/);
|
||||
assert.match(service, /resumeAudio/);
|
||||
|
||||
// 方案B 架构:session.update 由服务端组装,浏览器只原样转发;instructions/音色不下放前端
|
||||
assert.match(service, /\/api\/train\/practice\/realtime\/session/);
|
||||
assert.match(service, /sendSessionUpdate/);
|
||||
assert.doesNotMatch(service, /instructions/);
|
||||
assert.doesNotMatch(service, /REALTIME_PRACTICE_VOICES|createRealtimePracticeSessionUpdate|normalizeRealtimePracticeVoice/);
|
||||
assert.doesNotMatch(service, /semantic_vad/);
|
||||
assert.doesNotMatch(service, /DASHSCOPE_API_KEY|Authorization:\s*Bearer/);
|
||||
|
||||
// function calling 工具桥:事件分支、串行链、软着陆兜底、回传协议
|
||||
assert.match(service, /\/api\/train\/practice\/realtime\/tools\/invoke/);
|
||||
assert.match(service, /response\.function_call_arguments\.done/);
|
||||
assert.match(service, /function_call_output/);
|
||||
assert.match(service, /response\.create/);
|
||||
assert.match(service, /toolCallChain/);
|
||||
assert.match(service, /工具暂时不可用,请直接回应员工/);
|
||||
assert.match(service, /calling-tool/);
|
||||
|
||||
// 服务端是会话配置的唯一组装点
|
||||
assert.match(backendSessionService, /session\.update/);
|
||||
assert.match(backendSessionService, /semantic_vad/);
|
||||
assert.match(backendSessionService, /TOOL_SEARCH_KNOWLEDGE/);
|
||||
assert.match(backendPersonaRegistry, /search_knowledge/);
|
||||
assert.match(backendPersonaRegistry, /owner-calm/);
|
||||
assert.match(backendPersonaRegistry, /digital-mentor/);
|
||||
assert.match(backendService, /实时陪练限流暂时不可用/);
|
||||
|
||||
// 页面:人设卡片选择 + 本地持久化;音色 picker 已删除
|
||||
assert.match(page, /实时陪练 Beta/);
|
||||
assert.match(page, /实时陪练音色/);
|
||||
assert.match(page, /<picker :range="realtimeVoiceOptions" range-key="label"/);
|
||||
assert.match(page, /aihr_realtime_practice_voice/);
|
||||
assert.match(page, /selectRealtimeVoice/);
|
||||
assert.match(page, /createRealtimePracticeController/);
|
||||
assert.match(page, /实时陪练人设/);
|
||||
assert.match(page, /aihr_realtime_practice_persona/);
|
||||
assert.match(page, /selectRealtimePersona/);
|
||||
assert.match(page, /fetchRealtimePersonas/);
|
||||
assert.match(page, /createRealtimeSession/);
|
||||
assert.match(page, /正在查知识库/);
|
||||
assert.doesNotMatch(page, /realtimeVoice|selectRealtimeVoice|aihr_realtime_practice_voice/);
|
||||
});
|
||||
|
||||
test('实时陪练音色白名单会写入会话配置,非法值回退默认音色', async () => {
|
||||
const runtime = await realtimeVoiceRuntime();
|
||||
const voices = Array.from(runtime.REALTIME_PRACTICE_VOICES);
|
||||
test('工具调用串行化与竞态防护:链式执行、attempt 双检、stop 重置', async () => {
|
||||
const service = await source('../src/services/realtime-practice.ts');
|
||||
|
||||
assert.deepEqual(voices, [
|
||||
'Tina', 'Cindy', 'Liora Mira', 'Sunnybobi', 'Raymond', 'Ethan', 'Theo Calm', 'Serena', 'Harvey', 'Maia', 'Evan', 'Qiao', 'Momo', 'Wil', 'Angel', 'Li Cassian', 'Mia', 'Joyner', 'Gold', 'Katerina', 'Ryan', 'Jennifer', 'Aiden', 'Mione', 'Sunny', 'Dylan', 'Eric', 'Peter', 'Joseph Chen', 'Marcus', 'Li', 'Kiki', 'Rocky', 'Sohee', 'Lenn', 'Ono Anna', 'Sonrisa', 'Bodega', 'Emilien', 'Andre', 'Radio Gol', 'Alek', 'Rizky', 'Roya', 'Arda', 'Hana', 'Dolce', 'Jakub', 'Griet', 'Eliška', 'Marina', 'Siiri', 'Ingrid', 'Sigga', 'Bea', 'Chloe'
|
||||
]);
|
||||
for (const voice of voices) {
|
||||
assert.equal(runtime.normalizeRealtimePracticeVoice(voice), voice);
|
||||
assert.equal(runtime.createRealtimePracticeSessionUpdate('测试', voice).session.voice, voice);
|
||||
}
|
||||
assert.equal(runtime.normalizeRealtimePracticeVoice('untrusted-voice'), 'Ethan');
|
||||
assert.equal(runtime.createRealtimePracticeSessionUpdate('测试', 'untrusted-voice').session.voice, 'Ethan');
|
||||
// 串行链必须基于上一次调用收尾,且带末端 catch 防毒化
|
||||
assert.match(service, /toolCallChain = toolCallChain\.then/);
|
||||
assert.match(service, /\.catch\(\(\) => \{/);
|
||||
// stop() 递增 attempt 使迟到的工具回调失效,并重置链
|
||||
assert.match(service, /activeAttempt \+= 1/);
|
||||
assert.match(service, /toolCallChain = Promise\.resolve\(\)/);
|
||||
// 回传前双检:attempt 仍当前 + 数据通道仍打开
|
||||
assert.match(service, /!isCurrent\(attempt\) \|\| channel\.readyState !== 'open'/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user