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_INPUT,
|
||||||
NEEDS_CONFIRMATION,
|
NEEDS_CONFIRMATION,
|
||||||
NO_EVIDENCE,
|
NO_EVIDENCE,
|
||||||
|
PARTIAL,
|
||||||
|
CONFLICT,
|
||||||
FORBIDDEN,
|
FORBIDDEN,
|
||||||
UNAVAILABLE,
|
UNAVAILABLE,
|
||||||
FAILED
|
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.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);
|
||||||
|
|||||||
+6
-1
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+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;
|
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'
|
||||||
|
|||||||
Reference in New Issue
Block a user