feat(trace): bind feedback to runs

This commit is contained in:
zhuyongxin
2026-07-10 20:29:56 +08:00
parent 027aed1eeb
commit d928a1968a
14 changed files with 527 additions and 23 deletions
@@ -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();
}
}