From d928a1968ae522dfce07b11e727ad5ec0bf710d8 Mon Sep 17 00:00:00 2001 From: zhuyongxin Date: Fri, 10 Jul 2026 20:29:56 +0800 Subject: [PATCH] feat(trace): bind feedback to runs --- .../ISS-010-session-run-trace-isolation.md | 2 +- .../session-run-trace-isolation/decisions.md | 13 +- .../session-run-trace-isolation/design.md | 5 +- .../phase-4-evidence.md | 124 +++++++++++++++ .../specs/session-run-trace-isolation/spec.md | 9 ++ .../session-run-trace-isolation/tasks.md | 14 +- .../agent/controller/FeedbackController.java | 6 +- .../superbiz/agent/dto/FeedbackRequest.java | 1 + .../superbiz/agent/dto/FeedbackResponse.java | 2 + .../agent/service/CaseLibraryService.java | 22 ++- .../agent/service/FeedbackService.java | 74 ++++++++- .../controller/FeedbackControllerTest.java | 43 ++++++ .../agent/service/CaseLibraryServiceTest.java | 90 +++++++++++ .../agent/service/FeedbackServiceTest.java | 145 ++++++++++++++++++ 14 files changed, 527 insertions(+), 23 deletions(-) create mode 100644 openspec/changes/session-run-trace-isolation/phase-4-evidence.md create mode 100644 src/test/java/com/superbiz/agent/controller/FeedbackControllerTest.java create mode 100644 src/test/java/com/superbiz/agent/service/CaseLibraryServiceTest.java create mode 100644 src/test/java/com/superbiz/agent/service/FeedbackServiceTest.java diff --git a/mvp/issues/active/ISS-010-session-run-trace-isolation.md b/mvp/issues/active/ISS-010-session-run-trace-isolation.md index 7f4301c..e79cae1 100644 --- a/mvp/issues/active/ISS-010-session-run-trace-isolation.md +++ b/mvp/issues/active/ISS-010-session-run-trace-isolation.md @@ -1,6 +1,6 @@ # ISS-010 同 session 多轮诊断 Trace 隔离 -**状态**:OpenSpec 已创建,Phase 1 已完成,Phase 2 进行中 +**状态**:OpenSpec 已创建,Phase 1-3 已提交,Phase 4 正在收口 gate / commit **严重程度**:高 **发现时间**:2026-07-10 **来源**:同一 `sessionId` 多轮 Chat E2E 验证 diff --git a/openspec/changes/session-run-trace-isolation/decisions.md b/openspec/changes/session-run-trace-isolation/decisions.md index 7f83a21..a4a6dde 100644 --- a/openspec/changes/session-run-trace-isolation/decisions.md +++ b/openspec/changes/session-run-trace-isolation/decisions.md @@ -2,7 +2,7 @@ ## sm-flow State -- Checkpoint: Apply / Phase 2 +- Checkpoint: Apply / Phase 5 ready - Scale: complex - Capability source: sm-flow built-in protocol for context/proposal; grill decisions are recorded from the confirmed user discussion in the issue thread. - Change slug: `session-run-trace-isolation` @@ -168,3 +168,14 @@ Audit conclusions: - Added lightweight `GET /api/chat/session/{sessionId}/runs` backed by `diagnosis_run` summaries. - Added focused tests for latest trace, exact first trace, exact second trace, wrong-session rejection, missing session, read-only trace behavior, run listing, and legacy fallback. - Phase 3 E2E used Maven startup with profile `mvp-demo`; evidence is recorded in `phase-3-evidence.md`. + +## Phase 4 Apply Notes + +- Capability source: `openspec-apply-change` + sm-flow apply protocol. `codebase-retrieval` and LSP tools were still unavailable; call-chain confirmation used OpenSpec context, `rg`, targeted file reads, focused tests, API E2E, DB inspection, and logs. +- Added `FeedbackRequest.runId` and `FeedbackResponse.runId/fallbackToLatestRun`. +- Changed `FeedbackController` to pass `runId` through to `FeedbackService`. +- Changed `FeedbackService` so new feedback prefers exact `sessionId + runId`, validates ownership, falls back to latest run when `runId` is omitted, and writes new feedback to `diagnosis_run.feedback`. +- Preserved a legacy `DiagnosisSession` fallback only when no `diagnosis_run` exists, so old data can still receive feedback during the migration window. +- Added `CaseLibraryService.createFromRun`, using `diagnosis_run.run_id` as the new automatic `case_library.diagnosis_id`; `createFromSession` remains the legacy session-id path. +- Phase 4 evidence is recorded in `phase-4-evidence.md`. +- Document review follow-up: clarified that the legacy `DiagnosisSession` feedback path only applies when no `diagnosis_run` exists for the session. It returns no bound `runId` and is not the same as latest-run fallback. diff --git a/openspec/changes/session-run-trace-isolation/design.md b/openspec/changes/session-run-trace-isolation/design.md index a567cbd..666cd18 100644 --- a/openspec/changes/session-run-trace-isolation/design.md +++ b/openspec/changes/session-run-trace-isolation/design.md @@ -100,12 +100,13 @@ Interface level: L4. - `/api/chat` response adds `runId`. - `/api/ai_ops` SSE emits a compatible metadata message before report content. It keeps the existing SSE event name `message` and sends a JSON `SseMessage` with `type=metadata`; the metadata payload includes `sessionId` and `runId`. Report content continues to stream through the existing `type=content` message shape. - Trace API accepts optional `runId`. -- Feedback request accepts preferred `runId`. Feedback response includes the actual bound `runId` and `fallbackToLatestRun`; wrong-session `runId`, missing session, and missing run use the existing failed feedback response path with HTTP 400 from `FeedbackController`. +- Feedback request accepts preferred `runId`. For run-backed data, feedback response includes the actual bound `runId` and `fallbackToLatestRun`; wrong-session `runId`, missing session, and missing run use the existing failed feedback response path with HTTP 400 from `FeedbackController`. Historical `DiagnosisSession` fallback is retained only when no `diagnosis_run` exists for the session; that legacy path has no bound `runId` and is not treated as latest-run fallback. - New run summary API: `GET /api/chat/session/{sessionId}/runs`, returned through the existing `ApiResponse>` wrapper. A session with metadata but no runs returns an empty list; a missing session returns the existing not-found/error behavior. Compatibility: -- Old `sessionId`-only trace and feedback calls bind to latest run. +- Old `sessionId`-only trace and feedback calls bind to latest run when run-backed data exists. +- Historical feedback calls for sessions with no `diagnosis_run` may still bind to retained `diagnosis_session` data during the migration window. - Old `diagnosis_session` is retained for rollback and historical comparison. - New code must not keep writing new execution state into `diagnosis_session` after the write switch. diff --git a/openspec/changes/session-run-trace-isolation/phase-4-evidence.md b/openspec/changes/session-run-trace-isolation/phase-4-evidence.md new file mode 100644 index 0000000..c9e19a0 --- /dev/null +++ b/openspec/changes/session-run-trace-isolation/phase-4-evidence.md @@ -0,0 +1,124 @@ +# Phase 4 Evidence: Feedback and Case Library Run Binding + +## Scope + +Phase 4 implements run-scoped feedback and run-based automatic case creation: + +- Feedback request accepts preferred `runId`. +- Feedback validates run/session ownership. +- Missing `runId` falls back to the latest run and returns `fallbackToLatestRun=true` plus the bound `runId`. +- New feedback persists to `diagnosis_run.feedback`. +- Useful feedback creates or reuses `case_library` from `diagnosis_run.query` and `diagnosis_run.answer`. +- New automatic `case_library.diagnosis_id` values store `run_id`; old `session_id` values remain supported through the legacy `DiagnosisSession` path. + +## Focused Tests + +Focused tests: + +```text +mvn -q "-Dtest=FeedbackServiceTest,CaseLibraryServiceTest,FeedbackControllerTest" test +``` + +Result: passed. + +Coverage: + +- exact `sessionId + runId` feedback updates the specified run; +- missing `runId` binds to latest run and returns `fallbackToLatestRun=true`; +- wrong-session `runId` fails without saving feedback or case data; +- useful feedback creates a case from the run; +- case creation is idempotent by `diagnosis_id`; +- legacy `DiagnosisSession` case creation still stores `diagnosis_id=session_id`; +- `FeedbackController` passes `request.runId` to `FeedbackService`. + +## E2E Feedback API Check + +Maven startup: + +```text +mvn spring-boot:run -Dspring-boot.run.profiles=mvp-demo +``` + +Captured artifacts: + +- `target/e2e/phase4-mvn-20260710-2016.out.log` +- `target/e2e/phase4-mvn-20260710-2016.err.log` +- `target/e2e/phase4-feedback-exact-run.json` +- `target/e2e/phase4-feedback-fallback-latest.json` +- `target/e2e/phase4-feedback-wrong-session.json` +- `target/e2e/phase4-summary.json` + +The Maven process was stopped after evidence collection. + +Inputs reused the Phase 3 E2E session: + +```text +sessionId = e2e-phase3-codex-20260710-1912 +run1 = run-fdccbe21-e050-4f92-a741-a062aef59644 +run2 = run-a9b883ab-9cab-4eec-accf-38129bd2eb94 +``` + +API responses: + +```text +exact run feedback: + status=200 + runId=run-fdccbe21-e050-4f92-a741-a062aef59644 + fallbackToLatestRun=false + caseId=428b0cf8-d3b4-4f8a-a82e-4e815999d3ff + +legacy fallback feedback: + status=200 + runId=run-a9b883ab-9cab-4eec-accf-38129bd2eb94 + fallbackToLatestRun=true + caseId=null + +wrong-session feedback: + status=400 + success=false + message=runId does not belong to sessionId +``` + +Log evidence from `phase4-mvn-20260710-2016.out.log`: + +- `反馈已记录: sessionId=e2e-phase3-codex-20260710-1912, runId=run-fdccbe21-e050-4f92-a741-a062aef59644, feedback=useful, fallbackToLatestRun=false, caseId=428b0cf8-d3b4-4f8a-a82e-4e815999d3ff` +- `反馈已记录: sessionId=e2e-phase3-codex-20260710-1912, runId=run-a9b883ab-9cab-4eec-accf-38129bd2eb94, feedback=not_useful, fallbackToLatestRun=true, caseId=null` + +## Database Inspection + +Queried through `scripts/query_mysql.py`. + +`diagnosis_run.feedback`: + +```text +run-a9b883ab-9cab-4eec-accf-38129bd2eb94 | feedback=not_useful | status=SUCCESS +run-fdccbe21-e050-4f92-a741-a062aef59644 | feedback=useful | status=SUCCESS +``` + +`case_library`: + +```text +case_id=428b0cf8-d3b4-4f8a-a82e-4e815999d3ff +diagnosis_id=run-fdccbe21-e050-4f92-a741-a062aef59644 +source_type=AUTO +title=支付接口最近出现超时,请结合知识库、日志和指标判断可能原因,并给出修复建议。 +``` + +No automatic case was created for the fallback `not_useful` feedback on run2. + +## Final Gate + +The final Phase 4 gate was rerun after document review follow-up: + +```text +mvn -q clean test-compile +mvn -q "-Dtest=FeedbackServiceTest,CaseLibraryServiceTest,FeedbackControllerTest,DiagnosisTraceServiceTest,ChatControllerTest" test +openspec validate session-run-trace-isolation --strict +git diff --check +``` + +Result: passed. + +## Conclusion + +Phase 4 satisfies run-scoped feedback, observable latest-run fallback, run-based useful case creation, and transitional old-data compatibility for `case_library.diagnosis_id`. diff --git a/openspec/changes/session-run-trace-isolation/specs/session-run-trace-isolation/spec.md b/openspec/changes/session-run-trace-isolation/specs/session-run-trace-isolation/spec.md index eedbbb0..31e6a3e 100644 --- a/openspec/changes/session-run-trace-isolation/specs/session-run-trace-isolation/spec.md +++ b/openspec/changes/session-run-trace-isolation/specs/session-run-trace-isolation/spec.md @@ -96,10 +96,19 @@ The system SHALL bind new feedback to a diagnosis run rather than an ambiguous m #### Scenario: Feedback without runId falls back observably - **WHEN** a legacy feedback request includes `sessionId` but omits `runId` +- **AND** at least one `diagnosis_run` exists for that session - **THEN** the system SHALL bind feedback to the latest run for that session - **AND** the response SHALL include `fallbackToLatestRun=true` - **AND** the response SHALL include the actual bound `runId` +#### Scenario: Historical feedback without run-backed data remains compatible +- **WHEN** a legacy feedback request includes `sessionId` but omits `runId` +- **AND** no `diagnosis_run` exists for that session +- **AND** a historical `diagnosis_session` row exists for that session +- **THEN** the system MAY bind feedback to the historical session row for migration compatibility +- **AND** the response SHALL NOT claim latest-run fallback +- **AND** the response MAY omit `runId` + #### Scenario: Feedback rejects run from another session - **WHEN** a feedback request includes a `runId` that belongs to a different `sessionId` - **THEN** the system SHALL return a failed feedback response instead of updating either run diff --git a/openspec/changes/session-run-trace-isolation/tasks.md b/openspec/changes/session-run-trace-isolation/tasks.md index 0aa7c78..f656c1d 100644 --- a/openspec/changes/session-run-trace-isolation/tasks.md +++ b/openspec/changes/session-run-trace-isolation/tasks.md @@ -32,13 +32,13 @@ ## 4. Feedback and Case Library Run Binding -- [ ] 4.1 Change feedback request handling to prefer `runId` and validate run/session ownership. -- [ ] 4.2 Implement legacy feedback fallback to latest run with observable `fallbackToLatestRun=true` and actual bound `runId`. -- [ ] 4.3 Change feedback persistence to update `diagnosis_run.feedback` for new data. -- [ ] 4.4 Change `CaseLibraryService` to create automatic cases from `diagnosis_run.query` and `diagnosis_run.answer`. -- [ ] 4.5 Preserve transitional case-library semantics where old `diagnosis_id` values may be `session_id` and new automatic values are `run_id`. -- [ ] 4.6 Add tests for run-specific feedback, legacy fallback, useful case creation, idempotency, and old-data compatibility. -- [ ] 4.7 Phase 4 gate: run focused feedback/case tests, inspect DB with `scripts/query_mysql.py`, update task status, archive phase evidence, and commit before starting Phase 5. +- [x] 4.1 Change feedback request handling to prefer `runId` and validate run/session ownership. +- [x] 4.2 Implement legacy feedback fallback to latest run with observable `fallbackToLatestRun=true` and actual bound `runId`. +- [x] 4.3 Change feedback persistence to update `diagnosis_run.feedback` for new data. +- [x] 4.4 Change `CaseLibraryService` to create automatic cases from `diagnosis_run.query` and `diagnosis_run.answer`. +- [x] 4.5 Preserve transitional case-library semantics where old `diagnosis_id` values may be `session_id` and new automatic values are `run_id`. +- [x] 4.6 Add tests for run-specific feedback, legacy fallback, useful case creation, idempotency, and old-data compatibility. +- [x] 4.7 Phase 4 gate: run focused feedback/case tests, inspect DB with `scripts/query_mysql.py`, update task status, archive phase evidence, and commit before starting Phase 5. ## 5. AIOps Run Isolation diff --git a/src/main/java/com/superbiz/agent/controller/FeedbackController.java b/src/main/java/com/superbiz/agent/controller/FeedbackController.java index 556443d..523f76c 100644 --- a/src/main/java/com/superbiz/agent/controller/FeedbackController.java +++ b/src/main/java/com/superbiz/agent/controller/FeedbackController.java @@ -16,7 +16,11 @@ public class FeedbackController { @PostMapping("/feedback") public ResponseEntity 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); } diff --git a/src/main/java/com/superbiz/agent/dto/FeedbackRequest.java b/src/main/java/com/superbiz/agent/dto/FeedbackRequest.java index 8bf8649..4ad9f26 100644 --- a/src/main/java/com/superbiz/agent/dto/FeedbackRequest.java +++ b/src/main/java/com/superbiz/agent/dto/FeedbackRequest.java @@ -7,5 +7,6 @@ import lombok.Setter; @Setter public class FeedbackRequest { private String sessionId; + private String runId; private String feedback; } diff --git a/src/main/java/com/superbiz/agent/dto/FeedbackResponse.java b/src/main/java/com/superbiz/agent/dto/FeedbackResponse.java index 226095b..06d24c8 100644 --- a/src/main/java/com/superbiz/agent/dto/FeedbackResponse.java +++ b/src/main/java/com/superbiz/agent/dto/FeedbackResponse.java @@ -9,4 +9,6 @@ public class FeedbackResponse { private boolean success; private String message; private String caseId; + private String runId; + private boolean fallbackToLatestRun; } diff --git a/src/main/java/com/superbiz/agent/service/CaseLibraryService.java b/src/main/java/com/superbiz/agent/service/CaseLibraryService.java index 09cad07..9b94a52 100644 --- a/src/main/java/com/superbiz/agent/service/CaseLibraryService.java +++ b/src/main/java/com/superbiz/agent/service/CaseLibraryService.java @@ -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; }); } diff --git a/src/main/java/com/superbiz/agent/service/FeedbackService.java b/src/main/java/com/superbiz/agent/service/FeedbackService.java index a78657e..ec914ef 100644 --- a/src/main/java/com/superbiz/agent/service/FeedbackService.java +++ b/src/main/java/com/superbiz/agent/service/FeedbackService.java @@ -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(); } } diff --git a/src/test/java/com/superbiz/agent/controller/FeedbackControllerTest.java b/src/test/java/com/superbiz/agent/controller/FeedbackControllerTest.java new file mode 100644 index 0000000..9b83889 --- /dev/null +++ b/src/test/java/com/superbiz/agent/controller/FeedbackControllerTest.java @@ -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 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"); + } +} diff --git a/src/test/java/com/superbiz/agent/service/CaseLibraryServiceTest.java b/src/test/java/com/superbiz/agent/service/CaseLibraryServiceTest.java new file mode 100644 index 0000000..d0e065b --- /dev/null +++ b/src/test/java/com/superbiz/agent/service/CaseLibraryServiceTest.java @@ -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 captor = ArgumentCaptor.forClass(CaseLibrary.class); + verify(caseLibraryRepository).save(captor.capture()); + assertEquals("legacy-session", captor.getValue().getDiagnosisId()); + assertEquals("legacy answer", captor.getValue().getRootCause()); + } +} diff --git a/src/test/java/com/superbiz/agent/service/FeedbackServiceTest.java b/src/test/java/com/superbiz/agent/service/FeedbackServiceTest.java new file mode 100644 index 0000000..1a34cce --- /dev/null +++ b/src/test/java/com/superbiz/agent/service/FeedbackServiceTest.java @@ -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(); + } +}