feat(aihr): enable grounded agent active runtime
This commit is contained in:
+266
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
+2
@@ -53,6 +53,8 @@ public final class AihrAgentDto {
|
||||
NEEDS_INPUT,
|
||||
NEEDS_CONFIRMATION,
|
||||
NO_EVIDENCE,
|
||||
PARTIAL,
|
||||
CONFLICT,
|
||||
FORBIDDEN,
|
||||
UNAVAILABLE,
|
||||
FAILED
|
||||
|
||||
+12
@@ -18,6 +18,7 @@ import org.dromara.aihr.knowledge.service.AihrKnowledgeQueryService;
|
||||
import org.dromara.aihr.knowledge.service.AihrFormalPolicyClassifier;
|
||||
import org.dromara.aihr.knowledge.service.AihrKnowledgeQueryService.MediaMode;
|
||||
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.AihrWebAiService;
|
||||
import org.dromara.common.core.exception.ServiceException;
|
||||
@@ -44,6 +45,12 @@ public class AihrAgentOrchestrator {
|
||||
private final AihrWebAiService webAiService;
|
||||
private final AihrAgentAuditService auditService;
|
||||
private final AihrAgentShadowService shadowService;
|
||||
private AihrAgentActiveService activeService;
|
||||
|
||||
@Autowired
|
||||
void setActiveService(AihrAgentActiveService activeService) {
|
||||
this.activeService = activeService;
|
||||
}
|
||||
|
||||
public AgentResponse handle(AgentRequest request) {
|
||||
return execute(request, null, false);
|
||||
@@ -125,6 +132,11 @@ public class AihrAgentOrchestrator {
|
||||
+ "进入指令、个人信息、产品价格或审批结果。请补充权威来源或通过正式渠道复核。",
|
||||
null);
|
||||
}
|
||||
AgentResponse grounded = activeService == null
|
||||
? null : activeService.tryHandle(request, plan, principal);
|
||||
if (grounded != null) {
|
||||
return grounded;
|
||||
}
|
||||
return switch (plan.intent()) {
|
||||
case SOCIAL -> simple(plan, AgentStatus.COMPLETED, "你好,我是数字师傅。你可以问制度、待办、训练或现场问题。",
|
||||
null);
|
||||
|
||||
+6
-1
@@ -9,7 +9,8 @@ public class AihrAgentShadowProperties {
|
||||
|
||||
public enum GroundedMode {
|
||||
OFF,
|
||||
SHADOW
|
||||
SHADOW,
|
||||
ACTIVE
|
||||
}
|
||||
|
||||
private GroundedMode groundedMode = GroundedMode.OFF;
|
||||
@@ -52,4 +53,8 @@ public class AihrAgentShadowProperties {
|
||||
public boolean shadowEnabled() {
|
||||
return groundedMode == GroundedMode.SHADOW;
|
||||
}
|
||||
|
||||
public boolean activeEnabled() {
|
||||
return groundedMode == GroundedMode.ACTIVE;
|
||||
}
|
||||
}
|
||||
|
||||
+164
@@ -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 条人工标注黄金集前,不宣称正式召回率达标。
|
||||
@@ -255,7 +255,7 @@ export interface UnifiedKnowledgeResponse {
|
||||
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 =
|
||||
| 'KNOWLEDGE_QA'
|
||||
|
||||
Reference in New Issue
Block a user