feat(agent): audit orchestrated runs

This commit is contained in:
2026-07-24 22:13:04 +08:00
parent 434bab6fed
commit 6ba4e06a2f
12 changed files with 286 additions and 13 deletions
@@ -0,0 +1,59 @@
package org.dromara.aihr.agent;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.aihr.agent.AihrAgentDto.AgentPlan;
import org.dromara.aihr.agent.AihrAgentDto.AgentResponse;
import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
@Slf4j
public class AihrAgentAuditService {
private final JdbcTemplate jdbcTemplate;
public void record(AihrKnowledgePrincipal principal, AgentPlan plan, AgentResponse response,
long durationMs, String errorCode) {
if (principal == null || plan == null || response == null) {
return;
}
try {
String sourceType = response.sourceSummary().isEmpty()
? null
: clean(response.sourceSummary().get(0).type(), 32);
String resultRef = response.actionDraft() == null
? null
: clean("DRAFT:" + response.actionDraft().type(), 100);
jdbcTemplate.update("""
insert into aihr_agent_run
(run_id, tenant_id, client_key, user_id, conversation_id, context_version,
intent, tool, status, source_type, duration_ms, error_code, result_ref, create_time)
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, now())
""",
clean(response.runId(), 64), clean(principal.tenantId(), 20),
clean(principal.clientKey(), 64), principal.userId(),
clean(response.conversationId(), 64), response.contextVersion(),
plan.intent().name(), plan.tool().name(), response.status().name(), sourceType,
Math.max(0, durationMs), safeCode(errorCode), resultRef
);
} catch (RuntimeException ignored) {
log.warn("agent audit write failed (details hidden)");
}
}
private static String safeCode(String value) {
String code = clean(value, 32);
return code != null && code.matches("[A-Za-z0-9_-]+") ? code : null;
}
private static String clean(String value, int maxLength) {
if (value == null || value.isBlank()) {
return null;
}
String cleaned = value.trim();
return cleaned.length() <= maxLength ? cleaned : cleaned.substring(0, maxLength);
}
}
@@ -9,6 +9,7 @@ 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;
import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryRequest;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryResponse;
import org.dromara.aihr.knowledge.service.AihrKnowledgeDataToolService.CurrentTaskSummary;
@@ -35,11 +36,51 @@ public class AihrAgentOrchestrator {
private final AihrKnowledgeQueryService queryService;
private final AihrAgentActionService actionService;
private final AihrWebAiService webAiService;
private final AihrAgentAuditService auditService;
public AgentResponse handle(AgentRequest request) {
requireQuestion(request);
AgentPlan plan = planner.plan(request.question(), false);
var principal = principalResolver.current();
return execute(request, null, false);
}
public AgentResponse handleMedia(AgentRequest request, MultipartFile file) {
return execute(request, file, true);
}
private AgentResponse execute(AgentRequest request, MultipartFile file, boolean media) {
long started = System.nanoTime();
AgentPlan plan = null;
AihrKnowledgePrincipal principal = null;
AgentResponse response = null;
String errorCode = null;
try {
requireQuestion(request);
if (media && (file == null || file.isEmpty())) {
throw new ServiceException("附件不能为空", 400);
}
plan = planner.plan(request.question(), media);
principal = principalResolver.current();
response = media
? executeMedia(plan, request, file, principal)
: executeText(plan, request, principal);
return response;
} catch (ServiceException ex) {
errorCode = ex.getCode() == null ? "REJECTED" : "HTTP_" + ex.getCode();
throw ex;
} catch (RuntimeException ex) {
errorCode = "INTERNAL";
throw ex;
} finally {
if (auditService != null && plan != null && principal != null) {
AgentResponse audited = response == null
? failed(plan, request, errorCode)
: response;
auditService.record(principal, plan, audited,
(System.nanoTime() - started) / 1_000_000L, errorCode);
}
}
}
private AgentResponse executeText(AgentPlan plan, AgentRequest request, AihrKnowledgePrincipal principal) {
if (plan.requiresExternalConsent() && !request.externalConsent()) {
return simple(plan, AgentStatus.NEEDS_INPUT,
"全网查询会将脱敏后的问题发送给外部公开检索服务。",
@@ -58,13 +99,9 @@ public class AihrAgentOrchestrator {
};
}
public AgentResponse handleMedia(AgentRequest request, MultipartFile file) {
requireQuestion(request);
if (file == null || file.isEmpty()) {
throw new ServiceException("附件不能为空", 400);
}
AgentPlan plan = planner.plan(request.question(), true);
policy.authorize(principalResolver.current(), plan, request.externalConsent());
private AgentResponse executeMedia(AgentPlan plan, AgentRequest request, MultipartFile file,
AihrKnowledgePrincipal principal) {
policy.authorize(principal, plan, request.externalConsent());
MediaMode mode = needsKnowledge(request.question())
? MediaMode.MEDIA_WITH_KNOWLEDGE
: MediaMode.MEDIA_ONLY;
@@ -157,6 +194,15 @@ public class AihrAgentOrchestrator {
);
}
private static AgentResponse failed(AgentPlan plan, AgentRequest request, String errorCode) {
AgentStatus status = "HTTP_403".equals(errorCode) ? AgentStatus.FORBIDDEN : AgentStatus.FAILED;
return new AgentResponse(
runId(), request == null ? null : request.conversationId(),
request == null ? null : request.contextVersion(), plan.intent(), status, "",
List.of(), List.of(), List.of(), null, null, null, List.of()
);
}
private static String runId() {
return "agent_run_" + UUID.randomUUID().toString().replace("-", "");
}
@@ -0,0 +1,83 @@
package org.dromara.aihr.agent;
import org.dromara.aihr.agent.AihrAgentDto.AgentPlan;
import org.dromara.aihr.agent.AihrAgentDto.AgentResponse;
import org.dromara.aihr.agent.AihrAgentDto.AgentStatus;
import org.dromara.aihr.agent.AihrAgentDto.Intent;
import org.dromara.aihr.agent.AihrAgentDto.ResponseStyle;
import org.dromara.aihr.agent.AihrAgentDto.SourceSummary;
import org.dromara.aihr.agent.AihrAgentDto.Tool;
import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import java.util.Arrays;
import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
@Tag("dev")
class AihrAgentAuditServiceTest {
@Test
void auditStoresOnlyMinimalMetadataWithoutQuestionOrAnswer() {
var jdbc = new CapturingJdbcTemplate(false);
var service = new AihrAgentAuditService(jdbc);
service.record(principal(), plan(), response(), 23L, null);
String stored = jdbc.sql + Arrays.toString(jdbc.args);
assertTrue(stored.contains("agent_run_test"));
assertTrue(stored.contains("MY_CURRENT_TASKS"));
assertFalse(stored.contains("敏感问题"));
assertFalse(stored.contains("敏感答案"));
}
@Test
void auditFailureNeverBreaksAValidResponse() {
var service = new AihrAgentAuditService(new CapturingJdbcTemplate(true));
assertDoesNotThrow(() -> service.record(principal(), plan(), response(), 23L, "INTERNAL"));
}
private static AihrKnowledgePrincipal principal() {
return new AihrKnowledgePrincipal("000000", 1L, "app_user", "employee-1",
Set.of("employee"), Set.of("P1"), "mobile");
}
private static AgentPlan plan() {
return new AgentPlan(Intent.LIVE_MY_WORK, "敏感问题", Tool.MY_CURRENT_TASKS,
false, false, ResponseStyle.FACT);
}
private static AgentResponse response() {
return new AgentResponse("agent_run_test", "conversation_1", 1L, Intent.LIVE_MY_WORK,
AgentStatus.COMPLETED, "敏感答案",
List.of(new SourceSummary("LIVE_DATA", "我的待办", "2026-07-24T20:00:00+08:00")),
List.of(), List.of(), null, null, null, List.of());
}
private static final class CapturingJdbcTemplate extends JdbcTemplate {
private final boolean fail;
private String sql;
private Object[] args;
private CapturingJdbcTemplate(boolean fail) {
this.fail = fail;
}
@Override
public int update(String sql, Object... args) {
if (fail) {
throw new IllegalStateException("database unavailable");
}
this.sql = sql;
this.args = args;
return 1;
}
}
}
@@ -55,6 +55,7 @@ class AihrAgentMediaTest {
},
query,
null,
null,
null
);
}
@@ -111,6 +111,7 @@ class AihrAgentOrchestratorTest {
},
query,
actionService,
null,
null
);
}
@@ -72,7 +72,8 @@ class AihrAgentWebResearchTest {
}
},
null,
web
web,
null
);
}