diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/AihrAgentActiveService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/AihrAgentActiveService.java new file mode 100644 index 00000000..8897b565 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/AihrAgentActiveService.java @@ -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 keyPoints = answer.steps().stream().map(GroundedClaim::text).toList(); + List 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 citations = toCitations(plan, decision); + List sources = new ArrayList<>(); + Set 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 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 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; + }; + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/AihrAgentDto.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/AihrAgentDto.java index 88057153..c69409c2 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/AihrAgentDto.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/AihrAgentDto.java @@ -53,6 +53,8 @@ public final class AihrAgentDto { NEEDS_INPUT, NEEDS_CONFIRMATION, NO_EVIDENCE, + PARTIAL, + CONFLICT, FORBIDDEN, UNAVAILABLE, FAILED diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/AihrAgentOrchestrator.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/AihrAgentOrchestrator.java index d5d91a44..41e83a08 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/AihrAgentOrchestrator.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/AihrAgentOrchestrator.java @@ -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); diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/shadow/AihrAgentShadowProperties.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/shadow/AihrAgentShadowProperties.java index 78cab695..14ca301c 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/shadow/AihrAgentShadowProperties.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/shadow/AihrAgentShadowProperties.java @@ -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; + } } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/AihrAgentActiveServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/AihrAgentActiveServiceTest.java new file mode 100644 index 00000000..32face56 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/AihrAgentActiveServiceTest.java @@ -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"); + } +} diff --git a/docs/AIHR_GROUNDED_AGENT_ACTIVE_RUNBOOK.md b/docs/AIHR_GROUNDED_AGENT_ACTIVE_RUNBOOK.md new file mode 100644 index 00000000..4a863a0c --- /dev/null +++ b/docs/AIHR_GROUNDED_AGENT_ACTIVE_RUNBOOK.md @@ -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:` 授权串执行发布,不手工覆盖 JAR。 +3. 发布后先保持 `OFF` 做服务、JAR、schema 和认证请求预检。 +4. 通过内部账号验证后,再由受控 systemd drop-in 设置 `AIHR_AGENT_GROUNDED_MODE=ACTIVE` 并重启服务。 +5. 任一异常将环境变量改回 `OFF`,按同一配置路径重启;不需要回滚数据库结构。 + +审计只能记录 query hash、run ID、结构化状态、工具/证据计数、差异和耗时,不得写入原问题、答案、手机号、token、附件或工具正文。 + +没有 100-300 条人工标注黄金集前,不宣称正式召回率达标。 diff --git a/mobile-uni/src/types/api.ts b/mobile-uni/src/types/api.ts index bcca382a..a0a692c7 100644 --- a/mobile-uni/src/types/api.ts +++ b/mobile-uni/src/types/api.ts @@ -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'