feat(feedback): 补提交 feedback 相关源码(漏提交的新建文件)

This commit is contained in:
zhuyongxin
2026-07-01 10:57:49 +08:00
parent bb44140901
commit 354ffc1947
9 changed files with 466 additions and 0 deletions
+157
View File
@@ -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 返回给用户的完整答案';