feat(trace): bind feedback to runs
This commit is contained in:
@@ -16,7 +16,11 @@ public class FeedbackController {
|
||||
|
||||
@PostMapping("/feedback")
|
||||
public ResponseEntity<FeedbackResponse> submitFeedback(@RequestBody FeedbackRequest request) {
|
||||
FeedbackResponse response = feedbackService.submitFeedback(request.getSessionId(), request.getFeedback());
|
||||
FeedbackResponse response = feedbackService.submitFeedback(
|
||||
request.getSessionId(),
|
||||
request.getRunId(),
|
||||
request.getFeedback()
|
||||
);
|
||||
if (!response.isSuccess()) {
|
||||
return ResponseEntity.badRequest().body(response);
|
||||
}
|
||||
|
||||
@@ -7,5 +7,6 @@ import lombok.Setter;
|
||||
@Setter
|
||||
public class FeedbackRequest {
|
||||
private String sessionId;
|
||||
private String runId;
|
||||
private String feedback;
|
||||
}
|
||||
|
||||
@@ -9,4 +9,6 @@ public class FeedbackResponse {
|
||||
private boolean success;
|
||||
private String message;
|
||||
private String caseId;
|
||||
private String runId;
|
||||
private boolean fallbackToLatestRun;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.superbiz.agent.service;
|
||||
|
||||
import com.superbiz.agent.domain.entity.CaseLibrary;
|
||||
import com.superbiz.agent.domain.entity.DiagnosisRun;
|
||||
import com.superbiz.agent.domain.entity.DiagnosisSession;
|
||||
import com.superbiz.agent.domain.enums.FaultCategory;
|
||||
import com.superbiz.agent.domain.enums.SourceType;
|
||||
@@ -20,22 +21,31 @@ public class CaseLibraryService {
|
||||
@Autowired
|
||||
private CaseLibraryRepository caseLibraryRepository;
|
||||
|
||||
public CaseLibrary createFromRun(DiagnosisRun run) {
|
||||
return createFromSource(run.getRunId(), run.getQuery(), run.getAnswer(), "runId=" + run.getRunId());
|
||||
}
|
||||
|
||||
public CaseLibrary createFromSession(DiagnosisSession session) {
|
||||
return caseLibraryRepository.findByDiagnosisId(session.getSessionId())
|
||||
return createFromSource(session.getSessionId(), session.getQuery(), session.getAnswer(),
|
||||
"sessionId=" + session.getSessionId());
|
||||
}
|
||||
|
||||
private CaseLibrary createFromSource(String diagnosisId, String query, String answer, String logContext) {
|
||||
return caseLibraryRepository.findByDiagnosisId(diagnosisId)
|
||||
.orElseGet(() -> {
|
||||
String content = session.getAnswer();
|
||||
String content = answer;
|
||||
if (content == null || content.isBlank()) {
|
||||
content = session.getQuery() + "\n(自动提取失败,请人工补充)";
|
||||
content = query + "\n(自动提取失败,请人工补充)";
|
||||
}
|
||||
|
||||
String title = session.getQuery();
|
||||
String title = query;
|
||||
if (title.length() > 100) {
|
||||
title = title.substring(0, 100);
|
||||
}
|
||||
|
||||
CaseLibrary caseLibrary = CaseLibrary.builder()
|
||||
.caseId(UUID.randomUUID().toString())
|
||||
.diagnosisId(session.getSessionId())
|
||||
.diagnosisId(diagnosisId)
|
||||
.sourceType(SourceType.AUTO)
|
||||
.faultCategory(FaultCategory.GENERAL)
|
||||
.title(title)
|
||||
@@ -46,7 +56,7 @@ public class CaseLibraryService {
|
||||
.build();
|
||||
|
||||
CaseLibrary saved = caseLibraryRepository.save(caseLibrary);
|
||||
logger.info("案例已沉淀: caseId={}, sessionId={}", saved.getCaseId(), session.getSessionId());
|
||||
logger.info("案例已沉淀: caseId={}, {}", saved.getCaseId(), logContext);
|
||||
return saved;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package com.superbiz.agent.service;
|
||||
|
||||
import com.superbiz.agent.domain.entity.DiagnosisRun;
|
||||
import com.superbiz.agent.domain.entity.DiagnosisSession;
|
||||
import com.superbiz.agent.dto.FeedbackResponse;
|
||||
import com.superbiz.agent.repository.DiagnosisRunRepository;
|
||||
import com.superbiz.agent.repository.DiagnosisSessionRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -19,20 +21,81 @@ public class FeedbackService {
|
||||
@Autowired
|
||||
private DiagnosisSessionRepository diagnosisSessionRepository;
|
||||
|
||||
@Autowired
|
||||
private DiagnosisRunRepository diagnosisRunRepository;
|
||||
|
||||
@Autowired
|
||||
private CaseLibraryService caseLibraryService;
|
||||
|
||||
public FeedbackResponse submitFeedback(String sessionId, String feedback) {
|
||||
return submitFeedback(sessionId, null, feedback);
|
||||
}
|
||||
|
||||
public FeedbackResponse submitFeedback(String sessionId, String runId, String feedback) {
|
||||
if (sessionId == null || sessionId.isBlank()) {
|
||||
return FeedbackResponse.builder().success(false).message("sessionId 不能为空").build();
|
||||
}
|
||||
if (!FEEDBACK_USEFUL.equals(feedback) && !FEEDBACK_NOT_USEFUL.equals(feedback)) {
|
||||
return FeedbackResponse.builder().success(false)
|
||||
.message("feedback 只能是 useful 或 not_useful").build();
|
||||
return FeedbackResponse.builder()
|
||||
.success(false)
|
||||
.message("feedback 只能是 useful 或 not_useful")
|
||||
.build();
|
||||
}
|
||||
|
||||
DiagnosisSession session = diagnosisSessionRepository.findBySessionId(sessionId)
|
||||
.orElse(null);
|
||||
if (runId != null && !runId.isBlank()) {
|
||||
return submitRunFeedback(sessionId, runId, feedback, false);
|
||||
}
|
||||
|
||||
return diagnosisRunRepository.findFirstBySessionIdOrderByCreatedAtDescIdDesc(sessionId)
|
||||
.map(run -> submitRunFeedback(sessionId, run.getRunId(), feedback, true))
|
||||
.orElseGet(() -> submitLegacySessionFeedback(sessionId, feedback));
|
||||
}
|
||||
|
||||
private FeedbackResponse submitRunFeedback(String sessionId,
|
||||
String runId,
|
||||
String feedback,
|
||||
boolean fallbackToLatestRun) {
|
||||
DiagnosisRun run = diagnosisRunRepository.findBySessionIdAndRunId(sessionId, runId).orElse(null);
|
||||
if (run == null) {
|
||||
if (diagnosisRunRepository.findByRunId(runId).isPresent()) {
|
||||
return FeedbackResponse.builder()
|
||||
.success(false)
|
||||
.message("runId does not belong to sessionId")
|
||||
.runId(runId)
|
||||
.fallbackToLatestRun(fallbackToLatestRun)
|
||||
.build();
|
||||
}
|
||||
return FeedbackResponse.builder()
|
||||
.success(false)
|
||||
.message("run 不存在")
|
||||
.runId(runId)
|
||||
.fallbackToLatestRun(fallbackToLatestRun)
|
||||
.build();
|
||||
}
|
||||
|
||||
run.setFeedback(feedback);
|
||||
|
||||
String caseId = null;
|
||||
if (FEEDBACK_USEFUL.equals(feedback)) {
|
||||
var caseLibrary = caseLibraryService.createFromRun(run);
|
||||
caseId = caseLibrary.getCaseId();
|
||||
}
|
||||
|
||||
diagnosisRunRepository.save(run);
|
||||
logger.info("反馈已记录: sessionId={}, runId={}, feedback={}, fallbackToLatestRun={}, caseId={}",
|
||||
sessionId, runId, feedback, fallbackToLatestRun, caseId);
|
||||
|
||||
return FeedbackResponse.builder()
|
||||
.success(true)
|
||||
.message("反馈已记录")
|
||||
.caseId(caseId)
|
||||
.runId(runId)
|
||||
.fallbackToLatestRun(fallbackToLatestRun)
|
||||
.build();
|
||||
}
|
||||
|
||||
private FeedbackResponse submitLegacySessionFeedback(String sessionId, String feedback) {
|
||||
DiagnosisSession session = diagnosisSessionRepository.findBySessionId(sessionId).orElse(null);
|
||||
if (session == null) {
|
||||
return FeedbackResponse.builder().success(false).message("会话不存在").build();
|
||||
}
|
||||
@@ -46,12 +109,13 @@ public class FeedbackService {
|
||||
}
|
||||
|
||||
diagnosisSessionRepository.save(session);
|
||||
logger.info("反馈已记录: sessionId={}, feedback={}, caseId={}", sessionId, feedback, caseId);
|
||||
logger.info("历史反馈已记录: sessionId={}, feedback={}, caseId={}", sessionId, feedback, caseId);
|
||||
|
||||
return FeedbackResponse.builder()
|
||||
.success(true)
|
||||
.message("反馈已记录")
|
||||
.caseId(caseId)
|
||||
.fallbackToLatestRun(false)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.superbiz.agent.controller;
|
||||
|
||||
import com.superbiz.agent.dto.FeedbackRequest;
|
||||
import com.superbiz.agent.dto.FeedbackResponse;
|
||||
import com.superbiz.agent.service.FeedbackService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class FeedbackControllerTest {
|
||||
|
||||
@Test
|
||||
void submitFeedbackPassesRunIdToService() {
|
||||
FeedbackController controller = new FeedbackController();
|
||||
FeedbackService feedbackService = mock(FeedbackService.class);
|
||||
ReflectionTestUtils.setField(controller, "feedbackService", feedbackService);
|
||||
|
||||
FeedbackRequest request = new FeedbackRequest();
|
||||
request.setSessionId("session-1");
|
||||
request.setRunId("run-1");
|
||||
request.setFeedback("useful");
|
||||
|
||||
when(feedbackService.submitFeedback("session-1", "run-1", "useful"))
|
||||
.thenReturn(FeedbackResponse.builder()
|
||||
.success(true)
|
||||
.runId("run-1")
|
||||
.fallbackToLatestRun(false)
|
||||
.build());
|
||||
|
||||
ResponseEntity<FeedbackResponse> response = controller.submitFeedback(request);
|
||||
|
||||
assertEquals(200, response.getStatusCode().value());
|
||||
assertTrue(response.getBody().isSuccess());
|
||||
assertEquals("run-1", response.getBody().getRunId());
|
||||
verify(feedbackService).submitFeedback("session-1", "run-1", "useful");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package com.superbiz.agent.service;
|
||||
|
||||
import com.superbiz.agent.domain.entity.CaseLibrary;
|
||||
import com.superbiz.agent.domain.entity.DiagnosisRun;
|
||||
import com.superbiz.agent.domain.entity.DiagnosisSession;
|
||||
import com.superbiz.agent.domain.enums.SourceType;
|
||||
import com.superbiz.agent.repository.CaseLibraryRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
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;
|
||||
|
||||
class CaseLibraryServiceTest {
|
||||
|
||||
private final CaseLibraryRepository caseLibraryRepository = mock(CaseLibraryRepository.class);
|
||||
private final CaseLibraryService service = new CaseLibraryService();
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
ReflectionTestUtils.setField(service, "caseLibraryRepository", caseLibraryRepository);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createFromRunStoresRunIdAsDiagnosisIdAndUsesRunContent() {
|
||||
DiagnosisRun run = DiagnosisRun.builder()
|
||||
.sessionId("session-1")
|
||||
.runId("run-1")
|
||||
.query("payment timeout")
|
||||
.answer("redis timeout caused payment latency")
|
||||
.build();
|
||||
when(caseLibraryRepository.findByDiagnosisId("run-1")).thenReturn(Optional.empty());
|
||||
when(caseLibraryRepository.save(any(CaseLibrary.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
CaseLibrary saved = service.createFromRun(run);
|
||||
|
||||
assertEquals("run-1", saved.getDiagnosisId());
|
||||
assertEquals("payment timeout", saved.getTitle());
|
||||
assertEquals("redis timeout caused payment latency", saved.getRootCause());
|
||||
assertEquals("redis timeout caused payment latency", saved.getSolution());
|
||||
assertEquals(SourceType.AUTO, saved.getSourceType());
|
||||
assertNotNull(saved.getCaseId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createFromRunReusesExistingCaseForIdempotency() {
|
||||
DiagnosisRun run = DiagnosisRun.builder()
|
||||
.runId("run-existing")
|
||||
.query("query")
|
||||
.answer("answer")
|
||||
.build();
|
||||
CaseLibrary existing = CaseLibrary.builder()
|
||||
.caseId("case-existing")
|
||||
.diagnosisId("run-existing")
|
||||
.build();
|
||||
when(caseLibraryRepository.findByDiagnosisId("run-existing")).thenReturn(Optional.of(existing));
|
||||
|
||||
CaseLibrary saved = service.createFromRun(run);
|
||||
|
||||
assertEquals("case-existing", saved.getCaseId());
|
||||
verify(caseLibraryRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createFromSessionPreservesLegacySessionIdSemantics() {
|
||||
DiagnosisSession session = DiagnosisSession.builder()
|
||||
.sessionId("legacy-session")
|
||||
.query("legacy query")
|
||||
.answer("legacy answer")
|
||||
.build();
|
||||
when(caseLibraryRepository.findByDiagnosisId("legacy-session")).thenReturn(Optional.empty());
|
||||
when(caseLibraryRepository.save(any(CaseLibrary.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
service.createFromSession(session);
|
||||
|
||||
ArgumentCaptor<CaseLibrary> captor = ArgumentCaptor.forClass(CaseLibrary.class);
|
||||
verify(caseLibraryRepository).save(captor.capture());
|
||||
assertEquals("legacy-session", captor.getValue().getDiagnosisId());
|
||||
assertEquals("legacy answer", captor.getValue().getRootCause());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package com.superbiz.agent.service;
|
||||
|
||||
import com.superbiz.agent.domain.entity.CaseLibrary;
|
||||
import com.superbiz.agent.domain.entity.DiagnosisRun;
|
||||
import com.superbiz.agent.domain.entity.DiagnosisSession;
|
||||
import com.superbiz.agent.dto.FeedbackResponse;
|
||||
import com.superbiz.agent.repository.DiagnosisRunRepository;
|
||||
import com.superbiz.agent.repository.DiagnosisSessionRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
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.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class FeedbackServiceTest {
|
||||
|
||||
private final DiagnosisSessionRepository diagnosisSessionRepository = mock(DiagnosisSessionRepository.class);
|
||||
private final DiagnosisRunRepository diagnosisRunRepository = mock(DiagnosisRunRepository.class);
|
||||
private final CaseLibraryService caseLibraryService = mock(CaseLibraryService.class);
|
||||
private final FeedbackService service = new FeedbackService();
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
ReflectionTestUtils.setField(service, "diagnosisSessionRepository", diagnosisSessionRepository);
|
||||
ReflectionTestUtils.setField(service, "diagnosisRunRepository", diagnosisRunRepository);
|
||||
ReflectionTestUtils.setField(service, "caseLibraryService", caseLibraryService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void submitFeedbackWithRunIdUpdatesSpecifiedRun() {
|
||||
DiagnosisRun run = run("session-1", "run-1");
|
||||
when(diagnosisRunRepository.findBySessionIdAndRunId("session-1", "run-1"))
|
||||
.thenReturn(Optional.of(run));
|
||||
|
||||
FeedbackResponse response = service.submitFeedback("session-1", "run-1", "not_useful");
|
||||
|
||||
assertTrue(response.isSuccess());
|
||||
assertEquals("run-1", response.getRunId());
|
||||
assertFalse(response.isFallbackToLatestRun());
|
||||
assertEquals("not_useful", run.getFeedback());
|
||||
verify(diagnosisRunRepository).save(run);
|
||||
verifyNoInteractions(caseLibraryService);
|
||||
verifyNoInteractions(diagnosisSessionRepository);
|
||||
}
|
||||
|
||||
@Test
|
||||
void submitFeedbackWithoutRunIdFallsBackToLatestRunObservably() {
|
||||
DiagnosisRun latest = run("session-1", "run-latest");
|
||||
when(diagnosisRunRepository.findFirstBySessionIdOrderByCreatedAtDescIdDesc("session-1"))
|
||||
.thenReturn(Optional.of(latest));
|
||||
when(diagnosisRunRepository.findBySessionIdAndRunId("session-1", "run-latest"))
|
||||
.thenReturn(Optional.of(latest));
|
||||
|
||||
FeedbackResponse response = service.submitFeedback("session-1", null, "not_useful");
|
||||
|
||||
assertTrue(response.isSuccess());
|
||||
assertEquals("run-latest", response.getRunId());
|
||||
assertTrue(response.isFallbackToLatestRun());
|
||||
assertEquals("not_useful", latest.getFeedback());
|
||||
verify(diagnosisRunRepository).save(latest);
|
||||
verify(diagnosisSessionRepository, never()).findBySessionId(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void submitFeedbackRejectsRunFromAnotherSession() {
|
||||
when(diagnosisRunRepository.findBySessionIdAndRunId("session-1", "run-other"))
|
||||
.thenReturn(Optional.empty());
|
||||
when(diagnosisRunRepository.findByRunId("run-other"))
|
||||
.thenReturn(Optional.of(run("session-2", "run-other")));
|
||||
|
||||
FeedbackResponse response = service.submitFeedback("session-1", "run-other", "useful");
|
||||
|
||||
assertFalse(response.isSuccess());
|
||||
assertEquals("run-other", response.getRunId());
|
||||
verify(diagnosisRunRepository, never()).save(any());
|
||||
verifyNoInteractions(caseLibraryService);
|
||||
verifyNoInteractions(diagnosisSessionRepository);
|
||||
}
|
||||
|
||||
@Test
|
||||
void usefulFeedbackCreatesCaseFromRun() {
|
||||
DiagnosisRun run = run("session-1", "run-useful");
|
||||
CaseLibrary caseLibrary = CaseLibrary.builder().caseId("case-1").build();
|
||||
when(diagnosisRunRepository.findBySessionIdAndRunId("session-1", "run-useful"))
|
||||
.thenReturn(Optional.of(run));
|
||||
when(caseLibraryService.createFromRun(run)).thenReturn(caseLibrary);
|
||||
|
||||
FeedbackResponse response = service.submitFeedback("session-1", "run-useful", "useful");
|
||||
|
||||
assertTrue(response.isSuccess());
|
||||
assertEquals("case-1", response.getCaseId());
|
||||
assertEquals("run-useful", response.getRunId());
|
||||
verify(caseLibraryService).createFromRun(run);
|
||||
verify(diagnosisRunRepository).save(run);
|
||||
}
|
||||
|
||||
@Test
|
||||
void legacySessionFallbackPreservesOldDataCompatibility() {
|
||||
DiagnosisSession session = DiagnosisSession.builder()
|
||||
.sessionId("legacy-session")
|
||||
.query("legacy query")
|
||||
.answer("legacy answer")
|
||||
.build();
|
||||
CaseLibrary caseLibrary = CaseLibrary.builder().caseId("legacy-case").build();
|
||||
when(diagnosisRunRepository.findFirstBySessionIdOrderByCreatedAtDescIdDesc("legacy-session"))
|
||||
.thenReturn(Optional.empty());
|
||||
when(diagnosisSessionRepository.findBySessionId("legacy-session")).thenReturn(Optional.of(session));
|
||||
when(caseLibraryService.createFromSession(session)).thenReturn(caseLibrary);
|
||||
|
||||
FeedbackResponse response = service.submitFeedback("legacy-session", null, "useful");
|
||||
|
||||
assertTrue(response.isSuccess());
|
||||
assertNull(response.getRunId());
|
||||
assertFalse(response.isFallbackToLatestRun());
|
||||
assertEquals("legacy-case", response.getCaseId());
|
||||
assertEquals("useful", session.getFeedback());
|
||||
verify(diagnosisSessionRepository).save(session);
|
||||
verify(caseLibraryService).createFromSession(session);
|
||||
}
|
||||
|
||||
private DiagnosisRun run(String sessionId, String runId) {
|
||||
return DiagnosisRun.builder()
|
||||
.sessionId(sessionId)
|
||||
.runId(runId)
|
||||
.query("query for " + runId)
|
||||
.answer("answer for " + runId)
|
||||
.status("SUCCESS")
|
||||
.agentFlow("CHAT")
|
||||
.createdAt(LocalDateTime.of(2026, 7, 10, 10, 0))
|
||||
.updatedAt(LocalDateTime.of(2026, 7, 10, 10, 1))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user