feat(trace): bind feedback to runs
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# ISS-010 同 session 多轮诊断 Trace 隔离
|
# ISS-010 同 session 多轮诊断 Trace 隔离
|
||||||
|
|
||||||
**状态**:OpenSpec 已创建,Phase 1 已完成,Phase 2 进行中
|
**状态**:OpenSpec 已创建,Phase 1-3 已提交,Phase 4 正在收口 gate / commit
|
||||||
**严重程度**:高
|
**严重程度**:高
|
||||||
**发现时间**:2026-07-10
|
**发现时间**:2026-07-10
|
||||||
**来源**:同一 `sessionId` 多轮 Chat E2E 验证
|
**来源**:同一 `sessionId` 多轮 Chat E2E 验证
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## sm-flow State
|
## sm-flow State
|
||||||
|
|
||||||
- Checkpoint: Apply / Phase 2
|
- Checkpoint: Apply / Phase 5 ready
|
||||||
- Scale: complex
|
- 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.
|
- 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`
|
- 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 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.
|
- 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 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.
|
||||||
|
|||||||
@@ -100,12 +100,13 @@ Interface level: L4.
|
|||||||
- `/api/chat` response adds `runId`.
|
- `/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.
|
- `/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`.
|
- 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<List<RunSummary>>` wrapper. A session with metadata but no runs returns an empty list; a missing session returns the existing not-found/error behavior.
|
- New run summary API: `GET /api/chat/session/{sessionId}/runs`, returned through the existing `ApiResponse<List<RunSummary>>` wrapper. A session with metadata but no runs returns an empty list; a missing session returns the existing not-found/error behavior.
|
||||||
|
|
||||||
Compatibility:
|
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.
|
- 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.
|
- New code must not keep writing new execution state into `diagnosis_session` after the write switch.
|
||||||
|
|
||||||
|
|||||||
@@ -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`.
|
||||||
@@ -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
|
#### Scenario: Feedback without runId falls back observably
|
||||||
- **WHEN** a legacy feedback request includes `sessionId` but omits `runId`
|
- **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
|
- **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 `fallbackToLatestRun=true`
|
||||||
- **AND** the response SHALL include the actual bound `runId`
|
- **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
|
#### Scenario: Feedback rejects run from another session
|
||||||
- **WHEN** a feedback request includes a `runId` that belongs to a different `sessionId`
|
- **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
|
- **THEN** the system SHALL return a failed feedback response instead of updating either run
|
||||||
|
|||||||
@@ -32,13 +32,13 @@
|
|||||||
|
|
||||||
## 4. Feedback and Case Library Run Binding
|
## 4. Feedback and Case Library Run Binding
|
||||||
|
|
||||||
- [ ] 4.1 Change feedback request handling to prefer `runId` and validate run/session ownership.
|
- [x] 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`.
|
- [x] 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.
|
- [x] 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`.
|
- [x] 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`.
|
- [x] 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.
|
- [x] 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.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
|
## 5. AIOps Run Isolation
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,11 @@ public class FeedbackController {
|
|||||||
|
|
||||||
@PostMapping("/feedback")
|
@PostMapping("/feedback")
|
||||||
public ResponseEntity<FeedbackResponse> submitFeedback(@RequestBody FeedbackRequest request) {
|
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()) {
|
if (!response.isSuccess()) {
|
||||||
return ResponseEntity.badRequest().body(response);
|
return ResponseEntity.badRequest().body(response);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,5 +7,6 @@ import lombok.Setter;
|
|||||||
@Setter
|
@Setter
|
||||||
public class FeedbackRequest {
|
public class FeedbackRequest {
|
||||||
private String sessionId;
|
private String sessionId;
|
||||||
|
private String runId;
|
||||||
private String feedback;
|
private String feedback;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,4 +9,6 @@ public class FeedbackResponse {
|
|||||||
private boolean success;
|
private boolean success;
|
||||||
private String message;
|
private String message;
|
||||||
private String caseId;
|
private String caseId;
|
||||||
|
private String runId;
|
||||||
|
private boolean fallbackToLatestRun;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.superbiz.agent.service;
|
package com.superbiz.agent.service;
|
||||||
|
|
||||||
import com.superbiz.agent.domain.entity.CaseLibrary;
|
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.entity.DiagnosisSession;
|
||||||
import com.superbiz.agent.domain.enums.FaultCategory;
|
import com.superbiz.agent.domain.enums.FaultCategory;
|
||||||
import com.superbiz.agent.domain.enums.SourceType;
|
import com.superbiz.agent.domain.enums.SourceType;
|
||||||
@@ -20,22 +21,31 @@ public class CaseLibraryService {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private CaseLibraryRepository caseLibraryRepository;
|
private CaseLibraryRepository caseLibraryRepository;
|
||||||
|
|
||||||
|
public CaseLibrary createFromRun(DiagnosisRun run) {
|
||||||
|
return createFromSource(run.getRunId(), run.getQuery(), run.getAnswer(), "runId=" + run.getRunId());
|
||||||
|
}
|
||||||
|
|
||||||
public CaseLibrary createFromSession(DiagnosisSession session) {
|
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(() -> {
|
.orElseGet(() -> {
|
||||||
String content = session.getAnswer();
|
String content = answer;
|
||||||
if (content == null || content.isBlank()) {
|
if (content == null || content.isBlank()) {
|
||||||
content = session.getQuery() + "\n(自动提取失败,请人工补充)";
|
content = query + "\n(自动提取失败,请人工补充)";
|
||||||
}
|
}
|
||||||
|
|
||||||
String title = session.getQuery();
|
String title = query;
|
||||||
if (title.length() > 100) {
|
if (title.length() > 100) {
|
||||||
title = title.substring(0, 100);
|
title = title.substring(0, 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
CaseLibrary caseLibrary = CaseLibrary.builder()
|
CaseLibrary caseLibrary = CaseLibrary.builder()
|
||||||
.caseId(UUID.randomUUID().toString())
|
.caseId(UUID.randomUUID().toString())
|
||||||
.diagnosisId(session.getSessionId())
|
.diagnosisId(diagnosisId)
|
||||||
.sourceType(SourceType.AUTO)
|
.sourceType(SourceType.AUTO)
|
||||||
.faultCategory(FaultCategory.GENERAL)
|
.faultCategory(FaultCategory.GENERAL)
|
||||||
.title(title)
|
.title(title)
|
||||||
@@ -46,7 +56,7 @@ public class CaseLibraryService {
|
|||||||
.build();
|
.build();
|
||||||
|
|
||||||
CaseLibrary saved = caseLibraryRepository.save(caseLibrary);
|
CaseLibrary saved = caseLibraryRepository.save(caseLibrary);
|
||||||
logger.info("案例已沉淀: caseId={}, sessionId={}", saved.getCaseId(), session.getSessionId());
|
logger.info("案例已沉淀: caseId={}, {}", saved.getCaseId(), logContext);
|
||||||
return saved;
|
return saved;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package com.superbiz.agent.service;
|
package com.superbiz.agent.service;
|
||||||
|
|
||||||
|
import com.superbiz.agent.domain.entity.DiagnosisRun;
|
||||||
import com.superbiz.agent.domain.entity.DiagnosisSession;
|
import com.superbiz.agent.domain.entity.DiagnosisSession;
|
||||||
import com.superbiz.agent.dto.FeedbackResponse;
|
import com.superbiz.agent.dto.FeedbackResponse;
|
||||||
|
import com.superbiz.agent.repository.DiagnosisRunRepository;
|
||||||
import com.superbiz.agent.repository.DiagnosisSessionRepository;
|
import com.superbiz.agent.repository.DiagnosisSessionRepository;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
@@ -19,20 +21,81 @@ public class FeedbackService {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private DiagnosisSessionRepository diagnosisSessionRepository;
|
private DiagnosisSessionRepository diagnosisSessionRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private DiagnosisRunRepository diagnosisRunRepository;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private CaseLibraryService caseLibraryService;
|
private CaseLibraryService caseLibraryService;
|
||||||
|
|
||||||
public FeedbackResponse submitFeedback(String sessionId, String feedback) {
|
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()) {
|
if (sessionId == null || sessionId.isBlank()) {
|
||||||
return FeedbackResponse.builder().success(false).message("sessionId 不能为空").build();
|
return FeedbackResponse.builder().success(false).message("sessionId 不能为空").build();
|
||||||
}
|
}
|
||||||
if (!FEEDBACK_USEFUL.equals(feedback) && !FEEDBACK_NOT_USEFUL.equals(feedback)) {
|
if (!FEEDBACK_USEFUL.equals(feedback) && !FEEDBACK_NOT_USEFUL.equals(feedback)) {
|
||||||
return FeedbackResponse.builder().success(false)
|
return FeedbackResponse.builder()
|
||||||
.message("feedback 只能是 useful 或 not_useful").build();
|
.success(false)
|
||||||
|
.message("feedback 只能是 useful 或 not_useful")
|
||||||
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
DiagnosisSession session = diagnosisSessionRepository.findBySessionId(sessionId)
|
if (runId != null && !runId.isBlank()) {
|
||||||
.orElse(null);
|
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) {
|
if (session == null) {
|
||||||
return FeedbackResponse.builder().success(false).message("会话不存在").build();
|
return FeedbackResponse.builder().success(false).message("会话不存在").build();
|
||||||
}
|
}
|
||||||
@@ -46,12 +109,13 @@ public class FeedbackService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
diagnosisSessionRepository.save(session);
|
diagnosisSessionRepository.save(session);
|
||||||
logger.info("反馈已记录: sessionId={}, feedback={}, caseId={}", sessionId, feedback, caseId);
|
logger.info("历史反馈已记录: sessionId={}, feedback={}, caseId={}", sessionId, feedback, caseId);
|
||||||
|
|
||||||
return FeedbackResponse.builder()
|
return FeedbackResponse.builder()
|
||||||
.success(true)
|
.success(true)
|
||||||
.message("反馈已记录")
|
.message("反馈已记录")
|
||||||
.caseId(caseId)
|
.caseId(caseId)
|
||||||
|
.fallbackToLatestRun(false)
|
||||||
.build();
|
.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