feat(aihr): enable grounded agent active runtime

This commit is contained in:
key
2026-08-04 22:29:13 +08:00
parent 901f1aeb3c
commit ba0c718975
7 changed files with 490 additions and 2 deletions
@@ -0,0 +1,266 @@
package org.dromara.aihr.agent;
import lombok.extern.slf4j.Slf4j;
import org.dromara.aihr.agent.AihrAgentDto.AgentPlan;
import org.dromara.aihr.agent.AihrAgentDto.AgentRequest;
import org.dromara.aihr.agent.AihrAgentDto.AgentResponse;
import org.dromara.aihr.agent.AihrAgentDto.AgentStatus;
import org.dromara.aihr.agent.AihrAgentDto.Clarification;
import org.dromara.aihr.agent.AihrAgentDto.Intent;
import org.dromara.aihr.agent.AihrAgentDto.SourceSummary;
import org.dromara.aihr.agent.answer.AihrGroundedAnswerComposer;
import org.dromara.aihr.agent.context.AihrAgentContextResolver;
import org.dromara.aihr.agent.context.AihrAgentConversationStateService;
import org.dromara.aihr.agent.context.AihrAgentConversationStateService.StoredConversationState;
import org.dromara.aihr.agent.grounding.AihrAgentGroundingDto.ConversationState;
import org.dromara.aihr.agent.grounding.AihrAgentGroundingDto.DecisionResult;
import org.dromara.aihr.agent.grounding.AihrAgentGroundingDto.DecisionStatus;
import org.dromara.aihr.agent.grounding.AihrAgentGroundingDto.DocumentEvidence;
import org.dromara.aihr.agent.grounding.AihrAgentGroundingDto.ExecutionContext;
import org.dromara.aihr.agent.grounding.AihrAgentGroundingDto.GroundedAnswer;
import org.dromara.aihr.agent.grounding.AihrAgentGroundingDto.GroundedClaim;
import org.dromara.aihr.agent.grounding.AihrAgentGroundingDto.ResolvedContext;
import org.dromara.aihr.agent.grounding.AihrAgentGroundingDto.SemanticQueryPlan;
import org.dromara.aihr.agent.planning.AihrAgentSemanticPlanner;
import org.dromara.aihr.agent.runtime.AihrGroundedAgentRuntime;
import org.dromara.aihr.agent.runtime.AihrGroundedAgentRuntime.RuntimeResult;
import org.dromara.aihr.agent.shadow.AihrAgentExecutionContextFactory;
import org.dromara.aihr.agent.shadow.AihrAgentShadowProperties;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeAppDto.AuthenticatedApp;
import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.Citation;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryRequest;
import org.dromara.aihr.knowledge.service.AihrKnowledgeAppService;
import org.dromara.aihr.knowledge.service.AihrKnowledgeConversationService;
import org.dromara.aihr.knowledge.service.AihrKnowledgeConversationService.ConversationContext;
import org.dromara.common.core.constant.HttpStatus;
import org.dromara.common.core.exception.ServiceException;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
/**
* User-visible adapter for the grounded runtime. The old orchestrator remains
* the compatibility boundary: this service returns null for unsupported plans
* or infrastructure failures so the caller can use the established path.
*/
@Service
@Slf4j
public class AihrAgentActiveService {
private final AihrAgentShadowProperties properties;
private final AihrAgentExecutionContextFactory contextFactory;
private final AihrAgentConversationStateService stateService;
private final AihrAgentContextResolver contextResolver;
private final AihrAgentSemanticPlanner semanticPlanner;
private final AihrGroundedAgentRuntime runtime;
private final AihrKnowledgeAppService appService;
private final AihrKnowledgeConversationService conversationService;
public AihrAgentActiveService(AihrAgentShadowProperties properties,
AihrAgentExecutionContextFactory contextFactory,
AihrAgentConversationStateService stateService,
AihrAgentContextResolver contextResolver,
AihrAgentSemanticPlanner semanticPlanner,
AihrGroundedAgentRuntime runtime,
AihrKnowledgeAppService appService,
AihrKnowledgeConversationService conversationService) {
this.properties = properties;
this.contextFactory = contextFactory;
this.stateService = stateService;
this.contextResolver = contextResolver;
this.semanticPlanner = semanticPlanner;
this.runtime = runtime;
this.appService = appService;
this.conversationService = conversationService;
}
public AgentResponse tryHandle(AgentRequest request, AgentPlan legacyPlan,
AihrKnowledgePrincipal authenticatedPrincipal) {
if (!properties.activeEnabled() || !eligible(request, legacyPlan)
|| authenticatedPrincipal == null) return null;
try {
ExecutionContext executionContext = contextFactory.create(authenticatedPrincipal,
request.projectCode());
StoredConversationState stored = loadState(executionContext, request);
ConversationState previous = stored == null ? ConversationState.empty(0L) : stored.state();
ResolvedContext resolved = contextResolver.resolve(request.question(), previous);
SemanticQueryPlan plan = semanticPlanner.plan(request.question(), resolved);
RuntimeResult result = runtime.run(request.question(), resolved, plan, executionContext);
if (!result.handled() || result.decision() == null || result.answer() == null) return null;
AgentResponse response = adapt(request, legacyPlan, plan, result);
Long nextVersion = persist(executionContext, authenticatedPrincipal, request, plan, resolved,
result, stored, response);
if (nextVersion != null && !nextVersion.equals(response.contextVersion())) {
response = withContextVersion(response, nextVersion);
}
return response;
} catch (ServiceException ex) {
// Do not turn an authorization or optimistic-concurrency failure into
// a legacy answer that may have been evaluated under stale context.
if (ex.getCode() != null && (ex.getCode() == HttpStatus.FORBIDDEN
|| ex.getCode() == HttpStatus.CONFLICT || ex.getCode() == HttpStatus.BAD_REQUEST)) {
throw ex;
}
log.warn("grounded Agent active execution unavailable (details hidden)");
return null;
} catch (RuntimeException ex) {
log.warn("grounded Agent active execution failed (details hidden)");
return null;
}
}
private StoredConversationState loadState(ExecutionContext context, AgentRequest request) {
if (request.conversationId() == null || request.conversationId().isBlank()) {
return new StoredConversationState(false, 0L, ConversationState.empty(0L));
}
return stateService.load(context, request.conversationId(), request.contextVersion());
}
private Long persist(ExecutionContext context, AihrKnowledgePrincipal principal, AgentRequest request,
SemanticQueryPlan plan, ResolvedContext resolved, RuntimeResult result,
StoredConversationState stored, AgentResponse response) {
if (request.conversationId() == null || request.conversationId().isBlank()) return null;
try {
AuthenticatedApp app = appService.requireSessionApp(context.tenantId(), context.appId());
ConversationContext conversation = conversationService.prepare(principal, app, new QueryRequest(
request.question(), List.of(), "sop", null, "AGENT_GROUNDED", 5, null,
request.conversationId(), request.contextVersion(), request.projectCode(), null));
Long nextVersion = conversationService.append(principal, app, conversation,
request.question(), response.answer(), toCitations(plan, result.decision()));
if (nextVersion == null || !conversation.stateful()) return nextVersion;
StoredConversationState persisted = stateService.load(context, conversation.conversationId(), nextVersion);
ConversationState desired = stateService.capture(context,
stored == null ? persisted.state() : stored.state(), resolved, plan,
result.toolResults(), result.decision());
stateService.save(context, conversation.conversationId(), nextVersion, persisted, desired);
return nextVersion;
} catch (ServiceException ex) {
if (ex.getCode() != null && (ex.getCode() == HttpStatus.FORBIDDEN
|| ex.getCode() == HttpStatus.CONFLICT || ex.getCode() == HttpStatus.BAD_REQUEST)) {
throw ex;
}
log.warn("grounded Agent visible turn persistence unavailable (details hidden)");
return request.contextVersion();
} catch (RuntimeException ex) {
log.warn("grounded Agent visible turn persistence failed (details hidden)");
return request.contextVersion();
}
}
private static boolean eligible(AgentRequest request, AgentPlan plan) {
if (request == null || plan == null || request.broadcastMessageId() != null
|| plan.requiresExternalConsent() || plan.requiresConfirmation()) return false;
if (plan.intent() == Intent.KNOWLEDGE_QA) {
return plan.tool() == AihrAgentDto.Tool.KNOWLEDGE_SEARCH;
}
return plan.intent() == Intent.LIVE_MY_WORK
&& plan.tool() == AihrAgentDto.Tool.MY_CURRENT_TASKS;
}
private static AgentResponse adapt(AgentRequest request, AgentPlan legacyPlan,
SemanticQueryPlan plan, RuntimeResult result) {
GroundedAnswer answer = result.answer();
DecisionResult decision = result.decision();
AgentStatus status = mapStatus(answer.status());
String body = renderAnswer(answer);
List<String> keyPoints = answer.steps().stream().map(GroundedClaim::text).toList();
List<String> cautions = new ArrayList<>();
answer.missingInformation().forEach(value -> cautions.add(humanize(value)));
decision.conflicts().forEach(value -> cautions.add(humanize(value)));
Clarification clarification = answer.clarificationQuestion() == null
|| answer.clarificationQuestion().isBlank() ? null
: new Clarification(answer.clarificationQuestion(), answer.missingInformation());
List<Citation> citations = toCitations(plan, decision);
List<SourceSummary> sources = new ArrayList<>();
Set<String> labels = new LinkedHashSet<>();
for (DocumentEvidence evidence : decision.verifiedEvidence()) {
labels.add(sourceLabel(evidence.sourceKind()));
}
if (!decision.verifiedFacts().isEmpty()) labels.add("实时业务事实");
String verifiedAt = OffsetDateTime.now().toString();
for (String label : labels) {
sources.add(new SourceSummary(label.equals("实时业务事实") ? "LIVE_DATA" : "ENTERPRISE_KNOWLEDGE",
label, verifiedAt));
}
return new AgentResponse(
"agent_run_" + UUID.randomUUID().toString().replace("-", ""),
request.conversationId(), request.contextVersion(), legacyPlan.intent(), status, body,
List.copyOf(sources), citations, List.of(), null, null, null, clarification, List.of(),
keyPoints, List.copyOf(new LinkedHashSet<>(cautions)), "GROUNDED_ACTIVE"
);
}
private static AgentResponse withContextVersion(AgentResponse response, Long contextVersion) {
return new AgentResponse(response.runId(), response.conversationId(), contextVersion, response.intent(),
response.status(), response.answer(), response.sourceSummary(), response.citations(), response.resources(),
response.broadcastContext(), response.data(), response.actionDraft(), response.clarification(),
response.nextActions(), response.keyPoints(), response.cautions(), response.auditResultRef());
}
private static String renderAnswer(GroundedAnswer answer) {
List<String> claims = new ArrayList<>();
answer.confirmedStatements().stream().map(GroundedClaim::text).forEach(claims::add);
String summary = answer.summary() == null ? "" : answer.summary().trim();
if (claims.isEmpty()) return summary;
return summary.isBlank() ? String.join("\n", claims) : summary + "\n" + String.join("\n", claims);
}
private static List<Citation> toCitations(SemanticQueryPlan plan, DecisionResult decision) {
return decision.verifiedEvidence().stream().map(evidence -> new Citation(
null, "DOCUMENT", evidence.fragmentId() == null ? evidence.citationId()
: String.valueOf(evidence.fragmentId()), evidence.title(), evidence.content(), evidence.fragmentId(),
plan.domain(), "ACTIVE", null, null, null, null, null, evidence.sourceAuthority(), evidence.sourceKind(),
evidence.citationId(), evidence.version(), instantText(evidence.effectiveAt()), evidence.applicability()
)).toList();
}
private static String instantText(Instant value) {
return value == null ? null : value.toString();
}
private static AgentStatus mapStatus(DecisionStatus status) {
return switch (status) {
case ANSWERED -> AgentStatus.COMPLETED;
case PARTIAL -> AgentStatus.PARTIAL;
case NEEDS_INPUT -> AgentStatus.NEEDS_INPUT;
case NO_EVIDENCE -> AgentStatus.NO_EVIDENCE;
case CONFLICT -> AgentStatus.CONFLICT;
};
}
private static String sourceLabel(String sourceKind) {
return switch (sourceKind == null ? "" : sourceKind) {
case "FORMAL_POLICY" -> "正式制度";
case "OPERATING_MANUAL" -> "操作手册";
case "APPROVED_SOP" -> "已批准SOP";
case "EXPERIENCE" -> "经验材料";
case "CASE" -> "案例";
default -> "企业资料";
};
}
private static String humanize(String code) {
if (code == null || code.isBlank()) return "存在未说明的证据缺口";
return switch (code) {
case "PROCESS_STEPS", "ENTRY_STEPS" -> "缺少明确的办理步骤依据";
case "REQUIRED_MATERIALS", "MATERIALS" -> "缺少必备材料依据";
case "APPROVAL_REVIEW", "APPROVAL" -> "缺少审批或复核依据";
case "APPLICABILITY", "ELIGIBILITY" -> "缺少适用条件依据";
case "AMOUNT_BASIS", "AMOUNT_RULE" -> "缺少金额口径依据";
case "CURRENT_TASKS" -> "当前待办数据暂不可用";
case "CAPABILITY_REGISTRY" -> "当前能力注册信息暂不可用";
case "NO_AUTHORIZED_DOCUMENT_EVIDENCE" -> "当前授权知识空间没有合格正式资料";
case "TOOL_AUTHORIZATION_REJECTED" -> "工具授权未通过,无法据此回答";
default -> code.startsWith("REJECTED_") ? "部分候选证据未通过治理校验" : code;
};
}
}
@@ -53,6 +53,8 @@ public final class AihrAgentDto {
NEEDS_INPUT, NEEDS_INPUT,
NEEDS_CONFIRMATION, NEEDS_CONFIRMATION,
NO_EVIDENCE, NO_EVIDENCE,
PARTIAL,
CONFLICT,
FORBIDDEN, FORBIDDEN,
UNAVAILABLE, UNAVAILABLE,
FAILED FAILED
@@ -18,6 +18,7 @@ import org.dromara.aihr.knowledge.service.AihrKnowledgeQueryService;
import org.dromara.aihr.knowledge.service.AihrFormalPolicyClassifier; import org.dromara.aihr.knowledge.service.AihrFormalPolicyClassifier;
import org.dromara.aihr.knowledge.service.AihrKnowledgeQueryService.MediaMode; import org.dromara.aihr.knowledge.service.AihrKnowledgeQueryService.MediaMode;
import org.dromara.aihr.agent.shadow.AihrAgentShadowService; import org.dromara.aihr.agent.shadow.AihrAgentShadowService;
import org.springframework.beans.factory.annotation.Autowired;
import org.dromara.aihr.webai.AihrWebAiDto; import org.dromara.aihr.webai.AihrWebAiDto;
import org.dromara.aihr.webai.AihrWebAiService; import org.dromara.aihr.webai.AihrWebAiService;
import org.dromara.common.core.exception.ServiceException; import org.dromara.common.core.exception.ServiceException;
@@ -44,6 +45,12 @@ public class AihrAgentOrchestrator {
private final AihrWebAiService webAiService; private final AihrWebAiService webAiService;
private final AihrAgentAuditService auditService; private final AihrAgentAuditService auditService;
private final AihrAgentShadowService shadowService; private final AihrAgentShadowService shadowService;
private AihrAgentActiveService activeService;
@Autowired
void setActiveService(AihrAgentActiveService activeService) {
this.activeService = activeService;
}
public AgentResponse handle(AgentRequest request) { public AgentResponse handle(AgentRequest request) {
return execute(request, null, false); return execute(request, null, false);
@@ -125,6 +132,11 @@ public class AihrAgentOrchestrator {
+ "进入指令、个人信息、产品价格或审批结果。请补充权威来源或通过正式渠道复核。", + "进入指令、个人信息、产品价格或审批结果。请补充权威来源或通过正式渠道复核。",
null); null);
} }
AgentResponse grounded = activeService == null
? null : activeService.tryHandle(request, plan, principal);
if (grounded != null) {
return grounded;
}
return switch (plan.intent()) { return switch (plan.intent()) {
case SOCIAL -> simple(plan, AgentStatus.COMPLETED, "你好,我是数字师傅。你可以问制度、待办、训练或现场问题。", case SOCIAL -> simple(plan, AgentStatus.COMPLETED, "你好,我是数字师傅。你可以问制度、待办、训练或现场问题。",
null); null);
@@ -9,7 +9,8 @@ public class AihrAgentShadowProperties {
public enum GroundedMode { public enum GroundedMode {
OFF, OFF,
SHADOW SHADOW,
ACTIVE
} }
private GroundedMode groundedMode = GroundedMode.OFF; private GroundedMode groundedMode = GroundedMode.OFF;
@@ -52,4 +53,8 @@ public class AihrAgentShadowProperties {
public boolean shadowEnabled() { public boolean shadowEnabled() {
return groundedMode == GroundedMode.SHADOW; return groundedMode == GroundedMode.SHADOW;
} }
public boolean activeEnabled() {
return groundedMode == GroundedMode.ACTIVE;
}
} }
@@ -0,0 +1,164 @@
package org.dromara.aihr.agent;
import org.dromara.aihr.agent.AihrAgentDto.AgentPlan;
import org.dromara.aihr.agent.AihrAgentDto.AgentRequest;
import org.dromara.aihr.agent.AihrAgentDto.AgentResponse;
import org.dromara.aihr.agent.AihrAgentDto.ResponseStyle;
import org.dromara.aihr.agent.context.AihrAgentContextResolver;
import org.dromara.aihr.agent.context.AihrAgentConversationStateService;
import org.dromara.aihr.agent.grounding.AihrAgentGroundingDto.DecisionResult;
import org.dromara.aihr.agent.grounding.AihrAgentGroundingDto.DecisionStatus;
import org.dromara.aihr.agent.grounding.AihrAgentGroundingDto.DocumentEvidence;
import org.dromara.aihr.agent.grounding.AihrAgentGroundingDto.ExecutionContext;
import org.dromara.aihr.agent.grounding.AihrAgentGroundingDto.GroundedAnswer;
import org.dromara.aihr.agent.grounding.AihrAgentGroundingDto.GroundedClaim;
import org.dromara.aihr.agent.grounding.AihrAgentGroundingDto.ResolvedContext;
import org.dromara.aihr.agent.grounding.AihrAgentGroundingDto.SemanticQueryPlan;
import org.dromara.aihr.agent.runtime.AihrGroundedAgentRuntime;
import org.dromara.aihr.agent.runtime.AihrGroundedAgentRuntime.RuntimeResult;
import org.dromara.aihr.agent.shadow.AihrAgentExecutionContextFactory;
import org.dromara.aihr.agent.shadow.AihrAgentShadowProperties;
import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal;
import org.dromara.aihr.knowledge.service.AihrKnowledgeAppService;
import org.dromara.aihr.knowledge.service.AihrKnowledgeConversationService;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Tag;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@Tag("dev")
class AihrAgentActiveServiceTest {
@Test
void activeTextReturnsGroundedPartialWithQualifiedCitation() {
AihrAgentShadowProperties properties = properties();
AihrAgentExecutionContextFactory contextFactory = mock(AihrAgentExecutionContextFactory.class);
AihrAgentConversationStateService stateService = mock(AihrAgentConversationStateService.class);
AihrAgentContextResolver contextResolver = mock(AihrAgentContextResolver.class);
org.dromara.aihr.agent.planning.AihrAgentSemanticPlanner planner =
mock(org.dromara.aihr.agent.planning.AihrAgentSemanticPlanner.class);
AihrGroundedAgentRuntime runtime = mock(AihrGroundedAgentRuntime.class);
AihrKnowledgeAppService appService = mock(AihrKnowledgeAppService.class);
AihrKnowledgeConversationService conversations = mock(AihrKnowledgeConversationService.class);
AihrKnowledgePrincipal principal = principal();
ExecutionContext executionContext = context();
ResolvedContext resolved = new ResolvedContext("报销流程", null, "报销流程", List.of(), List.of(),
List.of(), List.of(), List.of(), List.of(), 0L);
SemanticQueryPlan semanticPlan = semanticPlan();
DocumentEvidence evidence = new DocumentEvidence("cite-1", 11L, "FORMAL_POLICY", "FORMAL_POLICY",
"v1", Instant.parse("2026-01-01T00:00:00Z"), "当前租户", "报销手册", "提交报销单",
Set.of("PROCESS_STEPS"), Set.of("PROCESS_STEP"));
DecisionResult decision = new DecisionResult(DecisionStatus.PARTIAL, "VERIFIED_DOCUMENTS_AVAILABLE",
List.of(evidence), List.of(), List.of("REQUIRED_MATERIALS"), List.of(), List.of());
GroundedAnswer answer = new GroundedAnswer(DecisionStatus.PARTIAL, "已根据正式依据整理部分结果。",
List.of(new GroundedClaim("提交报销单", "PROCESS_STEP", List.of("cite-1"), List.of())),
List.of(), List.of("REQUIRED_MATERIALS"), null);
when(contextFactory.create(principal, null)).thenReturn(executionContext);
when(contextResolver.resolve("报销流程", org.dromara.aihr.agent.grounding.AihrAgentGroundingDto.ConversationState.empty(0L)))
.thenReturn(resolved);
when(planner.plan("报销流程", resolved)).thenReturn(semanticPlan);
when(runtime.run("报销流程", resolved, semanticPlan, executionContext))
.thenReturn(new RuntimeResult(true, decision, answer, List.of(),
new AihrGroundedAgentRuntime.RuntimeTrace(1, 1, 1, 2L, 0L, 0L, List.of(), List.of())));
AihrAgentActiveService service = new AihrAgentActiveService(properties, contextFactory, stateService,
contextResolver, planner, runtime, appService, conversations);
AgentResponse response = service.tryHandle(new AgentRequest("报销流程", null, null, null, null, false),
new AgentPlan(AihrAgentDto.Intent.KNOWLEDGE_QA, "报销流程", AihrAgentDto.Tool.KNOWLEDGE_SEARCH,
false, false, ResponseStyle.GROUNDED), principal);
assertThat(response).isNotNull();
assertThat(response.status()).isEqualTo(AihrAgentDto.AgentStatus.PARTIAL);
assertThat(response.answer()).contains("提交报销单");
assertThat(response.citations()).singleElement().satisfies(citation -> {
assertThat(citation.sourceAuthority()).isEqualTo("FORMAL_POLICY");
assertThat(citation.sourceKind()).isEqualTo("FORMAL_POLICY");
});
verify(runtime).run("报销流程", resolved, semanticPlan, executionContext);
}
@Test
void activeNoEvidenceDoesNotExposeLegacyAnswer() {
AihrAgentShadowProperties properties = properties();
AihrAgentExecutionContextFactory contextFactory = mock(AihrAgentExecutionContextFactory.class);
AihrAgentContextResolver contextResolver = mock(AihrAgentContextResolver.class);
org.dromara.aihr.agent.planning.AihrAgentSemanticPlanner planner =
mock(org.dromara.aihr.agent.planning.AihrAgentSemanticPlanner.class);
AihrGroundedAgentRuntime runtime = mock(AihrGroundedAgentRuntime.class);
AihrKnowledgePrincipal principal = principal();
ExecutionContext executionContext = context();
ResolvedContext resolved = new ResolvedContext("无正式依据", null, "无正式依据", List.of(), List.of(),
List.of(), List.of(), List.of(), List.of(), 0L);
SemanticQueryPlan semanticPlan = semanticPlan();
DecisionResult decision = new DecisionResult(DecisionStatus.NO_EVIDENCE, "UNVERIFIED", List.of(), List.of(),
List.of("KNOWLEDGE_EVIDENCE"), List.of(), List.of());
GroundedAnswer answer = new GroundedAnswer(DecisionStatus.NO_EVIDENCE, "当前没有可用的合格证据。",
List.of(), List.of(), List.of("KNOWLEDGE_EVIDENCE"), null);
when(contextFactory.create(principal, null)).thenReturn(executionContext);
when(contextResolver.resolve(any(), any())).thenReturn(resolved);
when(planner.plan(any(), any())).thenReturn(semanticPlan);
when(runtime.run(any(), any(), any(), any())).thenReturn(new RuntimeResult(true, decision, answer, List.of(),
new AihrGroundedAgentRuntime.RuntimeTrace(1, 1, 0, 2L, 0L, 0L, List.of(), List.of())));
AihrAgentActiveService service = new AihrAgentActiveService(properties, contextFactory,
mock(AihrAgentConversationStateService.class), contextResolver, planner, runtime,
mock(AihrKnowledgeAppService.class), mock(AihrKnowledgeConversationService.class));
AgentResponse response = service.tryHandle(new AgentRequest("无正式依据", null, null, null, null, false),
new AgentPlan(AihrAgentDto.Intent.KNOWLEDGE_QA, "无正式依据", AihrAgentDto.Tool.KNOWLEDGE_SEARCH,
false, false, ResponseStyle.GROUNDED), principal);
assertThat(response).isNotNull();
assertThat(response.status()).isEqualTo(AihrAgentDto.AgentStatus.NO_EVIDENCE);
assertThat(response.answer()).doesNotContain("旧答案");
assertThat(response.citations()).isEmpty();
}
@Test
void unsupportedPlanFallsBackToLegacy() {
AihrAgentShadowProperties properties = properties();
AihrGroundedAgentRuntime runtime = mock(AihrGroundedAgentRuntime.class);
AihrAgentActiveService service = new AihrAgentActiveService(properties,
mock(AihrAgentExecutionContextFactory.class), mock(AihrAgentConversationStateService.class),
mock(AihrAgentContextResolver.class), mock(org.dromara.aihr.agent.planning.AihrAgentSemanticPlanner.class),
runtime, mock(AihrKnowledgeAppService.class), mock(AihrKnowledgeConversationService.class));
AgentResponse response = service.tryHandle(new AgentRequest("发我原文件", null, null, null, null, false),
new AgentPlan(AihrAgentDto.Intent.RESOURCE_DELIVERY, "发我原文件", AihrAgentDto.Tool.KNOWLEDGE_RESOURCE,
false, false, ResponseStyle.FACT), principal());
assertThat(response).isNull();
verify(runtime, never()).run(any(), any(), any(), any());
}
private static AihrAgentShadowProperties properties() {
AihrAgentShadowProperties value = new AihrAgentShadowProperties();
value.setGroundedMode(AihrAgentShadowProperties.GroundedMode.ACTIVE);
return value;
}
private static AihrKnowledgePrincipal principal() {
return new AihrKnowledgePrincipal("tenant", 1L, "app_user", "employee-1", Set.of("employee"),
Set.of("P1"), "app");
}
private static ExecutionContext context() {
return new ExecutionContext("tenant", 1L, "app_user", "employee-1", 1L, null,
Set.of("P1"), Set.of(2L), Set.of("employee"), "auth-hash");
}
private static SemanticQueryPlan semanticPlan() {
return new SemanticQueryPlan("报销流程", "报销流程", List.of(), Set.of("KNOWLEDGE_QA",
"PROCESS_GUIDANCE"), "FINANCE_REIMBURSEMENT", List.of(), List.of(), List.of(), false,
List.of(), "test", "TEST");
}
}
@@ -0,0 +1,39 @@
# Grounded Agent ACTIVE 激活运行手册
状态:代码已实现,生产尚未切换。
## 配置契约
- `OFF`:旧 Agent 响应,默认值和快速回滚值。
- `SHADOW`:旧响应对用户可见,Grounded Agent 只做旁路审计。
- `ACTIVE`:仅文本 `KNOWLEDGE_QA` 和 `LIVE_MY_WORK` 进入 Grounded Agent;媒体、资源、外网、写入、训练等请求继续走旧链路。
生产配置通过服务端环境变量设置:
```text
AIHR_AGENT_GROUNDED_MODE=ACTIVE
```
不能在客户端提交模式、工具或授权范围。租户、用户、应用、项目和知识空间仍由认证主体重新解析。
## 用户可见行为
Grounded Agent 只把通过授权和证据门禁的结果适配为现有 `AgentResponse`:
- `ANSWERED` 映射为 `COMPLETED`;
- `PARTIAL`、`NEEDS_INPUT`、`NO_EVIDENCE`、`CONFLICT` 保留为对应状态;
- 正式制度、操作手册、已批准 SOP 使用真实来源字段;
- `NO_EVIDENCE`、`PARTIAL`、`CONFLICT` 不回退为隐藏的旧答案;
- 会话仍使用现有 30 分钟、最近 6 轮和 `contextVersion`,结构化状态写入 additive 的 `state_json/state_version`。
## 发布和回滚
1. 工作树干净后运行只读 `scripts/release-backend.sh plan`。
2. 仅使用计划输出的完整 `DEPLOY_BACKEND:<hash>` 授权串执行发布,不手工覆盖 JAR。
3. 发布后先保持 `OFF` 做服务、JAR、schema 和认证请求预检。
4. 通过内部账号验证后,再由受控 systemd drop-in 设置 `AIHR_AGENT_GROUNDED_MODE=ACTIVE` 并重启服务。
5. 任一异常将环境变量改回 `OFF`,按同一配置路径重启;不需要回滚数据库结构。
审计只能记录 query hash、run ID、结构化状态、工具/证据计数、差异和耗时,不得写入原问题、答案、手机号、token、附件或工具正文。
没有 100-300 条人工标注黄金集前,不宣称正式召回率达标。
+1 -1
View File
@@ -255,7 +255,7 @@ export interface UnifiedKnowledgeResponse {
broadcastContext?: BroadcastKnowledgeContext | null; broadcastContext?: BroadcastKnowledgeContext | null;
} }
export type AgentStatus = 'COMPLETED' | 'NEEDS_INPUT' | 'NEEDS_CONFIRMATION' | 'NO_EVIDENCE' | 'FORBIDDEN' | 'UNAVAILABLE' | 'FAILED'; export type AgentStatus = 'COMPLETED' | 'NEEDS_INPUT' | 'NEEDS_CONFIRMATION' | 'NO_EVIDENCE' | 'PARTIAL' | 'CONFLICT' | 'FORBIDDEN' | 'UNAVAILABLE' | 'FAILED';
export type AgentIntent = export type AgentIntent =
| 'KNOWLEDGE_QA' | 'KNOWLEDGE_QA'