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