fix(agent): preserve broadcast consultation context

This commit is contained in:
2026-07-25 00:12:53 +08:00
parent a67444645e
commit 5f8d8f96f0
10 changed files with 48 additions and 22 deletions
@@ -1,5 +1,6 @@
package org.dromara.aihr.agent;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.BroadcastContext;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.Citation;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.Resource;
@@ -95,6 +96,7 @@ public final class AihrAgentDto {
List<SourceSummary> sourceSummary,
List<Citation> citations,
List<Resource> resources,
BroadcastContext broadcastContext,
Object data,
ActionDraft actionDraft,
Clarification clarification,
@@ -147,7 +147,7 @@ public class AihrAgentOrchestrator {
return new AgentResponse(
runId(), response.conversationId(), response.contextVersion(), plan.intent(), status, answer,
sources,
response.citations(), response.resources(), response.data(), actionDraft, null, List.of()
response.citations(), response.resources(), response.broadcastContext(), response.data(), actionDraft, null, List.of()
);
}
@@ -165,7 +165,7 @@ public class AihrAgentOrchestrator {
return new AgentResponse(
runId(), request.conversationId(), request.contextVersion(), plan.intent(), status, answer,
List.of(new SourceSummary("WEB", response.provider(), OffsetDateTime.now().toString())),
List.of(), List.of(), response, null,
List.of(), List.of(), null, response, null,
status == AgentStatus.NEEDS_INPUT
? new Clarification("是否同意查询全网?", List.of())
: null,
@@ -199,7 +199,7 @@ public class AihrAgentOrchestrator {
Clarification clarification) {
return new AgentResponse(
runId(), null, null, plan.intent(), status, answer, List.of(), List.of(), List.of(),
null, null, clarification, List.of()
null, null, null, clarification, List.of()
);
}
@@ -208,7 +208,7 @@ public class AihrAgentOrchestrator {
return new AgentResponse(
runId(), request == null ? null : request.conversationId(),
request == null ? null : request.contextVersion(), plan.intent(), status, "",
List.of(), List.of(), List.of(), null, null, null, List.of()
List.of(), List.of(), List.of(), null, null, null, null, List.of()
);
}
@@ -58,7 +58,7 @@ class AihrAgentAuditServiceTest {
return new AgentResponse("agent_run_test", "conversation_1", 1L, Intent.LIVE_MY_WORK,
AgentStatus.COMPLETED, "敏感答案",
List.of(new SourceSummary("LIVE_DATA", "我的待办", "2026-07-24T20:00:00+08:00")),
List.of(), List.of(), null, null, null, List.of());
List.of(), List.of(), null, null, null, null, List.of());
}
private static final class CapturingJdbcTemplate extends JdbcTemplate {
@@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import org.dromara.aihr.agent.AihrAgentDto.AgentRequest;
import org.dromara.aihr.agent.AihrAgentDto.AgentStatus;
import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.BroadcastContext;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryRequest;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryResponse;
import org.dromara.aihr.knowledge.service.AihrKnowledgePrincipalResolver;
@@ -64,6 +65,18 @@ class AihrAgentOrchestratorTest {
assertTrue(result.sourceSummary().isEmpty());
}
@Test
void broadcastConsultationPreservesServerVerifiedContext() {
var query = new CapturingQueryService(response("按高温作业规定执行。", null, false,
new BroadcastContext(42L, "高温作业提醒", "2026-07-24T09:00:00")));
var result = orchestrator(query).handle(new AgentRequest(
"这条消息里的高温规定是什么?", "conversation_1", 0L, null, 42L, false));
assertEquals(42L, query.lastRequest.broadcastMessageId());
assertEquals(42L, result.broadcastContext().messageId());
}
@Test
void socialAndClarificationDoNotInvokeKnowledge() {
var query = new CapturingQueryService(response("unused", null, false));
@@ -147,8 +160,14 @@ class AihrAgentOrchestratorTest {
}
private static QueryResponse response(String answer, Object data, boolean noEvidence) {
return response(answer, data, noEvidence, null);
}
private static QueryResponse response(String answer, Object data, boolean noEvidence,
BroadcastContext broadcastContext) {
return new QueryResponse("request-1", "question", answer, List.of(), List.of(), noEvidence,
"test", null, data, "conversation_1", 1L, "QA", "question", List.of());
"test", null, data, "conversation_1", 1L, "QA", "question", List.of(), null,
broadcastContext);
}
private static final class CapturingQueryService extends AihrKnowledgeQueryService {
+1 -1
View File
@@ -2991,7 +2991,7 @@ onUnload(() => {
border-radius: 999px;
background: #eef3f8;
color: #5b6b7f;
font-size: 11px;
font-size: 12px;
}
.agent-clarification {
+1 -1
View File
@@ -111,7 +111,7 @@ export const normalizeAgentResponse = (
rewrittenQuery: question,
resources: response.resources || [],
memoryCandidate,
broadcastContext: null,
broadcastContext: response.broadcastContext || null,
agentStatus: response.status,
agentIntent: response.intent,
sourceSummary: response.sourceSummary || [],
+1
View File
@@ -261,6 +261,7 @@ export interface AgentResponse {
sourceSummary: AgentSourceSummary[];
citations: KnowledgeCitation[];
resources: KnowledgeResource[];
broadcastContext?: BroadcastKnowledgeContext | null;
data?: unknown;
actionDraft?: AgentActionDraft | null;
clarification?: AgentClarification | null;
+6 -4
View File
@@ -81,17 +81,19 @@ test('问师傅在真正开始会话后收起首次使用引导,保留对话
test('消息咨询只以消息 ID 作为受控上下文,并让每轮文字或语音请求携带它', async () => {
const [page, service, types] = await Promise.all([
read('../src/pages/user/sop/index.vue'),
read('../src/services/knowledge.ts'),
read('../src/services/agent.ts'),
read('../src/types/api.ts')
]);
assert.match(page, /getBroadcastMessage\(pending\.messageId\)/);
assert.match(page, /messageId: message\.id,\s*title: message\.title,\s*publishedAt: message\.publishedAt/);
assert.match(page, /broadcastMessageId \? undefined : currentProject\.value\?\.projectCode/);
assert.match(page, /options\.voice \? 'mobile_uni_voice' : 'mobile_uni_sop',\s*broadcastMessageId/);
assert.match(page, /const agentOptions = \{[\s\S]*?broadcastMessageId,[\s\S]*?\}/);
assert.match(page, /askAgent\(text, agentOptions\)/);
assert.match(service, /broadcastMessageId\?: number/);
assert.match(service, /\? \{ broadcastMessageId \}/);
assert.match(service, /broadcastContext: response\.broadcastContext \|\| legacy\.broadcastContext \|\| null/);
assert.match(service, /broadcastMessageId: options\.broadcastMessageId/);
assert.match(service, /\/api\/aihr\/agent\/messages/);
assert.match(service, /broadcastContext: response\.broadcastContext \|\| null/);
assert.match(types, /export interface BroadcastKnowledgeContext/);
assert.match(types, /broadcastContext\?: BroadcastKnowledgeContext \| null/);
});
+8 -8
View File
@@ -61,11 +61,11 @@ test('语音 profile 透传方言枚举,并保持普通话默认值', async ()
pageSource('../src/services/speech.ts')
]);
assert.match(apiTypes, /dialect\?: 'mandarin' \| 'cantonese' \| 'sichuanese'/);
assert.match(apiTypes, /dialect\?: 'mandarin' \| 'cantonese'[\s\S]*\| 'sichuanese'/);
assert.match(speechService, /dialect: 'mandarin'/);
assert.match(speechService, /customerVoiceProfile = \([\s\S]*?dialect:/);
assert.match(speechService, /dialect === 'cantonese'.*'zh-HK'/);
assert.match(speechService, /dialect === 'sichuanese'[\s\S]*?当前设备语音不支持四川话/);
assert.match(speechService, /dialect && dialect !== 'mandarin'/);
});
test('M3 将业主 TTS 绑定到当前训练会话与准确回合,供主管复盘回放', async () => {
@@ -77,7 +77,7 @@ test('M3 将业主 TTS 绑定到当前训练会话与准确回合,供主管复
assert.match(apiTypes, /export interface PracticeTtsContext/);
assert.match(speechService, /practiceContext\?: PracticeTtsContext/);
assert.match(speechService, /data: \{ text: text\.trim\(\)\.slice\(0, 300\), voiceProfile, practiceContext \}/);
assert.match(speechService, /data: \{ text: text\.trim\(\)\.slice\(0, 300\), voiceProfile: resolveTtsVoiceProfile\(voiceProfile\), practiceContext \}/);
assert.match(practicePage, /\{ sessionId: sessionId\.value, turnIndex: Number\(customerTurnIndex\), role: 'customer' \}/);
assert.match(practicePage, /speechPlayback\.preload\(spoken, profile, practiceContext\)/);
assert.match(practicePage, /turnVoice\(index\)\.profile, turnVoice\(index\)\.practiceContext/);
@@ -481,10 +481,10 @@ test('主管专项从正式团队名单按姓名多选并一次批量派发', as
});
test('问师傅支持现场媒体、答案播报和总结卡图片下载', async () => {
const [source, composerSource, knowledgeService, speechService, imageService] = await Promise.all([
const [source, composerSource, agentService, speechService, imageService] = await Promise.all([
pageSource('../src/pages/user/sop/index.vue'),
pageSource('../src/components/chat/ChatComposer.vue'),
pageSource('../src/services/knowledge.ts'),
pageSource('../src/services/agent.ts'),
pageSource('../src/services/speech.ts'),
pageSource('../src/services/summary-card-image.ts')
]);
@@ -492,11 +492,11 @@ test('问师傅支持现场媒体、答案播报和总结卡图片下载', async
assert.match(source, /@pick-media="pickMedia"/);
assert.match(composerSource, /图片\/视频/);
assert.match(composerSource, /仅用于本次现场分析/);
assert.match(source, /searchKnowledgeWithMedia/);
assert.match(source, /askAgentWithMedia/);
assert.match(source, /toggleAnswerSpeech/);
assert.match(source, /toggleSummarySpeech/);
assert.match(source, /downloadSummaryCardImage/);
assert.match(knowledgeService, /\/api\/knowledge\/query-media/);
assert.match(agentService, /\/api\/aihr\/agent\/messages\/media/);
assert.match(speechService, /\/api\/ai\/tts/);
assert.match(speechService, /SpeechSynthesisUtterance/);
assert.match(speechService, /playWithBrowserSpeech/);
@@ -580,7 +580,7 @@ test('播报复用已合成音频且总结图按内容高度生成并及时释
assert.match(speechService, /const sourceCache = new Map<string, string>\(\)/);
assert.match(speechService, /const maxCachedSources = 4/);
assert.match(speechService, /const sourceKey = cacheKey\(value, profile, practiceContext\)/);
assert.match(speechService, /const sourceKey = cacheKey\(value, effectiveProfile, practiceContext\)/);
assert.match(speechService, /const cachedSource = sourceCache\.get\(sourceKey\)/);
assert.match(speechService, /sourceCache\.clear\(\)/);
assert.match(speechService, /registerAbort\?\.\(\(\) => task\.abort\(\)\)/);
+4 -2
View File
@@ -23,8 +23,10 @@ test('个人助理客户端使用确认式记忆接口且确认请求携带稳
const card = await read('../src/components/chat/MemoryCandidateCard.vue');
assert.match(service, /\/api\/aihr\/personal-assistant\/memory-candidates/);
assert.match(service, /\$\{candidateBase\}\/\$\{candidateId\}\/confirm/);
assert.match(service, /\$\{candidateBase\}\/\$\{candidateId\}\/dismiss/);
assert.match(service, /\/api\/aihr\/agent\/actions\/\$\{encodeURIComponent\(String\(candidateRef\)\)\}\/confirm/);
assert.match(service, /\$\{candidateBase\}\/\$\{candidateRef\}\/confirm/);
assert.match(service, /\/api\/aihr\/agent\/actions\/\$\{encodeURIComponent\(String\(candidateRef\)\)\}\/dismiss/);
assert.match(service, /\$\{candidateBase\}\/\$\{candidateRef\}\/dismiss/);
assert.match(service, /\/api\/aihr\/service-memories/);
assert.match(service, /\/api\/aihr\/personal-assistant\/assistant-captures/);
assert.match(card, /const idempotencyKey = ref\(createMemoryIdempotencyKey\(props\.candidate\.id\)\)/);