feat(feedback): 补提交 feedback 相关源码(漏提交的新建文件)
This commit is contained in:
@@ -0,0 +1,157 @@
|
|||||||
|
# 证据评分与用户反馈架构
|
||||||
|
|
||||||
|
## 一、整体架构
|
||||||
|
|
||||||
|
```
|
||||||
|
用户对话
|
||||||
|
↓
|
||||||
|
ChatService.executeChat / executeChatComplex
|
||||||
|
↓ SUCCESS 后写入 answer,异步触发
|
||||||
|
EvaluationService.evaluate(sessionId, answer)
|
||||||
|
└─ 读取 tool_invocation 事实 → 规则引擎 → 写 selfEvaluation
|
||||||
|
|
||||||
|
用户提交反馈
|
||||||
|
↓
|
||||||
|
POST /api/feedback { sessionId, feedback: "useful" | "not_useful" }
|
||||||
|
↓
|
||||||
|
FeedbackService.submitFeedback
|
||||||
|
├─ 写 DiagnosisSession.feedback
|
||||||
|
├─ useful → CaseLibraryService.createFromSession → 写 case_library
|
||||||
|
└─ not_useful → 仅写 feedback,status 不变
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、评分规则(evidence_score)
|
||||||
|
|
||||||
|
### 定位
|
||||||
|
|
||||||
|
`evidence_score` 衡量的是**证据收集充分度**,不是答案准确性。
|
||||||
|
|
||||||
|
- 能证明的:Agent 是否有尝试收集证据、检索是否命中
|
||||||
|
- 不能证明的:答案是否有幻觉、推理是否正确
|
||||||
|
|
||||||
|
### 数据来源
|
||||||
|
|
||||||
|
规则引擎只消费 `tool_invocation` 表的事实记录,不依赖 LLM 判断。
|
||||||
|
|
||||||
|
### 规则定义
|
||||||
|
|
||||||
|
| 规则名 | 条件 | delta |
|
||||||
|
|---|---|---|
|
||||||
|
| `no_tool_call` | 无任何工具调用 | 直接 0 分,不参与加权 |
|
||||||
|
| `execution_failed` | status = FAILED | 直接 0 分,不参与加权 |
|
||||||
|
| `has_successful_tool_call` | 至少 1 次成功调用 | +30 |
|
||||||
|
| `l0_exact_match` | 任意调用有 L0 精确匹配命中 | +35 |
|
||||||
|
| `l1_semantic_match` | 无 L0 命中但有 L1 语义匹配 | +20 |
|
||||||
|
| `retrieval_no_hit` | 有检索调用但无任何命中 | -10 |
|
||||||
|
| `all_tool_calls_failed` | 全部调用失败 | -20 |
|
||||||
|
|
||||||
|
> L0 和 L1 互斥取高优先级(L0 命中时跳过 L1 分支)。
|
||||||
|
|
||||||
|
### selfEvaluation 字段格式
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"evidence_score": 65,
|
||||||
|
"source": "rule",
|
||||||
|
"factors": [
|
||||||
|
{"name": "has_successful_tool_call", "delta": 30, "description": "有成功的工具调用(20次)"},
|
||||||
|
{"name": "l0_exact_match", "delta": 35, "description": "L0 精确匹配命中"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `evidence_score` | 0-100 整数 |
|
||||||
|
| `source` | 当前固定为 `"rule"`;预留 `"llm"` 供后续扩展 |
|
||||||
|
| `factors` | 命中的规则列表,含 name / delta / description |
|
||||||
|
| `llm_opinion` | 预留字段(未实现),LLM 观点叠加时在此扩展 |
|
||||||
|
|
||||||
|
### 已知边界
|
||||||
|
|
||||||
|
- 非检索工具(DateTimeTools、QueryMetricsTools 等)不写 `tool_invocation`,这类 session 的 evidence_score = 0,属于设计边界
|
||||||
|
- 评分为异步写入(`@Async`),失败时 `selfEvaluation` 保持 null,前端需处理 null
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、反馈机制
|
||||||
|
|
||||||
|
### API
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/feedback
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"sessionId": "xxx",
|
||||||
|
"feedback": "useful" | "not_useful"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "反馈已记录",
|
||||||
|
"caseId": "uuid 或 null"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 后端行为
|
||||||
|
|
||||||
|
| feedback 值 | 操作 |
|
||||||
|
|---|---|
|
||||||
|
| `useful` | 写 `DiagnosisSession.feedback = "useful"`,生成 `CaseLibrary` 记录,返回 caseId |
|
||||||
|
| `not_useful` | 写 `DiagnosisSession.feedback = "not_useful"`,status 不变 |
|
||||||
|
| 其他值 | 返回 HTTP 400 |
|
||||||
|
|
||||||
|
### 重要设计决策
|
||||||
|
|
||||||
|
**BAD_CASE 不改 status 字段**
|
||||||
|
|
||||||
|
`status` 表示执行状态(RUNNING/SUCCESS/FAILED),是独立维度,不能被质量标签覆盖。
|
||||||
|
查询 BadCase 使用:`WHERE feedback = 'not_useful'`
|
||||||
|
|
||||||
|
**useful 触发案例沉淀规则**
|
||||||
|
|
||||||
|
| CaseLibrary 字段 | 来源 |
|
||||||
|
|---|---|
|
||||||
|
| caseId | UUID |
|
||||||
|
| diagnosisId | DiagnosisSession.sessionId |
|
||||||
|
| sourceType | AUTO |
|
||||||
|
| faultCategory | GENERAL(暂时,后续人工补充) |
|
||||||
|
| title | query 前 100 字符 |
|
||||||
|
| rootCause / solution | DiagnosisSession.answer(完整答案) |
|
||||||
|
| createdBy | "system" |
|
||||||
|
|
||||||
|
**幂等性**:同一 sessionId 重复提交 useful,返回已有 caseId,不重复插入 case_library。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、数据库变更
|
||||||
|
|
||||||
|
### V008(新增)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE diagnosis_session ADD COLUMN answer LONGTEXT COMMENT 'Agent 返回给用户的完整答案';
|
||||||
|
```
|
||||||
|
|
||||||
|
### diagnosis_session 关键字段
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `answer` | LONGTEXT | Agent 完整回答,useful 案例沉淀的内容来源 |
|
||||||
|
| `self_evaluation` | JSON | 证据评分结果,格式见上 |
|
||||||
|
| `feedback` | VARCHAR(16) | useful / not_useful / null |
|
||||||
|
| `status` | VARCHAR(16) | 执行状态,不受 feedback 影响 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、扩展方向(Phase 2)
|
||||||
|
|
||||||
|
- **LLM 观点层**:在 `selfEvaluation` 的 `llm_opinion` 字段叠加 LLM 结构化观点(has_root_cause、has_solution 等),作为独立 factors,不改变现有规则逻辑
|
||||||
|
- **案例结构化字段**:useful 触发时自动提取 faultCategory / errorCode,替代暂时的 GENERAL
|
||||||
|
- **重复召回问题**:Executor Prompt 约束或工具层 session 维度去重(见 [ISS-001](../issues/ISS-001-duplicate-retrieval.md))
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.superbiz.agent.config;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.scheduling.annotation.EnableAsync;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
@EnableAsync
|
||||||
|
public class AsyncConfig {
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
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.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api")
|
||||||
|
public class FeedbackController {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private FeedbackService feedbackService;
|
||||||
|
|
||||||
|
@PostMapping("/feedback")
|
||||||
|
public ResponseEntity<FeedbackResponse> submitFeedback(@RequestBody FeedbackRequest request) {
|
||||||
|
FeedbackResponse response = feedbackService.submitFeedback(request.getSessionId(), request.getFeedback());
|
||||||
|
if (!response.isSuccess()) {
|
||||||
|
return ResponseEntity.badRequest().body(response);
|
||||||
|
}
|
||||||
|
return ResponseEntity.ok(response);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.superbiz.agent.dto;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public class FeedbackRequest {
|
||||||
|
private String sessionId;
|
||||||
|
private String feedback;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.superbiz.agent.dto;
|
||||||
|
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Builder
|
||||||
|
public class FeedbackResponse {
|
||||||
|
private boolean success;
|
||||||
|
private String message;
|
||||||
|
private String caseId;
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package com.superbiz.agent.service;
|
||||||
|
|
||||||
|
import com.superbiz.agent.domain.entity.CaseLibrary;
|
||||||
|
import com.superbiz.agent.domain.entity.DiagnosisSession;
|
||||||
|
import com.superbiz.agent.domain.enums.FaultCategory;
|
||||||
|
import com.superbiz.agent.domain.enums.SourceType;
|
||||||
|
import com.superbiz.agent.repository.CaseLibraryRepository;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class CaseLibraryService {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(CaseLibraryService.class);
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private CaseLibraryRepository caseLibraryRepository;
|
||||||
|
|
||||||
|
public CaseLibrary createFromSession(DiagnosisSession session) {
|
||||||
|
return caseLibraryRepository.findByDiagnosisId(session.getSessionId())
|
||||||
|
.orElseGet(() -> {
|
||||||
|
String content = session.getAnswer();
|
||||||
|
if (content == null || content.isBlank()) {
|
||||||
|
content = session.getQuery() + "\n(自动提取失败,请人工补充)";
|
||||||
|
}
|
||||||
|
|
||||||
|
String title = session.getQuery();
|
||||||
|
if (title.length() > 100) {
|
||||||
|
title = title.substring(0, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
CaseLibrary caseLibrary = CaseLibrary.builder()
|
||||||
|
.caseId(UUID.randomUUID().toString())
|
||||||
|
.diagnosisId(session.getSessionId())
|
||||||
|
.sourceType(SourceType.AUTO)
|
||||||
|
.faultCategory(FaultCategory.GENERAL)
|
||||||
|
.title(title)
|
||||||
|
.rootCause(content)
|
||||||
|
.solution(content)
|
||||||
|
.createdBy("system")
|
||||||
|
.referenceCount(0)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
CaseLibrary saved = caseLibraryRepository.save(caseLibrary);
|
||||||
|
logger.info("案例已沉淀: caseId={}, sessionId={}", saved.getCaseId(), session.getSessionId());
|
||||||
|
return saved;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
package com.superbiz.agent.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.superbiz.agent.domain.entity.DiagnosisSession;
|
||||||
|
import com.superbiz.agent.domain.entity.ToolInvocation;
|
||||||
|
import com.superbiz.agent.repository.DiagnosisSessionRepository;
|
||||||
|
import com.superbiz.agent.repository.ToolInvocationRepository;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.scheduling.annotation.Async;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 证据评分服务
|
||||||
|
*
|
||||||
|
* 当前实现:基于 tool_invocation 事实的规则引擎,输出 evidence_score(0-100)。
|
||||||
|
* 扩展预留:LLM 观点辅助评估(evaluateWithLlm),未来可叠加到 factors 中作为独立维度。
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class EvaluationService {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(EvaluationService.class);
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private DiagnosisSessionRepository diagnosisSessionRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ToolInvocationRepository toolInvocationRepository;
|
||||||
|
|
||||||
|
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
|
||||||
|
@Async
|
||||||
|
public void evaluate(String sessionId, String answer) {
|
||||||
|
diagnosisSessionRepository.findBySessionId(sessionId).ifPresent(session -> {
|
||||||
|
try {
|
||||||
|
List<ToolInvocation> toolInvocations = toolInvocationRepository.findBySessionId(sessionId);
|
||||||
|
String selfEvaluation = evaluateWithRules(session, toolInvocations);
|
||||||
|
session.setSelfEvaluation(selfEvaluation);
|
||||||
|
diagnosisSessionRepository.save(session);
|
||||||
|
logger.info("证据评分已写入: sessionId={}, result={}", sessionId, selfEvaluation);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("评分失败: sessionId={}", sessionId, e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// 规则引擎(事实层)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private String evaluateWithRules(DiagnosisSession session, List<ToolInvocation> invocations) {
|
||||||
|
List<Map<String, Object>> factors = new ArrayList<>();
|
||||||
|
|
||||||
|
if ("FAILED".equals(session.getStatus())) {
|
||||||
|
factors.add(factor("execution_failed", -100, "执行失败"));
|
||||||
|
return buildResult(0, factors);
|
||||||
|
}
|
||||||
|
|
||||||
|
int total = invocations.size();
|
||||||
|
long successCount = invocations.stream().filter(t -> Boolean.TRUE.equals(t.getSuccess())).count();
|
||||||
|
boolean hasRetrieval = invocations.stream().anyMatch(t -> t.getRetrievalLayer() != null);
|
||||||
|
boolean hasL0Hit = invocations.stream()
|
||||||
|
.anyMatch(t -> t.getL0MatchCount() != null && t.getL0MatchCount() > 0);
|
||||||
|
boolean hasL1Hit = invocations.stream()
|
||||||
|
.anyMatch(t -> t.getL1MatchCount() != null && t.getL1MatchCount() > 0);
|
||||||
|
|
||||||
|
if (total == 0) {
|
||||||
|
factors.add(factor("no_tool_call", 0, "无工具调用,无法评估证据充分度"));
|
||||||
|
return buildResult(0, factors);
|
||||||
|
}
|
||||||
|
|
||||||
|
int score = 0;
|
||||||
|
|
||||||
|
// 工具成功调用
|
||||||
|
if (successCount > 0) {
|
||||||
|
int delta = 30;
|
||||||
|
factors.add(factor("has_successful_tool_call", delta, "有成功的工具调用(" + successCount + "次)"));
|
||||||
|
score += delta;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检索命中(L0 精确匹配,证据最强)
|
||||||
|
if (hasL0Hit) {
|
||||||
|
int delta = 35;
|
||||||
|
factors.add(factor("l0_exact_match", delta, "L0 精确匹配命中"));
|
||||||
|
score += delta;
|
||||||
|
}
|
||||||
|
// 检索命中(L1 语义匹配)
|
||||||
|
else if (hasL1Hit) {
|
||||||
|
int delta = 20;
|
||||||
|
factors.add(factor("l1_semantic_match", delta, "L1 语义匹配命中"));
|
||||||
|
score += delta;
|
||||||
|
}
|
||||||
|
// 有检索但无命中
|
||||||
|
else if (hasRetrieval) {
|
||||||
|
int delta = -10;
|
||||||
|
factors.add(factor("retrieval_no_hit", delta, "检索工具调用但无匹配结果"));
|
||||||
|
score += delta;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全部工具调用失败
|
||||||
|
if (successCount == 0) {
|
||||||
|
int delta = -20;
|
||||||
|
factors.add(factor("all_tool_calls_failed", delta, "所有工具调用均失败"));
|
||||||
|
score += delta;
|
||||||
|
}
|
||||||
|
|
||||||
|
score = Math.max(0, Math.min(100, score));
|
||||||
|
return buildResult(score, factors);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> factor(String name, int delta, String description) {
|
||||||
|
return Map.of("name", name, "delta", delta, "description", description);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildResult(int score, List<Map<String, Object>> factors) {
|
||||||
|
try {
|
||||||
|
Map<String, Object> result = Map.of(
|
||||||
|
"evidence_score", score,
|
||||||
|
"source", "rule",
|
||||||
|
"factors", factors
|
||||||
|
// llm_opinion: null ← 预留字段,LLM 观点叠加时在此处扩展
|
||||||
|
);
|
||||||
|
return objectMapper.writeValueAsString(result);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("序列化评分结果失败", e);
|
||||||
|
return "{\"evidence_score\":0,\"source\":\"rule\",\"factors\":[]}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// 预留:LLM 观点辅助(Phase 2)
|
||||||
|
// 实现时在此处添加 evaluateWithLlm(session, answer) 方法,
|
||||||
|
// 返回结构化观点(如 has_root_cause、has_solution 等),
|
||||||
|
// 作为独立 factors 叠加到 buildResult 中,不改变现有规则逻辑。
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package com.superbiz.agent.service;
|
||||||
|
|
||||||
|
import com.superbiz.agent.domain.entity.DiagnosisSession;
|
||||||
|
import com.superbiz.agent.dto.FeedbackResponse;
|
||||||
|
import com.superbiz.agent.repository.DiagnosisSessionRepository;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class FeedbackService {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(FeedbackService.class);
|
||||||
|
|
||||||
|
private static final String FEEDBACK_USEFUL = "useful";
|
||||||
|
private static final String FEEDBACK_NOT_USEFUL = "not_useful";
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private DiagnosisSessionRepository diagnosisSessionRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private CaseLibraryService caseLibraryService;
|
||||||
|
|
||||||
|
public FeedbackResponse submitFeedback(String sessionId, 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
DiagnosisSession session = diagnosisSessionRepository.findBySessionId(sessionId)
|
||||||
|
.orElse(null);
|
||||||
|
if (session == null) {
|
||||||
|
return FeedbackResponse.builder().success(false).message("会话不存在").build();
|
||||||
|
}
|
||||||
|
|
||||||
|
session.setFeedback(feedback);
|
||||||
|
|
||||||
|
String caseId = null;
|
||||||
|
if (FEEDBACK_USEFUL.equals(feedback)) {
|
||||||
|
var caseLibrary = caseLibraryService.createFromSession(session);
|
||||||
|
caseId = caseLibrary.getCaseId();
|
||||||
|
}
|
||||||
|
|
||||||
|
diagnosisSessionRepository.save(session);
|
||||||
|
logger.info("反馈已记录: sessionId={}, feedback={}, caseId={}", sessionId, feedback, caseId);
|
||||||
|
|
||||||
|
return FeedbackResponse.builder()
|
||||||
|
.success(true)
|
||||||
|
.message("反馈已记录")
|
||||||
|
.caseId(caseId)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE diagnosis_session ADD COLUMN answer LONGTEXT COMMENT 'Agent 返回给用户的完整答案';
|
||||||
Reference in New Issue
Block a user