From 6f8c5497523d0ce5c7b72546306a95a6c31cdd9a Mon Sep 17 00:00:00 2001 From: let5sne Date: Fri, 24 Jul 2026 20:33:07 +0800 Subject: [PATCH] feat(agent): confirm existing domain drafts --- .../aihr/agent/AihrAgentActionService.java | 72 +++++++++++++++++++ .../aihr/agent/AihrAgentController.java | 18 +++++ .../org/dromara/aihr/agent/AihrAgentDto.java | 2 +- .../aihr/agent/AihrAgentOrchestrator.java | 16 ++++- .../agent/AihrAgentActionServiceTest.java | 69 ++++++++++++++++++ .../aihr/agent/AihrAgentMediaTest.java | 3 +- .../aihr/agent/AihrAgentOrchestratorTest.java | 30 +++++++- 7 files changed, 205 insertions(+), 5 deletions(-) create mode 100644 backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/AihrAgentActionService.java create mode 100644 backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/AihrAgentActionServiceTest.java diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/AihrAgentActionService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/AihrAgentActionService.java new file mode 100644 index 00000000..a8fcf52f --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/AihrAgentActionService.java @@ -0,0 +1,72 @@ +package org.dromara.aihr.agent; + +import lombok.RequiredArgsConstructor; +import org.dromara.aihr.agent.AihrAgentDto.ActionDraft; +import org.dromara.aihr.memory.AihrMemoryDto.ConfirmMemoryRequest; +import org.dromara.aihr.memory.AihrMemoryDto.ConfirmMemoryResponse; +import org.dromara.aihr.memory.AihrMemoryDto.MemoryCandidateResponse; +import org.dromara.aihr.memory.AihrMemoryService; +import org.dromara.common.core.constant.GlobalConstants; +import org.dromara.common.core.exception.ServiceException; +import org.redisson.api.RBucket; +import org.redisson.api.RedissonClient; +import org.springframework.stereotype.Service; + +import java.security.SecureRandom; +import java.time.Duration; +import java.util.Base64; + +@Service +@RequiredArgsConstructor +public class AihrAgentActionService { + + private static final Duration DRAFT_TTL = Duration.ofMinutes(30); + private static final String PREFIX = GlobalConstants.GLOBAL_REDIS_KEY + "aihr:agent:action:"; + private static final SecureRandom RANDOM = new SecureRandom(); + + private final AihrMemoryService memoryService; + private final RedissonClient redissonClient; + + public ActionDraft register(MemoryCandidateResponse candidate) { + if (candidate == null || candidate.id() == null || candidate.version() == null) { + throw new ServiceException("确认草稿无效", 400); + } + RBucket index = redissonClient.getBucket(PREFIX + "candidate:" + candidate.id()); + String draftId = index.get(); + if (!valid(draftId)) { + draftId = ticket(); + index.set(draftId, DRAFT_TTL); + } + redissonClient.getBucket(PREFIX + "draft:" + draftId).set(candidate.id(), DRAFT_TTL); + return new ActionDraft(draftId, candidate.targetDomain(), candidate.version(), candidate.draft()); + } + + public ConfirmMemoryResponse confirm(String draftId, ConfirmMemoryRequest request) { + return memoryService.confirm(resolve(draftId), request); + } + + public MemoryCandidateResponse dismiss(String draftId) { + return memoryService.dismiss(resolve(draftId)); + } + + private Long resolve(String draftId) { + if (!valid(draftId)) { + throw new ServiceException("确认草稿不存在或已过期", 404); + } + Long candidateId = redissonClient.getBucket(PREFIX + "draft:" + draftId).get(); + if (candidateId == null || candidateId <= 0) { + throw new ServiceException("确认草稿不存在或已过期", 404); + } + return candidateId; + } + + private static String ticket() { + byte[] bytes = new byte[32]; + RANDOM.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + private static boolean valid(String value) { + return value != null && value.length() == 43 && value.matches("[A-Za-z0-9_-]+"); + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/AihrAgentController.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/AihrAgentController.java index 06c83801..3cc980ba 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/AihrAgentController.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/AihrAgentController.java @@ -4,9 +4,13 @@ import cn.dev33.satoken.annotation.SaCheckLogin; import lombok.RequiredArgsConstructor; import org.dromara.aihr.agent.AihrAgentDto.AgentRequest; import org.dromara.aihr.agent.AihrAgentDto.AgentResponse; +import org.dromara.aihr.memory.AihrMemoryDto.ConfirmMemoryRequest; +import org.dromara.aihr.memory.AihrMemoryDto.ConfirmMemoryResponse; +import org.dromara.aihr.memory.AihrMemoryDto.MemoryCandidateResponse; import org.dromara.common.core.domain.R; import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; @@ -20,6 +24,7 @@ import org.springframework.web.multipart.MultipartFile; public class AihrAgentController { private final AihrAgentOrchestrator orchestrator; + private final AihrAgentActionService actionService; @SaCheckLogin @PostMapping("/messages") @@ -43,4 +48,17 @@ public class AihrAgentController { file )); } + + @SaCheckLogin + @PostMapping("/actions/{draftId}/confirm") + public R confirm(@PathVariable String draftId, + @RequestBody ConfirmMemoryRequest request) { + return R.ok(actionService.confirm(draftId, request)); + } + + @SaCheckLogin + @PostMapping("/actions/{draftId}/dismiss") + public R dismiss(@PathVariable String draftId) { + return R.ok(actionService.dismiss(draftId)); + } } 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 fcf267bb..466b1e48 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 @@ -79,7 +79,7 @@ public final class AihrAgentDto { public record SourceSummary(String type, String label, String verifiedAt) { } - public record ActionDraft(String draftId, String type, Long version, Object payload) { + public record ActionDraft(String draftId, String type, Integer version, Object payload) { } public record Clarification(String prompt, List fields) { 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 db41464a..03025097 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 @@ -5,6 +5,7 @@ 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.ActionDraft; import org.dromara.aihr.agent.AihrAgentDto.Clarification; import org.dromara.aihr.agent.AihrAgentDto.Intent; import org.dromara.aihr.agent.AihrAgentDto.SourceSummary; @@ -30,6 +31,7 @@ public class AihrAgentOrchestrator { private final AihrAgentPolicy policy; private final AihrKnowledgePrincipalResolver principalResolver; private final AihrKnowledgeQueryService queryService; + private final AihrAgentActionService actionService; public AgentResponse handle(AgentRequest request) { requireQuestion(request); @@ -78,12 +80,22 @@ public class AihrAgentOrchestrator { private AgentResponse fromResponse(AgentPlan plan, QueryResponse response, String sourceType) { AgentStatus status = status(response); + ActionDraft actionDraft = null; + if (response.memoryCandidate() != null) { + actionDraft = actionService.register(response.memoryCandidate()); + status = response.memoryCandidate().missingFields().isEmpty() + ? AgentStatus.NEEDS_CONFIRMATION + : AgentStatus.NEEDS_INPUT; + sourceType = "MEMORY_DRAFT"; + } String sourceLabel = response.citations().isEmpty() ? sourceType : response.citations().get(0).title(); + String answer = actionDraft == null ? response.answer() + : "已整理为待确认记录,请核对后保存。"; return new AgentResponse( - runId(), response.conversationId(), response.contextVersion(), plan.intent(), status, response.answer(), + runId(), response.conversationId(), response.contextVersion(), plan.intent(), status, answer, List.of(new SourceSummary(sourceType, sourceLabel, OffsetDateTime.now().toString())), - response.citations(), response.resources(), response.data(), null, null, List.of() + response.citations(), response.resources(), response.data(), actionDraft, null, List.of() ); } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/AihrAgentActionServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/AihrAgentActionServiceTest.java new file mode 100644 index 00000000..24d9518c --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/AihrAgentActionServiceTest.java @@ -0,0 +1,69 @@ +package org.dromara.aihr.agent; + +import org.dromara.aihr.memory.AihrMemoryDto.ConfirmMemoryRequest; +import org.dromara.aihr.memory.AihrMemoryDto.ConfirmMemoryResponse; +import org.dromara.aihr.memory.AihrMemoryDto.MemoryCandidateResponse; +import org.dromara.aihr.memory.AihrMemoryDto.MemoryDraft; +import org.dromara.aihr.memory.AihrMemoryService; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.redisson.api.RBucket; +import org.redisson.api.RedissonClient; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@Tag("dev") +class AihrAgentActionServiceTest { + + @SuppressWarnings({"rawtypes", "unchecked"}) + @Test + void opaqueDraftDelegatesConfirmationToExistingMemoryStateMachine() { + RedissonClient redis = mock(RedissonClient.class); + RBucket bucket = mock(RBucket.class); + AihrMemoryService memory = mock(AihrMemoryService.class); + when(redis.getBucket(anyString())).thenReturn(bucket); + when(bucket.get()).thenReturn(null, 7L); + var service = new AihrAgentActionService(memory, redis); + var candidate = candidate(); + + var draft = service.register(candidate); + var request = new ConfirmMemoryRequest(1L, "agent-confirm-1234", candidate.draft(), false, "PRIVATE"); + when(memory.confirm(7L, request)).thenReturn(new ConfirmMemoryResponse("ASSISTANT_CAPTURE", 88L, 1)); + + var result = service.confirm(draft.draftId(), request); + + assertEquals("ASSISTANT_CAPTURE", result.targetDomain()); + assertEquals(88L, result.targetId()); + verify(memory).confirm(7L, request); + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + @Test + void dismissAlsoUsesTheExistingCandidateReference() { + RedissonClient redis = mock(RedissonClient.class); + RBucket bucket = mock(RBucket.class); + AihrMemoryService memory = mock(AihrMemoryService.class); + when(redis.getBucket(anyString())).thenReturn(bucket); + when(bucket.get()).thenReturn(7L); + when(memory.dismiss(7L)).thenReturn(candidate()); + var service = new AihrAgentActionService(memory, redis); + + var result = service.dismiss("0123456789012345678901234567890123456789012"); + + assertEquals(7L, result.id()); + verify(memory).dismiss(7L); + } + + private static MemoryCandidateResponse candidate() { + var draft = new MemoryDraft("P1", "3栋", null, "1201", "回访", "业主回访", + "3栋1201需要回访", null, "待跟进", null, null, null, null); + return new MemoryCandidateResponse(7L, 1, "DRAFT", "FOLLOW_UP", "ASSISTANT_CAPTURE", + draft, List.of(), "2026-07-24T21:00:00"); + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/AihrAgentMediaTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/AihrAgentMediaTest.java index ea9b4abe..62539124 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/AihrAgentMediaTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/AihrAgentMediaTest.java @@ -53,7 +53,8 @@ class AihrAgentMediaTest { Set.of("employee"), Set.of("P1"), "app"); } }, - query + query, + null ); } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/AihrAgentOrchestratorTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/AihrAgentOrchestratorTest.java index b145fbad..9e18f220 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/AihrAgentOrchestratorTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/AihrAgentOrchestratorTest.java @@ -9,6 +9,8 @@ import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryResponse; import org.dromara.aihr.knowledge.service.AihrKnowledgePrincipalResolver; import org.dromara.aihr.knowledge.service.AihrKnowledgeQueryService; import org.dromara.aihr.knowledge.service.AihrKnowledgeDataToolService.CurrentTaskSummary; +import org.dromara.aihr.memory.AihrMemoryDto.MemoryCandidateResponse; +import org.dromara.aihr.memory.AihrMemoryDto.MemoryDraft; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; @@ -71,7 +73,32 @@ class AihrAgentOrchestratorTest { assertEquals(AgentStatus.UNAVAILABLE, result.status()); } + @Test + void captureCandidateBecomesAnOpaqueConfirmationDraft() { + var candidate = new MemoryCandidateResponse(7L, 1, "DRAFT", "FOLLOW_UP", "ASSISTANT_CAPTURE", + new MemoryDraft("P1", "3栋", null, "1201", "回访", "业主回访", + "3栋1201需要回访", null, "待跟进", null, null, null, null), + List.of(), "2026-07-24T21:00:00"); + var query = new CapturingQueryService(response("", null, true).withMemoryCandidate(candidate)); + var action = new AihrAgentActionService(null, null) { + @Override + public AihrAgentDto.ActionDraft register(MemoryCandidateResponse ignored) { + return new AihrAgentDto.ActionDraft("opaque-draft", "ASSISTANT_CAPTURE", 1, candidate.draft()); + } + }; + + var result = orchestrator(query, action).handle(request("记一下,3栋1201需要回访")); + + assertEquals(AgentStatus.NEEDS_CONFIRMATION, result.status()); + assertEquals("opaque-draft", result.actionDraft().draftId()); + } + private static AihrAgentOrchestrator orchestrator(CapturingQueryService query) { + return orchestrator(query, null); + } + + private static AihrAgentOrchestrator orchestrator(CapturingQueryService query, + AihrAgentActionService actionService) { return new AihrAgentOrchestrator( new AihrAgentPlanner(null, new ObjectMapper()), new AihrAgentPolicy(), @@ -82,7 +109,8 @@ class AihrAgentOrchestratorTest { Set.of("employee"), Set.of("P1"), "app"); } }, - query + query, + actionService ); }