Compare commits

...
7 Commits
Author SHA1 Message Date
zhuyongxin 2a7164288f chore(docs): 补充 ISS-001 架构设计文档到 mvp
- 新增 mvp/architecture/session-dedup-knowledge-map.md
- 更新 mvp/README.md 文档导航
- ISS-001 issue 关联架构文档
2026-07-01 18:28:19 +08:00
zhuyongxin a1c896ebda chore(docs): 归档 ISS-001 session-dedup-knowledge-map + ISS-002 mvp 文档
- 移动 session-dedup-knowledge-map OpenSpec 到 archive 目录
- 提交 ISS-001 遗留的 devflow 档案文件
- 更新 ISS-002 状态为已修复
- 新增 mvp/architecture/action-memory-relevance.md 设计文档
- 更新 mvp/README.md 文档导航
- 更新 devflow/index.md OpenSpec 链接指向 archive
2026-07-01 18:27:04 +08:00
zhuyongxin e438df4355 feat(knowledge): Executor 行动记忆 + 归一化质量等级解决 ISS-002 重复检索
- RetrievedDocTracker 升级为域级+文档级双层记录(Map<sessionId, Map<domain, Set<filePath>>>)
- LookupKnowledgeTool 新增 Min-Max 归一化层(BGE-M3 L2 距离→[0,1] similarity)
- 三等级 relevanceLevel:PRECISE / HIGHLY_RELEVANT / REFERENCE + completenessHint 兜底信号
- LookupResult 新增 relevanceLevel、completenessHint、retrievedDomainsThisSession
- Executor prompt 重写:4 条检索约束 + 合法出口不查全不追责,重复检索才惩罚
- 入库可观测性:V010 迁移 + retrieval_details JSON 扩展
- 归档 executor-action-memory-relevance change
2026-07-01 18:24:41 +08:00
zhuyongxin e4f37cb9e6 fix(knowledge): 修复循环依赖 + 归档 session-dedup-knowledge-map + 记录 ISS-002
- KnowledgeIndexService: 域级生成从 @PostConstruct 移到 @EventListener(ApplicationReadyEvent),解决 KnowledgeIndexService ↔ KnowledgeDomainService 循环依赖
- devflow 归档: evidence.md + acceptance.md(含运行验证结果)
- devflow/index.md: session-dedup-knowledge-map 状态改为 archived
- openspec .archive-ready 标记
- mvp/issues/ISS-002: Executor 无约束重复调用 lookup_knowledge
2026-07-01 14:26:59 +08:00
zhuyongxin 354ffc1947 feat(feedback): 补提交 feedback 相关源码(漏提交的新建文件) 2026-07-01 10:57:49 +08:00
zhuyongxin bb44140901 feat(knowledge): 会话级去重 + 知识域地图注入 Planner 解决 ISS-001 重复检索
- RetrievedDocTracker: sessionId → Set<filePath> 会话级去重,LookupKnowledgeTool Step 5 过滤已检索文档
- KnowledgeDomainService: 域级聚合,LLM 生成 when_to_retrieve,构建 knowledge map YAML
- DocumentFieldEnricher: 上传时 LLM 补全 covers + whenToRetrieve(含同域文档排除上下文)
- KnowledgeDomain entity + V009 迁移: 域级元数据持久化,避免重启重复 LLM 调用
- ChatService: 注入 knowledge map 到 Planner prompt,会话结束时清理去重状态
- KnowledgeIndexService: 手写 JSON 解析替换为 Jackson ObjectMapper,启动时补建缺失域记录
- chat-planner-prompt: 新增知识库检索规则(按域 when_to_retrieve 判断,每域最多一次检索)
- doc-field-enricher-prompt / domain-summary-prompt: 外部化 LLM 提示词
2026-07-01 10:47:46 +08:00
zhuyongxin 2a796da490 feat(feedback): 置信度评分与用户反馈机制 & 归档 confidence-feedback change 2026-06-30 18:01:06 +08:00
74 changed files with 4208 additions and 194 deletions
+1
View File
@@ -55,3 +55,4 @@ uploads/
/volumes
/server.pid
.claude/settings.local.json
.opencode/plugins/emdash-notifications.js
+3
View File
@@ -9,3 +9,6 @@
| 2026-06-24 | lookup-knowledge-integration | 知识库检索 | L0精确匹配, L1语义检索, frontmatter, 混合检索 | archived |
| 2026-06-25 | doc-management-ui | 前端开发/文档管理 | 文档管理页面, CRUD, 状态监控, 纯静态页面, API集成 | archived |
| 2026-06-26 | session-storage | 会话存储/可观测 | diagnosis_session, agent_step, tool_invocation, token追踪, 多Agent路由 | openspec/changes/session-storage | archived |
| 2026-06-29 | confidence-feedback | 质量评估/反馈机制 | evidence_score, selfEvaluation, feedback, useful, not_useful, case_library, BAD_CASE, tool_invocation规则引擎, 反馈按钮, sessionId回传 | openspec/changes/confidence-feedback | archived |
| 2026-06-30 | session-dedup-knowledge-map | 去重/知识图谱 | RetrievedDocTracker, KnowledgeDomainService, knowledge_domain, covers, whenToRetrieve, Planner注入, ISS-001 | openspec/changes/archive/2026-06-30-session-dedup-knowledge-map | archived |
| 2026-07-01 | executor-action-memory-relevance | 检索质量/行动记忆 | relevanceLevel, completenessHint, Min-Max归一化, RetrievedDocTracker域级记录, Executor检索约束, ISS-002 | openspec/changes/archive/2026-07-01-executor-action-memory-relevance | archived |
@@ -0,0 +1,64 @@
# acceptance.md — confidence-feedback
## 实现清单
| 任务 | 文件 | 状态 |
|---|---|---|
| T0:Flyway V008 + answer 字段 | `V008__add_answer_to_diagnosis_session.sql`、`DiagnosisSession.java` | 完成 |
| T1:EvaluationService(规则引擎) | `EvaluationService.java` | 完成 |
| T2:ChatService 后置调用 | `ChatService.java` | 完成 |
| T3:FeedbackController + FeedbackService | `FeedbackController.java`、`FeedbackService.java`、`FeedbackRequest.java`、`FeedbackResponse.java` | 完成 |
| T4:CaseLibraryService | `CaseLibraryService.java` | 完成 |
| T5:AsyncConfig | `AsyncConfig.java` | 完成 |
## 验证记录
### 静态验证(已通过)
- `mvn compile` BUILD SUCCESS(2026-06-30)
- 无新增 ERROR,存量 WARNING 与本次改动无关
- import 完整性人工检查通过
### 脚本验证(已通过,2026-06-30)
验证工具:`scripts/query_mysql.py`(本次新建)
| 步骤 | 操作 | 结果 |
|---|---|---|
| 1 | POST /api/chat 发送问题 | 200,answer 有值 |
| 2 | 等 5 秒查 diagnosis_session | self_evaluation 写入规则引擎结果,answer 写入完整回答 |
| 3 | POST /api/feedback useful | 200,返回 caseId;case_library 新增一行,feedback=useful,status=SUCCESS |
| 4 | POST /api/feedback not_useful | 200,feedback=not_useful,status 仍为 SUCCESS(未被改写) |
| 5(边界)| 重复提交 useful | 返回同一 caseId,case_library 无重复插入 |
| 6(边界)| 非法 feedback 值 | HTTP 400 |
### Flyway V008 迁移
- 服务启动后 diagnosis_session 表存在 answer 列,验证通过(步骤 2 能写入 answer)
### 浏览器/人工验证(已通过,2026-06-30)
| 步骤 | 操作 | 结果 |
|---|---|---|
| 1 | 发送"今天天气怎么样" | AI 回复下方出现"有用/无用"按钮 |
| 2 | 点击"有用" | 按钮区域替换为"已标记为有用" |
| 3 | 网络请求确认 | POST /api/feedback 返回 HTTP 200,`success: true` |
### 前端反馈按钮(追加,2026-06-30)
**改动文件**:`app.js`、`styles.css`
关键设计:
- `ChatResult` record 新增(`ChatService`),`ChatResponse` 增加 `sessionId` 字段(`ChatController`)
- `sendQuickMessage` 读取 `chatResponse.sessionId` 存为 `this.lastSessionId`
- `createFeedbackBar(sessionId)` 闭包绑定 sessionId,避免多轮对话时 sessionId 错位
- `submitFeedback(feedback, barElement, sessionId)` 直接用传入参数,不依赖全局状态
- 流式模式(`/api/chat_stream`)反馈按钮会渲染,但 sessionId 为空,点击不生效(已知限制)
## 已知限制
- 非检索工具(DateTimeTools 等)不写 tool_invocation,evidence_score = 0(已接受,符合"证据充分度"定义)
- `@Async` 失败时 selfEvaluation 为 null,前端需处理 null(已接受)
- CaseLibrary 的 faultCategory 固定为 GENERAL,需人工补充(已接受,Phase 2 优化)
- LLM 观点层未实现,selfEvaluation JSON 预留 llm_opinion 扩展位(Phase 2)
- 流式模式反馈按钮 sessionId 缺失,暂不处理(已知,后续处理流式接口时一并解决)
@@ -0,0 +1,37 @@
# brief.md — confidence-feedback
## 背景
DiagnosisSession 已预留 `selfEvaluation`(JSON)和 `feedback`(VARCHAR 16)两个字段,但完全为空。Agent 完成对话后不计算证据评分,也没有接收用户反馈的 API,无法支撑报告质量评估和 BadCase 追踪。
## 目标
1. 给每次对话结果自动打一个基于事实的证据充分度评分(evidence_score)
2. 提供用户反馈 API(useful/not_useful),useful 触发案例自动沉淀,not_useful 标记 BadCase
## 范围
- `DiagnosisSession` 加 `answer` 字段(Flyway V008)
- `EvaluationService`:基于 tool_invocation 的规则引擎,@Async 写 selfEvaluation
- `FeedbackController` + `FeedbackService`:POST /api/feedback
- `CaseLibraryService.createFromSession`:幂等案例沉淀
- `AsyncConfig`:@EnableAsync
- `ChatService`:SUCCESS 分支写 answer + 触发 evaluate;新增 `ChatResult` record 回传 sessionId
- `ChatController.ChatResponse` 增加 `sessionId` 字段
- 前端 `app.js`:AI 回复下方反馈按钮,点击调用 `/api/feedback`,闭包绑定 sessionId
- 前端 `styles.css`:反馈栏样式
## 非目标
- 不实现 Verifier Agent 完整链路
- 不实现 LLM 自评(预留扩展位,Phase 2 再做)
- 不实现案例结构化字段自动填充(faultCategory 等暂时填 GENERAL)
- 不实现 BadCase 自动分析或 Prompt 优化
## 分档
standard
## 关联 OpenSpec
`openspec/changes/confidence-feedback/`
@@ -0,0 +1,115 @@
# decisions.md — confidence-feedback
## Question Pool(grill 阶段)
| # | 问题 | 模式 | 状态 |
|---|---|---|---|
| Q1 | 置信度由谁计算 | user-interview | 已确认 |
| Q2 | 反馈触发哪些后端操作 | user-interview | 已确认 |
| Q3 | CaseLibrary 结构化字段从哪里填 | evidence-driven | 已确认(方案变更) |
| Q4 | 验收口径 | user-interview | 已确认 |
---
## Evidence-Driven 结论
### Q3:CaseLibrary 内容来源
**初始结论**:从 `agent_step.thought` 提取(grill 阶段)
**修正(apply 阶段讨论后)**:
- 代码证据:`agent_step.thought` 截断为 2000 字符,`modelOutput` 截断为 500 字符,均不是完整答案
- `ChatService.executeChat` 第 269 行已有完整答案 `answer = response.getText()`,但未持久化
- 决策:给 `DiagnosisSession` 加 `answer TEXT` 字段,Flyway V008 迁移,案例内容直接从 `session.answer` 取
---
## User-Interview 确认记录
### Q1 — 置信度由谁评估
- 用户原话(grill):"两者都要:规则兜底 + Verifier 主打分"
- **apply 后修正**:讨论后决定去掉 LLM 自评,仅用规则引擎(见"apply 阶段决策")
- 最终实现:`EvaluationService` 纯规则,预留 `llm_opinion` 扩展位
### Q2 — 反馈触发操作
- 用户原话:"写入 DiagnosisSession.feedback 字段, not_useful → 打 BAD_CASE 标记"
- **apply 后修正**:BAD_CASE 不改 status,feedback 字段本身即为标记(见"apply 阶段决策")
- 最终实现:`FeedbackService` 只写 feedback + 可选写 case_library,不改 status
### Q4 — 验收口径
- 用户原话:"端到端可验证:发一次 chat → 查 DB 看 selfEvaluation 有值 → 提交 feedback → 查 DB 看 feedback + case_library"
- 确认状态:已确认,未变化
---
## Apply 阶段决策(post-grill 重要变更)
### 决策 A:DiagnosisSession 加 answer 字段
- **问题**:案例沉淀需要完整答案,agent_step.thought 被截断,不可用
- **决策**:新增 `answer LONGTEXT` 字段,ChatService SUCCESS 分支写入
- **影响**:V008 Flyway 迁移,CaseLibraryService 直接读 session.answer
### 决策 B:去掉 LLM 自评,只用规则引擎
- **问题**:LLM 评估自己的答案系统性偏高分;多一次调用消耗 token;Verifier Agent 当前未实现
- **决策**:MVP 阶段仅用基于 tool_invocation 的规则引擎
- **理由**:规则可解释、可复现、不撒谎;Verifier 留待诊断全链路实现时再做
- **预留**:`selfEvaluation` JSON 结构保留 `llm_opinion` 扩展位,代码底部注释说明接入点
### 决策 C:BAD_CASE 不改 status 字段
- **问题**:status 是执行状态语义(RUNNING/SUCCESS/FAILED),BAD_CASE 是质量标签,两个维度不同;覆盖 status 会破坏统计
- **决策**:`not_useful` 通过 `feedback` 字段本身标识,查 BadCase 用 `WHERE feedback = 'not_useful'`
### 决策 D:评分字段重命名为 evidence_score
- **问题**:原名 confidence 容易误解为"答案准确性",实际衡量的是"证据收集充分度"
- **决策**:重命名为 `evidence_score`,明确语义边界
- **边界说明**:工具调用能证明 Agent 有尝试收集证据,但无法证明答案无幻觉;这个分数过滤最差情况(无工具调用就给答案),不能识别"调用了工具但结论仍错误"
### 决策 E:规则输入来源仅限 tool_invocation 事实
- **问题**:DateTimeTools、QueryMetricsTools 等非检索工具调用未写入 tool_invocation
- **接受**:evidence_score 定义本来就是检索证据充分度,非检索工具排除在外是合理的,不是 bug
- **已知限制**:调用了时间工具但 evidence_score = 0 的 session 存在
---
## 架构审计记录
- 接口影响:`POST /api/feedback` 是新接口(L2);ChatService 主流程返回值不变(L1)
- 时序验证:tool_invocation 在工具执行时同步写入,evaluate @Async 在 Agent 完成后触发,无竞态问题
- 已接受风险:
- `@Async` 失败时 selfEvaluation 保持 null,前端需处理 null
- 案例结构化字段(faultCategory 等)暂时填 GENERAL,后续可人工补充
- LLM 自评预留但未实现,Phase 2 再迭代
### 决策 F:ChatResult record + ChatResponse.sessionId 回传
- **问题**:`ChatService` 内部生成 8 位 sessionId,但从不返回给前端;前端用自己的 sessionId 调 feedback 接口,后端查不到 session(400)
- **决策**:新增 `ChatResult(answer, sessionId)` record,`executeChatWithStrategy` 链路全部返回 `ChatResult`;`ChatResponse` 增加 `sessionId` 字段;前端读取并闭包绑定至对应消息的反馈按钮
- **影响**:`ChatService` 三个方法签名变更(内部链路),`ChatController` 调用方更新,前端 `app.js` 读取新字段
### 决策 G:反馈 sessionId 闭包绑定而非全局变量
- **问题**:最初实现用 `this.lastSessionId` 全局变量,多轮对话时点击早期消息的反馈按钮会提交最新 sessionId
- **决策**:`createFeedbackBar(sessionId)` 接收 sessionId 参数,`submitFeedback(feedback, bar, sessionId)` 直接用传入值,不读全局状态
- **效果**:每条 AI 回复绑定自己那轮的 sessionId,多轮对话下行为正确
### 项目技术栈清单
- ChatModel 注入:`@Autowired ChatModel chatModel`,通过 `ModelRoutingConfig` 路由
- Repository:Spring Data JPA,`Optional<T>` 返回,方法命名约定
- DTO:独立文件放 `dto/` 包
- 异步:新建 `AsyncConfig.java` 加 `@EnableAsync`(项目原无此配置)
- 无 MQ,无加密,工具类直接用 UUID.randomUUID()
- 日志:SLF4J Logger,`LoggerFactory.getLogger()`
- `ToolInvocationRepository.findBySessionId` 已有,可直接用
### 参考实现文件
- `ChatService.java`:executeChat/executeChatComplex 流程
- `CaseLibraryRepository.findByDiagnosisId`:幂等检查用
- `DiagnosisSessionRepository.findBySessionId`
- `ToolInvocationRepository.findBySessionId`
@@ -0,0 +1,52 @@
# evidence.md — confidence-feedback
## 代码证据
### agent_step.thought 不可作为案例内容
- 文件:`AgentLoggingHook.java:135`
- 证据:`thought` 在写入前截断为 2000 字符,`modelOutput` 截断为 500 字符
- 结论:两者均不是返回给用户的完整答案,案例质量低
### ChatService 已有完整答案未持久化
- 文件:`ChatService.java:269`(executeChat)、`ChatService.java:353`(executeChatComplex)
- 证据:`String answer = response.getText()` 只用于返回前端,未写入任何持久化存储
- 结论:加 `DiagnosisSession.answer` 字段是最干净的方案
### ToolInvocationRepository 已有 findBySessionId
- 文件:`ToolInvocationRepository.java`
- 证据:`findBySessionId(String sessionId)` 已实现,返回 `List<ToolInvocation>`
- 结论:规则引擎可直接读取 tool_invocation 事实,无需新增查询方法
### tool_invocation 写入时序安全
- 文件:`LookupKnowledgeTool.java:144`
- 证据:`saveToolInvocation` 在工具执行时同步调用,早于 ChatService 的 SUCCESS 分支
- 结论:@Async evaluate 触发时 tool_invocation 数据已在库,无竞态
### 项目原无 @EnableAsync
- 证据:`grep -rn "EnableAsync"` 无任何命中(apply 前)
- 结论:需要新建 `AsyncConfig.java`
### CaseLibraryRepository.findByDiagnosisId 已有幂等检查支持
- 文件:`CaseLibraryRepository.java`
- 证据:`findByDiagnosisId(String diagnosisId)` 已实现
- 结论:useful 重复提交时可用此方法检查,不重复插入
## 设计推导
### evidence_score vs confidence 命名
- 基于工具调用的分数衡量的是证据收集充分度,不是答案准确性
- "confidence" 容易误解,改为 "evidence_score" 更准确
- LLM 自评才适合叫 confidence,但当前未实现
### BAD_CASE 不应混入 status
- status 有明确执行状态语义(RUNNING/SUCCESS/FAILED)
- 一个 SUCCESS 的 session 被标为 BAD_CASE 后,按 status 做的统计会失真
- feedback 字段本身就够,`WHERE feedback = 'not_useful'` 即可查 BadCase
@@ -0,0 +1,58 @@
# Acceptance: session-dedup-knowledge-map
## 静态验证
| 项目 | 结果 | 说明 |
|------|------|------|
| 编译检查 | PASS | `mvn compile -q` exit code 0,所有 17 个变更文件无编译错误 |
| 代码结构检查 | PASS | 6 个新文件(RetrievedDocTracker, DocumentFieldEnricher, KnowledgeDomainService, KnowledgeDomain, KnowledgeDomainRepository, V009 迁移)均存在且路径正确 |
| Prompt 外部化 | PASS | `doc-field-enricher-prompt.md` 和 `domain-summary-prompt.md` 位于 `src/main/resources/prompts/`,Java 代码通过 `@PostConstruct` + `ClassPathResource` 加载 |
| Flyway 迁移脚本 | PASS | `V009__add_knowledge_domain.sql` 存在,表结构完整 |
| DTO 字段 | PASS | Frontmatter / KnowledgeEntry / LookupResult 新增字段均已添加 |
| 解析器扩展 | PASS | FrontmatterParser 解析 `covers` 和 `when_to_retrieve` |
| Jackson 替换 | PASS | KnowledgeIndexService 不再包含 extractJsonValue/extractJsonArray,改用 objectMapper.readValue |
| Prompt 检索规则 | PASS | chat-planner-prompt.md 新增"知识库检索规则"区块(4 条规则) |
## 脚本验证
| 项目 | 结果 | 说明 |
|------|------|------|
| 单元测试 | 未运行 | 项目当前无针对本 change 的单元测试 |
| 集成测试 | 未运行 | 需启动应用 + Milvus + MySQL 验证完整链路 |
## 浏览器/人工验证
| 项目 | 结果 | 说明 |
|------|------|------|
| V009 迁移 | PASS | Flyway 日志:`Successfully applied 1 migration to schema superbiz_agent, now at version v009` |
| knowledge_domain 表数据 | PASS | 4 个域全部 LLM 生成 when_to_retrieve 成功(api/domain/infrastructure/troubleshooting),内容包含跨域边界引用 |
| knowledge map 注入 Planner | PASS | 多 Agent 路径正常触发 `Supervisor → chat_planner → chat_executor`,Planner 能按域做检索规划 |
| session 级去重 | PASS | 两个 session 均验证去重生效:session `7c517329` 去 4 次重拦截,session `9693b9fb` 6 次去重拦截 |
| LLM 字段生成 | 未验证 | 需上传新文档后检查 metadata JSON 中是否包含 covers 和 whenToRetrieve |
## 未验证项
| 项目 | 风险 | 建议补验步骤 |
|------|------|-------------|
| LLM 字段生成 | 中 — 依赖外部 LLM 服务 | 上传新文档,检查 metadata JSON 中是否包含 covers 和 whenToRetrieve |
## 启动问题修复
| 问题 | 修复 | 状态 |
|------|------|------|
| `@PostConstruct` 中调用 `knowledgeDomainService.onDocumentChange()` 导致循环依赖 | 将域级生成从 `@PostConstruct` 移到 `@EventListener(ApplicationReadyEvent.class)` | 已修复,编译通过 |
## 任务完成状态
14/14 任务全部完成 (T1-1 ~ T6-2)。
## 遗留问题
ISS-002:Executor 无约束重复调用 `lookup_knowledge`(单会话 20+ 次),knowledge map 和检索约束只注入了 Planner 未注入 Executor。详见 `mvp/issues/ISS-002-executor-unconstrained-lookup.md`。
## 已知限制
1. **RetrievedDocTracker 为 JVM 内存存储**:应用重启后去重状态丢失,同一会话内重启无法继续去重(可接受,会话通常短于重启间隔)
2. **Planner 只看域级 when_to_retrieve**:文档级细粒度筛选留 Phase 2
3. **文档级 prompt 依赖同域其他文档**:首个上传到某域的文档无法获得同域参照(此时 prompt 输出"无同域其他文档")
4. **域级 prompt 依赖其他域已入库**:首次启动且 DB 为空时,其他域信息从 L0 索引 category 列表兜底
@@ -0,0 +1,33 @@
# Brief: session-dedup-knowledge-map
## 背景
ISS-001:Executor 在单次对话中重复调用 `lookup_knowledge` 多达 20 次,同一文档被召回 13 次。原因是工具层无状态、Planner 无知识边界感知。
## 目标
1. 彻底消除 session 内重复文档召回(Part A)
2. 给 Planner 注入知识图谱,让其在规划阶段就能判断需要检索哪个域、只检索一次(Part B)
## 范围
- `LookupKnowledgeTool`:session 级去重
- `Frontmatter` / `KnowledgeEntry`:新增 covers + whenToRetrieve
- `DocumentManagementService`:上传时 LLM 生成文档级字段
- `KnowledgeDomainService`(新):域级聚合与 DB 存储
- `knowledge_domain` 表(新)
- `ChatService` + `chat-planner-prompt.md`:注入 knowledge map
## 非目标(Phase 2)
- Executor 文档级 when_to_retrieve 细粒度筛选
- RRF 混合重排
- 文档 frontmatter 自动生成(手动覆盖 LLM 优先已支持)
## 分档
standard
## 关联 OpenSpec
openspec/changes/session-dedup-knowledge-map/
@@ -0,0 +1,60 @@
# decisions.md — session-dedup-knowledge-map
## Question Pool
| # | 问题 | 类型 | 状态 |
|---|---|---|---|
| Q1 | domain.when_to_retrieve 来源(手动/自动聚合/LLM上传时生成) | user-interview | 已确认 |
| Q2 | LLM 生成时机(同步上传 vs 异步补全) | user-interview | 已确认 |
| Q3 | knowledge map 结构(域级平铺 vs 两层) | user-interview | 已确认 |
| Q4 | domain.when_to_retrieve 存储(内存 vs DB) | user-interview | 已确认 |
| Q5 | Executor 文档级细粒度筛选是否进 MVP | user-interview | 已确认 |
| E1 | ThreadLocal 在多 Agent 路径是否安全 | evidence-driven | 已汇报 |
| E2 | 6 个文档是否全部有 category 字段 | evidence-driven | 已汇报 |
| E3 | 去重 key 设计 | evidence-driven | 已汇报 |
| E4 | Planner prompt token 增量是否可接受 | evidence-driven | 已汇报 |
| E5 | EvaluationService.tool_call_count 影响 | evidence-driven | 已汇报 |
## Evidence-Driven 结论
- **E1**:`AsyncConfig` 只启用 `@EnableAsync`,无 TaskDecorator。`SupervisorAgent.invoke()` 是同步阻塞调用,工具调用与主线程同线程,ThreadLocal 当前路径安全。异步扩展时需补 TaskDecorator。
- **E2**:全部 6 个文档均有 `category` 字段:api(1)、domain(1)、infrastructure(3)、troubleshooting(1)。
- **E3**:`KnowledgeEntry.filePath` 在 L0 内唯一,L1 `_source` 字段也是 filePath,统一用 filePath 作去重 key。
- **E4**:当前 planner prompt 21 行,注入 knowledge map 约增加 200-400 字符,可接受。
- **E5**:去重后 `agent_step.has_tool_call` 减少,`tool_call_count` 降低,这是修复效果,`EvaluationService` 评分规则无需改动。
## User-Interview 确认记录
**Q1** — doc.when_to_retrieve 来源
用户原话:选 C(上传时 LLM 自动生成)
确认状态:已确认
**Q2** — LLM 生成时机
用户原话:选 X(同步,上传时当场生成)
确认状态:已确认
**Q3** — knowledge map 结构
用户原话:认可两层结构(domain → documents[])
确认状态:已确认
补充:Planner 只注入域级 when_to_retrieve,文档级 when_to_retrieve 留 Executor 筛选(Phase 2)
**Q4** — domain.when_to_retrieve 存储
用户原话:存 DB,这样每次启动都不用让 LLM 再总结一次
确认状态:已确认 → 新建 knowledge_domain 表,Flyway 迁移脚本
**Q5** — Executor 文档级细粒度筛选
用户原话:留 Phase 2
确认状态:已确认,MVP 不做
## Pre-apply 补充决策
- **P1:KnowledgeIndexService.parseDocumentToEntry 替换为 Jackson**:`extractJsonValue` / `extractJsonArray` 手写解析器遇到含逗号、引号的自然语言字段(whenToRetrieve)会截断。全量替换为 `objectMapper.readValue(metadata, Frontmatter.class)`,影响范围仅 `KnowledgeIndexService`,行为更健壮。(用户确认)
- **P2:LookupResult 新增 message 字段**:去重命中时 `found=false` + `message="文档已在本会话中检索过:xxx"`,不复用 `primary.content`。语义清晰,LLM 能理解原因不会重试。(用户确认)
## 关键设计决策
1. **两级 when_to_retrieve**:文档级(upload 时 LLM 生成,存 metadata)+ 域级(文档变更时 LLM 聚合,存 knowledge_domain 表)
2. **域级重算触发**:文档上传后、文档删除后,只重算受影响的域(不是全量);`loadIndex()` 时如果某域在 DB 没有记录,则触发生成
3. **注入 Planner 只给域级**:knowledge map 只包含域级 when_to_retrieve + documents[](title + covers),不暴露文档级 when_to_retrieve
4. **去重 key**:filePath(L0+L1 统一)
5. **去重状态存储**:JVM 内 `ConcurrentHashMap<sessionId, Set<filePath>>`,`SessionContextHolder.clear()` 时同步清理
@@ -0,0 +1,87 @@
# Evidence: session-dedup-knowledge-map
## E1: ThreadLocal 在多 Agent 路径是否安全
**问题**:`SessionContextHolder` 基于 ThreadLocal,多 Agent 异步路径可能导致 sessionId 丢失。
**证据**:
- `AsyncConfig` 只启用 `@EnableAsync`,无 `TaskDecorator`
- `SupervisorAgent.invoke()` 是同步阻塞调用,工具调用与主线程同线程
- 当前路径下 ThreadLocal 安全
**结论**:当前同步路径安全。未来引入异步扩展时需补 `TaskDecorator` 传递 ThreadLocal。
---
## E2: 6 个文档是否全部有 category 字段
**问题**:域聚合依赖 `category` 字段分组,需确认现有文档是否都有值。
**证据**:
- 全部 6 个文档均有 `category` 字段:api(1)、domain(1)、infrastructure(3)、troubleshooting(1)
**结论**:现有文档无需修补,category 覆盖率 100%。
---
## E3: 去重 key 设计
**问题**:用什么字段唯一标识一个文档用于去重。
**证据**:
- `KnowledgeEntry.filePath` 在 L0 索引内唯一
- L1 向量索引的 `_source` 字段也是 filePath
- 上传时 `saveToLocal()` 生成 `knowledge_base/{category}/{fileName}` 路径
**结论**:统一用 `filePath` 作去重 key,L0 和 L1 一致。
---
## E4: Planner prompt token 增量是否可接受
**问题**:knowledge map YAML 注入 Planner prompt 会增加固定 token 开销。
**证据**:
- 当前 planner prompt 21 行
- 注入 knowledge map 约增加 200-400 字符(6 个文档场景)
- 相比 Planner 整体 prompt + 历史消息,增量占比 < 5%
**结论**:可接受,不构成性能瓶颈。
---
## E5: EvaluationService.tool_call_count 影响
**问题**:去重后 `tool_call_count` 降低,是否影响 `EvaluationService` 评分逻辑。
**证据**:
- `EvaluationService` 使用 `tool_call_count` 作为评分因子
- 去重导致重复调用被过滤,`tool_call_count` 下降
- 这是修复效果(消除了无意义的重复调用),不是回归
**结论**:`EvaluationService` 评分规则无需改动。下降的 `tool_call_count` 反映了真实效率提升。
---
## P1: 手写 JSON 解析器脆弱性
**问题**:`KnowledgeIndexService.extractJsonValue` / `extractJsonArray` 在遇到含逗号、引号的自然语言字段时会截断。
**证据**:
- `whenToRetrieve` 字段由 LLM 生成,内容为自然语言(含逗号、分号等标点)
- 手写解析器以 `"` 和 `,` 作分隔符,自然语言中的标点会导致提前截断
- Jackson `ObjectMapper.readValue(metadata, Frontmatter.class)` 是项目已有依赖
**结论**:全量替换为 Jackson,影响范围仅 `KnowledgeIndexService.parseDocumentToEntry()`,行为更健壮。
---
## P2: LookupResult 去重提示字段
**问题**:去重命中时如何向 LLM 返回"不要重试"的信号。
**证据**:
- 复用 `primary.content` 语义不清,LLM 可能理解为正常检索结果
- 独立 `message` 字段 + `found=false` 语义明确,LLM 能理解"已检索过"不再重试
**结论**:`LookupResult` 新增 `String message` 字段,去重时填入提示文本。
@@ -0,0 +1,70 @@
# Acceptance: executor-action-memory-relevance
## 分档
standard
## 任务完成状态
| 任务 | 状态 | 说明 |
|------|------|------|
| T1: RetrievedDocTracker 域级升级 | ✅ 完成 | 双层 Map 结构,域级+文档级记录 |
| T2: LookupResult 新增字段 | ✅ 完成 | relevanceLevel / completenessHint / retrievedDomainsThisSession |
| T3: 归一化计算逻辑 | ✅ 完成 | Min-Max 归一化 + 三等级判定 |
| T4: LookupKnowledgeTool 集成 | ✅ 完成 | 归一化层 + 行动记忆注入 + 域拦截 |
| T5: Executor Prompt 重写 | ✅ 完成 | 4 条检索约束,无 knowledge map |
| T6: 入库可观测性 | ✅ 完成 | V010 + Entity + JSON 扩展 |
| T7: BGE-M3 归一化验证测试 | ✅ 完成 | 范数=1.00000002,测试通过 |
## 静态验证
- [x] **语法/编译检查**: 所有 Java 文件编译通过
- [x] **Impact Analysis**: LookupKnowledgeTool、RetrievedDocTracker 变更范围经 `gitnexus_impact` 检查,均为 L2 内部接口影响
- [x] **Cross-artifact 对齐检查**: brief → proposal → design → specs → tasks 闭环,无 gap
- [x] **Prompt 约束检查**: chat-executor-prompt.md 不包含 knowledge map,包含 4 条检索约束
## 脚本验证
- [x] **V010 Flyway 迁移**: 迁移成功,`relevance_level` 和 `dedup_reason` 列已添加
```sql
ALTER TABLE tool_invocation
ADD COLUMN relevance_level VARCHAR(20),
ADD COLUMN dedup_reason VARCHAR(32);
```
- [x] **FullPipelineSmokeTest**: BGE-M3 归一化测试通过(范数=1.00000002)
- [x] **数据库数据校验**:
- `relevance_level` 列已写入 HIGHLY_RELEVANT / REFERENCE
- `dedup_reason` 列已写入 doc_retrieved / null
- `retrieval_details` JSON 包含 l1_top_similarity、completeness_hint、retrieved_domains、dedup_reason
## 浏览器/人工验证
- [x] **应用启动验证**: Spring Boot 应用正常启动,端口 9900
- [x] **Chat API 调用验证**: 通过 curl 测试 chat 接口,lookup_knowledge 调用链完整
```
curl -X POST "http://localhost:9900/api/chat/send" \
-H "Content-Type: application/json" \
-d '{"sessionId": "b66d799e", "question": "..."}'
```
- [x] **日志验证**: 应用日志可观察到 relevanceLevel、retrievedDomainsThisSession 输出
- [x] **归一化数学验证**: l1_top_score=0.383 → l1_top_similarity=0.8085(`1 - 0.383/2.0 = 0.8085`)✅
- [x] **域追踪验证**: `[infrastructure]` → `[infrastructure, api]` 域列表正常扩展
## 未验证
| 场景 | 原因 | 风险 | 补验建议 |
|------|------|------|---------|
| PRECISE 等级(L0 唯一精确匹配) | 测试会话无精确匹配场景 | 低 — L0 matchCount=1 的判断逻辑与 HIGHLY_RELEVANT 共用,实现确定性强 | 构造一条 L0 精确匹配的知识库文档后测试 |
| domain_retrieved 域级去重 | 需要同一域全部文档已检索再查该域才触发 | 低 — isDomainRetrieved 逻辑简单,与 isDocRetrieved 等价 | Phase 2 启用域级硬限流时测试 |
| DEDUPED 等级 | 当前 code path 去重时仍写 REFERENCE,DEDUPED 未被使用 | 低 — 设计预留,当前未启用 | Phase 2 若启用 DEDUPED 等级时验证 |
| Phase 2 域级硬限流 | 非本次范围 | 中 — 当前仅有软约束(prompt),LLM 仍可能在 REFERENCE 下继续检索 | 实测观察,如果 lookup 调用仍偏高,启动 Phase 2 |
## 剩余风险
1. **Prompt 软约束局限性**:实测 10 次调用中 9 次为 REFERENCE,说明 LLM 仍倾向于继续检索。如果 prompt 约束效果不足,需启用 Phase 2 域级硬限流。
2. **L1 Metadata 解析兼容性**:L1 domain 兜底路径解析 metadata JSON,如果知识库文档 frontmatter 格式不一致可能解析失败,已有 try-catch 兜底。
## 归档状态
- [ ] OpenSpec change 尚未归档
- [ ] devflow/index.md 状态为 `implemented`,待改为 `archived`
@@ -0,0 +1,35 @@
# Brief: executor-action-memory-relevance
## 背景
ISS-002:Executor 在单次会话中调用 `lookup_knowledge` 20+ 次,大部分是同域换变体的冗余调用。前序 change `session-dedup-knowledge-map` 解决了文档级重复召回(ISS-001),但未解决 Executor 重复调用问题。
## 目标
- Executor 获得行动记忆(知道自己本次会话已检索了哪些域)
- 检索结果提供归一化质量等级(PRECISE/HIGHLY_RELEVANT/REFERENCE)+ 兜底信号
- Executor prompt 提供明确的检索约束和"放弃检索"的合法出口
- 原始分数入库保留可观测性,但不暴露给 LLM
## 范围
- `RetrievedDocTracker`:域级 + 文档级双层记录
- `LookupKnowledgeTool`:归一化层 + 行动记忆注入
- `LookupResult`:新增 relevanceLevel / completenessHint / retrievedDomainsThisSession
- `chat-executor-prompt.md`:检索约束重写
- `ToolInvocation` + V010:入库可观测性
## 非目标
- 不给 Executor 注入 knowledge map(保持 Agent 边界)
- 不修改 Planner prompt 或 Planner 逻辑
- 不修改 PrimaryResult / SupplementResult 的字段(不暴露原始分数)
- Phase 2 域级硬限制暂不实施
## 分档
standard
## 关联 OpenSpec change
openspec/changes/executor-action-memory-relevance
@@ -0,0 +1,83 @@
# Decisions: executor-action-memory-relevance
## 过程日志
### Clarify 阶段
**入口摘要**:ISS-002 Executor 无约束重复调用 lookup_knowledge(单会话 20+ 次),需要行动记忆 + 归一化质量等级 + prompt 约束来解决。
**slug**: `executor-action-memory-relevance`
**规模分档**: `standard`(涉及 7 个文件,跨 DTO/工具层/持久化/Prompt,有设计决策需澄清)
### Context 阶段
**devflow/index.md 使用状态**: 已命中。前序 change `session-dedup-knowledge-map`(archived)提供了 RetrievedDocTracker、KnowledgeDomainService、ISS-002 文档。
**相关 ADR**: 无直接 ADR,但 `session-dedup-knowledge-map` 的 decisions.md 和 evidence.md 记录了文档级去重和 knowledge map 注入的决策。
**不能违反的历史决策**:
1. RetrievedDocTracker 的文档级去重必须保留
2. knowledge map 只注入 Planner,不注入 Executor(本次讨论确认)
3. L0/L1 原始分数不暴露给 LLM,只在归一化层内部使用(本次讨论确认)
**需进入 OpenSpec 的上下文点**:
1. L1 score 是 L2 距离(值域 [0,+∞)),不是归一化分数——阈值设计需基于实际分布
2. L0 的 category 可从 KnowledgeEntry.getCategory() 直接获取;L1 需解析 metadata JSON
3. ReactAgent 是自主决策工具调用的 Agent,Prompt 约束是软约束
### Grill 阶段 — Question Pool
**维度:术语**
1. [evidence-driven] `relevanceLevel` 三个等级(PRECISE/HIGHLY_RELEVANT/REFERENCE)的边界是否清晰,是否存在 LLM 误解的可能? → **已查证**:三个等级语义明确,PRECISE=唯一匹配、HIGHLY_RELEVANT=高分命中、REFERENCE=低置信度参考。LLM 理解风险低。
**维度:边界**
2. [evidence-driven] L1 score 是 L2 距离(值域 [0,+∞)),当前代码无阈值判断。归一化阈值如何设计? → **已查证**:L2 距离典型范围取决于 BGE-M3 1024 维 embedding 的尺度,需从 `tool_invocation.retrieval_details` 中查询实际 `l1_scores` 分布才能定阈值。当前先以常量定义,标记为"需实测校准"。
3. [evidence-driven] L1 结果的 category 提取需要解析 metadata JSON 字符串,当前 `SearchResult.metadata` 是 `toString()` 的结果。归一化层是否需要 L1 的 domain? → **已查证**:L1 的 domain 主要用于 RetrievedDocTracker 的域级记录。如果 L0 已命中且包含 category,可直接用 L0 的 category;如果仅 L1 命中,需解析 metadata 提取 category。当前知识库中 L0 大概率先命中,L1 domain 提取作为兜底路径。
4. [user-interview] 归一化阈值(L1 score 分界线)在实测数据不足时,是否接受先用保守初始值 + 后续调优的策略? → **用户待确认**
**维度:验收**
5. [evidence-driven] 现有 `tool_invocation` 表 `retrieval_details` JSON 中 `l1_scores` 存的是 L2 距离原始值,新增的 `relevance_level` 和 `completeness_hint` 入库后是否需要回填历史数据? → **已查证**:不需要回填历史数据,新列 nullable 即可,历史记录 relevance_level=null。
### Grill 结论
**evidence-driven 汇报**:
- E1: relevanceLevel 三等级语义清晰,LLM 误解风险低
- E2: L1 score 是 L2 距离,值域不固定,阈值需实测校准
- E3: L0 category 直接可用,L1 category 需解析 metadata(兜底路径)
- E4: 历史数据不回填,新列 nullable
**user-interview 已确认**:
- Q4: 归一化阈值先用保守初始值 + 后续调优 → **用户已确认**,并建议用 Min-Max 归一化到 [0,1]
### Specify 阶段补充
**BGE-M3 L2 归一化实测验证**:
- FullPipelineSmokeTest.embeddingBgeM3Works() 新增 L2 范数断言
- 结果:范数=1.00000002,误差 < 0.01,测试通过
- 结论:BGE-M3 输出为 L2 归一化单位向量,L2 距离数学硬上界 = 2.0
- Min-Max 归一化公式:`similarity = 1 - min(l2Score, 2.0) / 2.0`
**Cross-artifact 对齐检查**:
| 对齐项 | 状态 |
|--------|------|
| brief 目标/范围/非目标 → proposal 覆盖 | 已对齐 |
| proposal 范围/约束 → design 覆盖 | 已对齐 |
| design 归一化/行动记忆/接口影响 → specs 覆盖 | 已对齐 |
| specs 可观察行为 → tasks 覆盖 | 已对齐 |
**接口影响分级**:
- RetrievedDocTracker 数据结构升级 → L2(内部接口,消费者只有 LookupKnowledgeTool)
- LookupResult 新增 3 字段 → L2(工具返回值,无跨模块调用方)
- tool_invocation 新增 2 列 → L2(Flyway nullable,不影响现有查询)
- chat-executor-prompt.md 更新 → L1(Prompt 文本变更)
### Audit 阶段
**架构风险评估**(5 句以内):
1. 归一化层嵌入 LookupKnowledgeTool 内部(静态方法),无跨模块耦合风险。
2. RetrievedDocTracker 升级为双层结构,数据量级不变(文档数 × session 数),内存无风险。
3. L1 metadata 解析 category 是兜底路径,如果 JSON 格式不一致可能解析失败——已有 try-catch 兜底。
4. 归一化阈值 yml 配置化,运行时调优不需要改代码和重启——运维友好。
5. Prompt 约束仍依赖 LLM 遵守——如果 Phase 1 效果不足,Phase 2 域级硬限制的 isDomainRetrieved 已就绪,无需额外改造。
@@ -0,0 +1,65 @@
# Evidence: executor-action-memory-relevance
## Evidence-driven 结论
### E1: relevanceLevel 三等级语义清晰度
- **来源**: Grill 阶段 Question Pool #1
- **查证结果**: 三个等级语义明确,边界清晰:
- PRECISE:L0 唯一精确匹配,LLM 应直接使用
- HIGHLY_RELEVANT:归一化 similarity ≥ 0.75,高度相关
- REFERENCE:归一化 similarity ≥ 0.5,相关参考
- **结论**: LLM 误解风险低,语义边界足够清晰
### E2: L1 Score 值域与归一化阈值
- **来源**: Grill 阶段 Question Pool #2
- **查证结果**:
- L1 score 是 L2 距离,值域 [0, +∞)
- BGE-M3 输出为 L2 归一化单位向量(实测范数=1.00000002),L2 距离数学硬上界 = 2.0
- Min-Max 归一化公式:`similarity = 1 - min(l2Score, 2.0) / 2.0`
- **结论**: 使用 `maxL2Distance=2.0` 作为归一化上界,阈值 yml 可配置
### E3: L1 Domain 提取兜底路径
- **来源**: Grill 阶段 Question Pool #3
- **查证结果**:
- L0 的 domain 可从 `KnowledgeEntry.getCategory()` 直接获取
- L1 结果的 domain 需解析 `SearchResult.metadata` JSON 字符串
- 当前知识库设计下 L0 大概率先命中,L1 domain 提取作为兜底
- **结论**: 先尝试 L0 category,失败时解析 L1 metadata JSON(try-catch 兜底)
### E4: 历史数据不回填
- **来源**: Grill 阶段 Question Pool #5
- **查证结果**: 新列 `relevance_level` 和 `dedup_reason` 均为 nullable,不影响现有查询
- **结论**: 历史记录保持 null,不需要回填迁移
### E5: BGE-M3 L2 归一化实测验证
- **来源**: Specify 阶段 + FullPipelineSmokeTest
- **查证结果**:
- embeddingBgeM3Works() 测试新增 L2 范数断言
- 实测范数 = 1.00000002,误差 < 0.01
- 测试通过,BGE-M3 输出确认为 L2 归一化单位向量
- **结论**: L2 距离上界 = 2.0 的数学依据成立
### E6: V010 迁移验证
- **来源**: Apply 阶段运行时验证
- **查证结果**:
- Flyway V010 迁移成功执行
- `relevance_level` VARCHAR(20) 列可空,已正确写入
- `dedup_reason` VARCHAR(32) 列可空,已正确写入
- `retrieval_details` JSON 扩展字段(l1_top_similarity、relevance_level、completeness_hint、retrieved_domains、dedup_reason)全部写入
- **结论**: 入库可观测性符合设计
### E7: 数据库数据校验
- **来源**: Apply 阶段运行时验证
- **查证结果**:
- session `b66d799e` 共 10 条 lookup_knowledge 调用
- id=138: L2=0.383 → similarity=0.8085 → HIGHLY_RELEVANT(符合预期)
- id=139-147: 主要为 REFERENCE,doc_retrieved 去重正常触发
- retrieved_domains 域追踪:`[infrastructure]` → `[infrastructure, api]` 正常扩展
- **结论**: 归一化、行动记忆、去重机制数据层面全部验证通过
+3
View File
@@ -13,6 +13,9 @@
- [知识库检索使用指南](architecture/knowledge-retrieval-usage.md) - 文档编写和使用说明 ⭐新增
- [会话管理](architecture/session-management.md) - Redis + MySQL 会话管理
- [实施规划](architecture/implementation-plan.md) - 分阶段实施计划
- [会话级去重与知识域地图](architecture/session-dedup-knowledge-map.md) - 文档级去重 + Planner 知识域地图注入解决 ISS-001 ⭐新增
- [证据评分与用户反馈](architecture/confidence-feedback.md) - evidence_score 规则引擎 + feedback API ⭐新增
- [行动记忆与检索归一化](architecture/action-memory-relevance.md) - Executor 行动记忆 + 归一化质量等级解决 ISS-002 ⭐新增
---
+275
View File
@@ -0,0 +1,275 @@
# 行动记忆与检索质量归一化
Executor 行动记忆 + 归一化质量等级设计,解决 ISS-002 Executor 无约束重复检索问题。
---
## 一、问题背景
ISS-001 修复文档级去重后,Executor 在单次会话中仍调用 `lookup_knowledge` 20+ 次。根因:
1. **行动记忆缺失**:Executor 不知道自己已检索过哪些域
2. **质量信号缺失**:检索结果没有给 LLM 判断"结果够不够"的信号
3. **Prompt 缺少合法出口**:原 prompt 要求"所有外部信息都必须调用工具",LLM 不敢停止检索
---
## 二、整体架构
```
lookup_knowledge(query)
│
├─ Step 1: L0 精确匹配(keywords 索引)
├─ Step 2: L1 语义检索(Milvus 向量)
├─ Step 3: computeRelevance()
│ ├─ 归一化:L2 → similarity [0,1]
│ └─ 判定:PRECISE / HIGHLY_RELEVANT / REFERENCE
├─ Step 4: RetrievedDocTracker 检查
│ ├─ 文档级去重 → isDocRetrieved(sessionId, docKey)
│ ├─ 域级检查 → isDomainRetrieved(sessionId, domain)
│ └─ 记录 → markRetrieved(sessionId, domain, docKey)
└─ Step 5: 返回 LookupResult
├─ primary / supplement(原始内容,不含分数)
├─ relevanceLevel(PRECISE / HIGHLY_RELEVANT / REFERENCE)
├─ completenessHint(兜底信号)
└─ retrievedDomainsThisSession(行动记忆)
```
### 设计原则
| 原则 | 说明 |
|------|------|
| **Agent 边界清晰** | 不给 Executor 注入 knowledge map,Executor 只知道做了什么,不用知道有什么 |
| **分数封装** | L0/L1 原始分数不在 LookupResult 中返回 LLM,只在归一化层内部使用 |
| **原始分数只入库** | 原始 L2 距离写进 `tool_invocation.retrieval_details` JSON 用于可观测 |
| **软约束 + 硬拦截** | Prompt 约束(软)+ 工具层域级去重(硬)两层防御 |
---
## 三、归一化质量等级
### L2 距离归一化
BGE-M3 输出为 L2 归一化单位向量(实测范数=1.00000002),L2 距离数学硬上界 = 2.0。
```
similarity = 1 - min(l2Score, maxL2Distance) / maxL2Distance
```
| L2 距离 | similarity | 等级 |
|---------|-----------|------|
| 0.0 | 1.0 | PRECISE |
| 0.383 | 0.8085 | HIGHLY_RELEVANT |
| 0.5 | 0.75 | HIGHLY_RELEVANT |
| 0.6031 | 0.6984 | REFERENCE |
| 1.0 | 0.5 | REFERENCE 边界 |
| 2.0+ | 0.0 | 不视为有效结果 |
### 三等级判定
| 等级 | 条件 | completenessHint | LLM 行为 |
|------|------|-----------------|---------|
| PRECISE | L0 matchCount == 1 | "知识库中不存在比上述结果更精准的文档" | 直接使用,禁止再检索 |
| HIGHLY_RELEVANT | L0 命中 + similarity ≥ 0.75,或仅 L1 similarity ≥ 0.75 | "当前结果已高度相关,继续检索不太可能找到更精准的文档" | 可综合推理,大概率不需要继续查 |
| REFERENCE | 其余命中(similarity ≥ 0.5) | "当前结果为相关参考,如需更精准信息请明确缺少的具体维度" | 可参考,如需更精准请指出缺少的维度后定向补充 |
### 阈值配置
```yaml
retrieval:
normalization:
max-l2-distance: 2.0 # L2 距离上界
highly-relevant-threshold: 0.75 # similarity ≥ 0.75 → HIGHLY_RELEVANT
reference-threshold: 0.5 # similarity ≥ 0.5 → REFERENCE
```
---
## 四、行动记忆
### RetrievedDocTracker 数据结构
```java
// 从单层升级为双层:session → domain → filePath 集合
ConcurrentHashMap<String, Map<String, Set<String>>> sessionRetrievals;
```
### API
| 方法 | 作用 |
|------|------|
| `markRetrieved(sessionId, domain, filePath)` | 记录一次检索 |
| `isDocRetrieved(sessionId, filePath)` | 文档级去重 |
| `isDomainRetrieved(sessionId, domain)` | 域级检查 |
| `getRetrievedDomains(sessionId)` | 获取已检索域列表 |
| `clearSession(sessionId)` | 清理会话记录 |
### LookupResult 返回
```java
LookupResult.builder()
.found(true)
.primary(primaryResult)
.supplement(supplementResult)
.relevanceLevel("HIGHLY_RELEVANT") // PRECISE / HIGHLY_RELEVANT / REFERENCE
.completenessHint("当前结果已高度相关...") // 兜底信号
.retrievedDomainsThisSession(["infrastructure", "api"]) // 行动记忆
.message("...")
.build();
```
---
## 五、Executor Prompt 约束
### 4 条检索约束
1. **判断重复**:基于 `retrievedDomainsThisSession` 判断语义重叠
2. **重复了怎么办**:禁止换关键词重查;先指缺少的维度,再定向补充
3. **合法出口**:"不查全不会被追责,重复检索才会被惩罚"
4. **利用质量信号**:PRECISE → 停止;HIGHLY_RELEVANT + 域已检索 → 禁止;REFERENCE → 指出缺少维度
### 关键变化
原有 prompt:"所有需要外部信息的地方,都必须调用对应的工具"
→ 改为:"需要外部信息时调用工具,但须遵守下方的检索约束"
---
## 六、数据库变更
### V010
```sql
ALTER TABLE tool_invocation
ADD COLUMN relevance_level VARCHAR(20) COMMENT 'PRECISE/HIGHLY_RELEVANT/REFERENCE/DEDUPED',
ADD COLUMN dedup_reason VARCHAR(32) COMMENT 'doc_retrieved/domain_retrieved/null';
```
### retrieval_details JSON 扩展
```json
{
"l0_titles": ["MySQL 数据库连接池配置", "Redis 缓存配置指南"],
"l1_scores": [0.383, 0.4502, 0.7011],
"l1_top_score": 0.383,
"l1_top_similarity": 0.8085,
"relevance_level": "HIGHLY_RELEVANT",
"completeness_hint": "当前结果已高度相关,继续检索不太可能找到更精准的文档",
"retrieved_domains": ["infrastructure"]
}
```
扩展字段使用方式:
| 字段 | 用途 |
|------|------|
| `l1_top_score` | 原始 L2 距离最小值(可观测性) |
| `l1_top_similarity` | 归一化后的相似度 [0,1] |
| `relevance_level` | 归一化质量等级 |
| `completeness_hint` | 兜底信号 |
| `retrieved_domains` | 已检索域列表 |
| `dedup_reason` | 去重原因(如有) |
---
## 七、使用场景
### 场景 1:正常检索
```
用户:数据库连接池怎么配置?
Executor 内部:
1. lookup_knowledge("数据库连接池配置")
→ relevanceLevel=HIGHLY_RELEVANT (similarity=0.8085)
→ completenessHint="当前结果已高度相关..."
→ retrievedDomainsThisSession=["infrastructure"]
2. 基于已有信息直接回答,不再检索
```
### 场景 2:行动记忆阻止重复
```
Executor 步骤列表:
- 查数据库连接池配置
- 查 HikariCP 参数
- 查连接池耗尽排查
实际行为:
1. lookup("数据库连接池") → relevance=HIGHLY_RELEVANT, domains=["infrastructure"]
2. lookup("HikariCP 参数") → retrievedDomainsThisSession=["infrastructure"]
LLM 判断:infrastructure 域已检索过,禁止换关键词重查
→ 基于已有信息回答,指出缺少的具体维度
3. lookup("连接池耗尽") → 同域,被 prompt 约束拦截或工具层去重拦截
```
### 场景 3:PRECISE 精确匹配
```
用户:ERR_TIMEOUT 是什么?
Executor 内部:
1. lookup_knowledge("ERR_TIMEOUT")
→ L0 matchCount=1(唯一精确匹配)
→ relevanceLevel=PRECISE
→ completenessHint="知识库中不存在比上述结果更精准的文档"
2. 直接使用,不再检索
```
### 场景 4:REFERENCE + 定向补充
```
用户:如何排查生产故障?
Executor 内部:
1. lookup_knowledge("故障排查")
→ relevanceLevel=REFERENCE (similarity=0.6)
→ retrievedDomainsThisSession=["troubleshooting"]
2. LLM 判断:信息不足,缺少"日志分析"维度的具体步骤
3. lookup_knowledge("日志分析步骤")
→ 定向补充,不盲目换关键词
```
---
## 八、可观测性
### 查询质量分布
```sql
SELECT relevance_level, COUNT(*) AS cnt
FROM tool_invocation
WHERE tool_name = 'lookup_knowledge'
GROUP BY relevance_level;
```
### 去重原因分布
```sql
SELECT dedup_reason, COUNT(*) AS cnt
FROM tool_invocation
WHERE tool_name = 'lookup_knowledge'
GROUP BY dedup_reason;
```
### 归一化分数分布
```sql
SELECT
JSON_EXTRACT(retrieval_details, '$.l1_top_similarity') AS similarity,
COUNT(*) AS cnt
FROM tool_invocation
WHERE tool_name = 'lookup_knowledge'
AND retrieval_details IS NOT NULL
GROUP BY similarity
ORDER BY similarity;
```
---
## 九、扩展方向(Phase 2)
- **域级硬限流**:`isDomainRetrieved` 已就绪,在 LookupKnowledgeTool 入口直接拦截同域调用,不依赖 LLM 遵守 prompt
- **DEDUPED 等级**:去重时单独标记为 DEDUPED 等级,与 REFERENCE 区分
- **分数反馈调优**:基于 feedback 数据优化归一化阈值
+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,188 @@
# 会话级去重与知识域地图
文档级去重 + 知识域地图注入 Planner,解决 ISS-001 Executor 重复召回同一文档问题。
---
## 一、整体架构
本 change 包含两个独立但互补的部分:
```
Part A: 工具层去重
LookupKnowledgeTool
├── 维护 ConcurrentHashMap<sessionId, Set<filePath>>(JVM 内)
├── 每次检索前过滤已召回文档
└── SessionContextHolder.clear() 时同步清理
Part B: 知识域地图
┌─────────────────────────────────────────────┐
│ 文档上传 (DocumentManagementService) │
│ → LLM 生成 doc.covers + doc.when_to_retrieve │
│ → 存入 api_document.metadata │
│ → 触发域级重算 (KnowledgeDomainService) │
└─────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────┐
│ 域级聚合 (KnowledgeDomainService) │
│ → 读取同域所有文档的 when_to_retrieve │
│ → LLM 生成 domain.when_to_retrieve │
│ → 存入 knowledge_domain 表 │
└─────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────┐
│ 启动 (KnowledgeIndexService.loadIndex) │
│ → 加载 knowledge_domain 表 │
│ → 某域无记录则触发域级生成 │
└─────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────┐
│ Planner prompt (ChatService) │
│ → 注入 knowledge map(域级) │
│ → Planner 做粗粒度检索决策 │
└─────────────────────────────────────────────┘
```
---
## 二、Part A:工具层去重
### RetrievedDocTracker
session 级已召回文档追踪组件,将去重责任从 LLM 移交到工具层。
```java
ConcurrentHashMap<String, Set<String>> retrieved
key: sessionId
value: Set<filePath>
```
| 方法 | 作用 |
|------|------|
| `isAlreadyRetrieved(sessionId, filePath)` | 检查文档是否已召回 |
| `markRetrieved(sessionId, filePath)` | 记录已召回文档 |
| `clearSession(sessionId)` | 清理会话记录(SessionContextHolder.clear 触发) |
### 去重流程
```
lookup_knowledge(query)
→ L0 检索 → 命中一批文档
→ 遍历结果,过滤 isAlreadyRetrieved=true 的文档
→ 剩余文档作为 primary/supplement 返回
→ 实际返回的文档调用 markRetrieved
```
---
## 三、Part B:知识域地图
### Frontmatter 新增字段
文档上传时 LLM 自动生成以下两个字段:
```yaml
covers: ["支付失败排查", "扣款无回调"] # 业务场景标签
when_to_retrieve: "用户描述支付失败、超时时" # 文档级检索时机
```
### knowledge_domain 表
```sql
CREATE TABLE knowledge_domain (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
domain_id VARCHAR(64) NOT NULL UNIQUE,
description VARCHAR(256),
when_to_retrieve TEXT,
document_count INT DEFAULT 0,
updated_at DATETIME,
created_at DATETIME
);
```
### Knowledge Map(注入 Planner 的 YAML)
```yaml
available_knowledge_domains:
- domain_id: "payment"
description: "支付链路问题排查"
when_to_retrieve: "用户问题涉及支付、退款、对账时检索;优先检索一次,勿重复"
documents:
- title: "支付失败排查手册"
covers: ["支付超时", "扣款无回调"]
- title: "退款处理指南"
covers: ["退款未到账", "退款状态异常"]
- domain_id: "infrastructure"
...
```
### 注入链路
```
文档上传/删除
→ KnowledgeDomainService.onDocumentChange(category)
→ 读取同域所有文档的 when_to_retrieve
→ LLM 聚合为 domain.when_to_retrieve
→ 写入 knowledge_domain 表
应用启动
→ KnowledgeIndexService.loadIndex()
→ 加载 knowledge_domain → 无记录则触发聚合
→ ChatService.buildChatPlannerAgent() 注入 prompt
Planner prompt 中包含知识域地图
→ Planner 做粗粒度检索决策("查 payment 域")
→ Executor 收到步骤后执行具体检索
```
---
## 四、关键设计决策
| 决策 | 方案 | 原因 |
|------|------|------|
| 域级 when_to_retrieve 存 DB | 持久化 | 避免每次重启调 LLM,文档变更时只重算受影响域 |
| 文档级 when_to_retrieve 存 metadata JSON | 沿用现有路径 | 无需新增数据库字段 |
| RetrievedDocTracker 独立于 SessionContextHolder | 职责分离 | SessionContextHolder 只持有 sessionId,Tracker 是业务状态 |
| Planner 只看域级 | 分层决策 | 文档级 when_to_retrieve 留 Executor 筛选(Phase 2) |
| LLM 调用同步执行 | 上传时即时生成 | 接受约 1-2s 延迟,保证数据库和 L0 索引立即一致 |
---
## 五、Agent 边界
```
Planner 角色:知道"有什么域"
└─ 知识域地图:选定要检索的域(一次规划)
Executor 角色:知道"做了什么"
└─ 行动记忆:域级 + 文档级去重(ISS-002 升级为双层记忆)
```
Part B(知识域地图)只注入 Planner prompt,**不注入 Executor prompt**。Executor 只通过 RetrievedDocTracker 知道自己已检索了哪些文档,不需要知道全局域有哪些。
---
## 六、数据库变更
### V009
```sql
CREATE TABLE knowledge_domain (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
domain_id VARCHAR(64) NOT NULL UNIQUE,
description VARCHAR(256),
when_to_retrieve TEXT,
document_count INT DEFAULT 0,
updated_at DATETIME,
created_at DATETIME
);
```
---
## 七、参考资料
- **架构文档**:`mvp/architecture/knowledge-retrieval-architecture.md`
- **使用指南**:`mvp/architecture/knowledge-retrieval-usage.md`
- **OpenSpec**:`openspec/changes/archive/2026-06-30-session-dedup-knowledge-map/`
+82
View File
@@ -0,0 +1,82 @@
# ISS-001 Executor 重复召回同一文档
**状态**:已修复(2026-06-30)
**严重程度**:中(影响 token 消耗和上下文质量,不影响功能正确性)
**发现时间**:2026-06-30
**修复版本**:session-dedup-knowledge-map
**架构文档**:[会话级去重与知识域地图](../architecture/session-dedup-knowledge-map.md)
---
## 现象
单次对话中 `lookup_knowledge` 被调用 20 次,其中"故障诊断流程规范"被重复召回约 13 次,多个文档被重复召回 3-6 次。
```
tool_invocation 记录(db8bfa0f):
L0 命中"故障诊断流程规范" × 13
L0+L1 命中"MySQL 数据库连接池配置" × 5
L1 命中性能类故障 × 2
```
---
## 根本原因
**两个层面同时缺失去重机制:**
1. **工具层无去重**:`LookupKnowledgeTool` 每次独立检索,不感知调用历史,同一查询关键词必然返回同一文档
2. **Agent 层无记忆**:Executor Prompt 未要求跟踪已使用文档,LLM 每步倾向于"再确认一下",反复触发相同检索
**调用链路:**
```
Planner step 0:制定排查计划
Executor step 0:检索知识库 → 命中故障诊断流程规范
Executor step 1:继续检索 → 又命中故障诊断流程规范(不知道已取过)
Executor step 3:继续检索 → 又命中故障诊断流程规范
... (重复 13 次)
```
---
## 影响
- **Token 浪费**:同一文档内容反复塞入上下文,多 Agent 场景尤为明显
- **上下文窗口压缩**:重复内容占用有效 token 空间,可能导致有用信息被截断
- **evidence_score 失真**:`tool_call_count` 虚高,规则评分中"成功调用次数"被膨胀
---
## 修法方向
### 方案 A:Prompt 层约束(简单,优先验证)
在 `chat-executor-prompt.md` 中加规则:
```
已检索过的文档不要重复检索。每次调用 lookup_knowledge 前,
先检查对话历史中是否已有该文档的内容,有则直接使用,不再重复调用。
```
优点:不改代码,立即可验证
缺点:依赖 LLM 遵守指令,不保证 100% 生效
### 方案 B:工具层去重(可靠,推荐长期方案)
`LookupKnowledgeTool` 在 session 维度维护已召回文档 ID 集合,检索结果返回前过滤掉已召回的文档。
优点:彻底解决,不依赖 LLM
缺点:需要改工具代码,需要 session 级状态传递
### 建议
MVP 阶段先做**方案 A**验证效果,若重复率明显下降则保留;
若 LLM 不稳定遵守,再升级到**方案 B**。
---
## 相关文件
- `src/main/java/com/superbiz/agent/tool/LookupKnowledgeTool.java`
- `src/main/resources/prompts/chat-executor-prompt.md`
@@ -0,0 +1,87 @@
# ISS-002 Executor 无约束重复调用 lookup_knowledge
**状态**:已修复
**严重程度**:中(工具层去重已拦截重复文档,但调用本身仍浪费 token 和耗时)
**发现时间**:2026-07-01
**修复时间**:2026-07-01
**关联**:ISS-001(Part A 已修,Part B 注入范围不足)
---
## 现象
ISS-001 修复后,session 级去重(RetrievedDocTracker)生效,同一文档不再重复召回内容。但 Executor 在单次会话中仍调用 `lookup_knowledge` 20+ 次,大部分被去重拦截返回"已检索过"。
实测日志(session `7c517329`,2026-07-01 13:53):
```
Executor 调用 lookup_knowledge ~20 次
去重拦截 11 次:
- infrastructure/mysql-connection-pool.md × 6
- api/payment-errors.md × 5
有效检索仅 2-3 次(首次命中各域时)
```
Executor 用不同的 query 变体反复查同一个域,因为 LLM 觉得"需要更多细节"。
---
## 根本原因
**knowledge map 和检索约束只注入了 Planner prompt,未注入 Executor prompt。**
当前注入范围:
| 组件 | knowledge map | 每域最多一次约束 |
|------|:---:|:---:|
| Planner prompt | 已注入 | 已注入 |
| Executor prompt | **未注入** | **未注入** |
调用链路:
```
Supervisor → Planner:规划一次,输出"查 infrastructure 域 + api 域"
Supervisor → Executor:执行步骤(ReactAgent,自主决定调用工具)
Executor step 1:lookup("MySQL 连接池配置") → 命中 infrastructure 域 ✓
Executor step 2:lookup("HikariCP 参数调优") → 去重拦截 ✗
Executor step 3:lookup("连接池耗尽排查步骤") → 去重拦截 ✗
Executor step 4:lookup("支付超时排查") → 命中 api 域 ✓
Executor step 5:lookup("ERR_TIMEOUT 错误码") → 去重拦截 ✗
...(反复用不同变体查同域)
```
Executor 看不到"每个域只查一次"的约束,也不知道已有哪些域被检索过。
---
## 影响
- **Token 浪费**:每次去重拦截仍需走完 L0+L1 检索流程,再返回"已检索过";LLM 也要处理这个返回信息
- **耗时增加**:每次冗余调用约 400-500ms(L0+L1 检索 + 向量查询),20 次冗余调用浪费约 10s
- **LLM 行为低效**:Executor 花大量 step 在重复检索上,而不是基于已有信息推理
---
## 修法方向
### 方案 A:Executor prompt 注入 knowledge map + 检索约束
在 `chat-executor-prompt.md` 或 `buildChatExecutorAgent()` 中:
1. 注入 knowledge map(与 Planner 相同的 YAML)
2. 添加规则:"每个域最多调用一次 lookup_knowledge;已检索过的域不要再用不同关键词重复检索"
优点:与 Planner 对齐,LLM 能理解域级边界
缺点:仍依赖 LLM 遵守指令(但比纯 Prompt 约束强,因为有 knowledge map 做锚点)
### 方案 B:工具层硬限制(session + 域级计数)
在 `RetrievedDocTracker` 中增加域级计数:`ConcurrentHashMap<sessionId, Map<domain, count>>`。
当某域检索次数 > 1 时,直接在 `LookupKnowledgeTool` 入口返回"该域已检索过,不允许再次调用"。
优点:100% 可靠,不依赖 LLM
缺点:需改动 RetrievedDocTracker + LookupKnowledgeTool,需要从 filePath 反查 domain
### 建议
**先做方案 A**(改动小,与已有 knowledge map 注入逻辑一致),观察效果。
如果 LLM 仍不遵守,再升级到方案 B。
+6
View File
@@ -0,0 +1,6 @@
# 已知问题记录
| # | 标题 | 严重程度 | 状态 | 文件 |
|---|---|---|---|---|
| ISS-001 | Executor 重复召回同一文档 | 中 | 已修复 | [ISS-001-duplicate-retrieval.md](ISS-001-duplicate-retrieval.md) |
| ISS-002 | Executor 无约束重复调用 lookup_knowledge | 中 | 已修复 | [ISS-002-executor-unconstrained-lookup.md](ISS-002-executor-unconstrained-lookup.md) |
@@ -0,0 +1,116 @@
# Design: 置信度评分与用户反馈机制
## 架构约束(来自 devflow)
- Spring Boot 3.2 + Spring AI Alibaba
- JPA ddl-auto=validate,变更走 Flyway
- 已有实体:`DiagnosisSession`(含 selfEvaluation JSON、feedback VARCHAR)、`CaseLibrary`
- **新增字段**:`DiagnosisSession.answer TEXT`,存储返回给用户的完整答案,Flyway V008 迁移
- 已有 Repository:`DiagnosisSessionRepository`、`CaseLibraryRepository`
- 当前主流程入口:`ChatService.executeChat`(非流式)、`executeChatComplex`(多 Agent)
## 模块链路
```
用户对话
↓
ChatService.executeChat / executeChatComplex
↓ SUCCESS 后异步
EvaluationService.evaluate(sessionId, answer, steps)
├─ LLM 自评 → 写 selfEvaluation(含 confidence + reasoning)
└─ 规则兜底(LLM 失败时)→ 写 selfEvaluation(含 source: "rule")
用户提交反馈
↓
POST /api/feedback { sessionId, feedback }
↓
FeedbackService.submitFeedback(sessionId, feedback)
├─ 写 DiagnosisSession.feedback
├─ feedback=useful → 写 CaseLibrary
└─ feedback=not_useful → 更新 status=BAD_CASE
```
## 数据结构定义
### DiagnosisSession.selfEvaluation(JSON 字符串)
```json
{
"evidence_score": 65,
"source": "rule",
"factors": [
{"name": "has_successful_tool_call", "delta": 30, "description": "有成功的工具调用(2次)"},
{"name": "l1_semantic_match", "delta": 20, "description": "L1 语义匹配命中"}
]
}
```
字段说明:
- `evidence_score`:0-100,衡量证据收集充分度(非答案准确性)
- `source`:评分来源,当前固定为 `"rule"`;预留 `"llm"` 供后续 LLM 观点叠加
- `factors`:命中的规则因子列表,每项含 name / delta / description,可直接用于分析
- `llm_opinion`:预留字段,LLM 观点叠加时扩展此处,不改变现有规则逻辑
### FeedbackRequest(新 DTO)
```java
public class FeedbackRequest {
String sessionId; // 必填
String feedback; // "useful" | "not_useful"
}
```
### FeedbackResponse(新 DTO)
```java
public class FeedbackResponse {
boolean success;
String message;
String caseId; // useful 时返回生成的 case_id,否则 null
}
```
### CaseLibrary 生成规则(useful 时)
| CaseLibrary 字段 | 来源 |
|---|---|
| caseId | UUID |
| diagnosisId | DiagnosisSession.sessionId |
| sourceType | SourceType.AUTO |
| faultCategory | FaultCategory.GENERAL(暂时) |
| title | DiagnosisSession.query 前 100 字符 |
| rootCause | DiagnosisSession.answer(完整答案,不截断) |
| solution | DiagnosisSession.answer(同上) |
| createdBy | "system" |
## 关键技术决策
### 决策 1:LLM 自评异步执行
置信度计算在 Agent 主流程结束后异步进行(`@Async` + Spring 线程池),不阻塞用户响应。
原因:LLM 自评耗时 1-3 秒,主流程不应等待。
### 决策 2:置信度规则兜底参数
```
基础分:60
工具调用加分:toolCallCount × 5,上限 +20
步数少加分:stepCount <= 3 → +10
status=FAILED → 直接 0
```
### 决策 3:BAD_CASE 用 status 字段而非新字段
`DiagnosisSession.status` 已有 PENDING/RUNNING/SUCCESS/FAILED,扩展为允许包含 BAD_CASE。
该字段是 VARCHAR 16,直接存字符串,无需枚举类(Java 端用常量控制)。
### 决策 4:案例内容提取策略
useful 时,`rootCause` 和 `solution` 从 `AgentStepRepository.findBySessionIdOrderByStepIndex` 的最后一步 `thought` 字段提取。
如果 thought 为空,则用 DiagnosisSession.query + "(自动提取失败,请人工补充)" 占位。
## 接口影响等级
- `POST /api/feedback`:新增接口,L2(内部,前端新消费)
- `ChatService.executeChat`:新增异步后置调用,不改返回值,L1
- `DiagnosisSession.status` 增加 BAD_CASE 值:原调用方只读不写此字段,L2
@@ -0,0 +1,61 @@
# Proposal: 置信度评分与用户反馈机制
## 问题
DiagnosisSession 已预留 `selfEvaluation`(JSON)和 `feedback`(VARCHAR 16)两个字段,但目前完全为空——Agent 完成对话后不计算置信度,也没有接收用户反馈的 API,无法支撑报告质量评估和 BadCase 追踪。
## 建议方案
### 置信度评分(双轨)
**主轨:LLM 自评**
- 在 ChatService 的 `executeChat` 流程结束后,追加一次轻量 LLM 调用(EvaluationService),
将 Agent 的最终答案 + 步骤摘要传给模型,要求输出 `{"confidence": 0-100, "reasoning": "..."}` JSON。
- 结果写入 `DiagnosisSession.selfEvaluation`。
**兜底轨:规则计算**
- 若 LLM 自评失败(超时/解析失败),用规则计算:
- 基础分 60
- 工具调用数 > 0 每次 +5(上限 +20)
- 步数 <= 3 额外 +10
- 状态为 FAILED 直接 0
- 兜底结果同样写入 `selfEvaluation`,并附 `"source": "rule"` 标记。
### 用户反馈 API
新增 `POST /api/feedback`,接收:
```json
{ "sessionId": "xxx", "feedback": "useful" | "not_useful" }
```
后端操作:
1. 写入 `DiagnosisSession.feedback`。
2. 若 `feedback = "not_useful"`,将 `status` 更新为 `BAD_CASE`(需要在 status 枚举扩展此值)。
3. 若 `feedback = "useful"`,写入一条 `CaseLibrary` 记录(从 session 提取 query/answer)。
## 范围
- 新建 `EvaluationService`(置信度计算)
- 新建 `FeedbackService`(反馈处理)
- 新增 `POST /api/feedback` 接口(在 ChatController 或新 FeedbackController)
- 改造 `ChatService.executeChat` 在 SUCCESS 后调用 EvaluationService
- `DiagnosisSession.status` 枚举扩展 `BAD_CASE` 值
- Flyway 迁移:`diagnosis_session.status` 列注释更新(不改类型,字段已存在)
- 无需新建数据库表
## 非目标
- 不实现 Verifier Agent 完整链路(只做轻量自评,不是多 Agent 编排)
- 不实现案例沉淀的复杂结构化字段(CaseLibrary 的 faultCategory/errorCode 等填 GENERAL/null)
- 不实现 BadCase 的自动分析或 Prompt 优化流程
## 关键约束(来自 devflow)
- JPA ddl-auto = validate,表结构变更必须走 Flyway 迁移,但本次无需加新列
- `DiagnosisSession.selfEvaluation` 已声明为 JSON 类型,直接用 String 写入
- `CaseLibrary.faultCategory` 是枚举,默认填 GENERAL
- `SourceType.AUTO` 表示系统自动生成
## 风险
- LLM 自评 prompt 质量影响分数可信度,需要在 reasoning 字段记录依据
- BAD_CASE status 与现有 PENDING/RUNNING/SUCCESS/FAILED 并存,需确认 UI 是否受影响
@@ -0,0 +1,44 @@
# Functional Spec: 置信度评分与用户反馈机制
## REQ-1:置信度 LLM 自评
- Agent 对话(executeChat / executeChatComplex)成功后,异步调用 EvaluationService。
- EvaluationService 构造 Prompt,调用 ChatModel,要求输出纯 JSON:`{"confidence": 0-100, "reasoning": "...", "source": "llm"}`。
- 若 JSON 解析成功,写入 `DiagnosisSession.selfEvaluation`。
- 若调用失败或解析失败,转入规则兜底(REQ-2)。
- 验收:对话结束后数秒内,DB `diagnosis_session.self_evaluation` 非 null,且 `source` 字段存在。
## REQ-2:置信度规则兜底
- 触发条件:LLM 自评失败(任何异常)。
- 规则:基础分 60 + toolCallCount×5(上限+20)+ (stepCount<=3 ? +10 : 0),status=FAILED 则直接 0。
- 结果写入 `selfEvaluation`,含 `"source": "rule"`。
- 验收:LLM 自评失败时,self_evaluation 仍有值(非 null),且 source=rule。
## REQ-3:反馈接收 API
- 接口:`POST /api/feedback`
- 入参:`{ "sessionId": "xxx", "feedback": "useful" | "not_useful" }`
- 出参:`{ "success": true/false, "message": "...", "caseId": "uuid 或 null" }`
- 校验:sessionId 不能为空;feedback 只能是 useful 或 not_useful,否则返回 400。
- 验收:接口返回 200,DB 对应行 feedback 字段有值。
## REQ-4:useful → 案例沉淀
- 触发条件:feedback = "useful"。
- 操作:在 case_library 插入一条记录,caseId=UUID,sourceType=AUTO,faultCategory=GENERAL,
title=query 前 100 字,rootCause/solution 来自最后一步 agent_step.thought。
- 响应中返回 caseId。
- 验收:提交 useful 后,case_library 表新增一行,diagnosis_id = sessionId。
## REQ-5:not_useful → BAD_CASE 标记
- 触发条件:feedback = "not_useful"。
- 操作:feedback 字段本身即为标记,不修改 status 字段(status 保持执行状态语义)。
- 查询 BadCase 使用:`WHERE feedback = 'not_useful'`。
- 验收:提交 not_useful 后,DB diagnosis_session.feedback = "not_useful",status 不变。
## REQ-6:幂等性
- 同一 sessionId 重复提交 feedback,覆盖写入(不报错,不重复创建 CaseLibrary)。
- 已有 case_library 记录时(diagnosisId 已存在),跳过插入并返回已有 caseId。
@@ -0,0 +1,133 @@
# Tasks: 置信度评分与用户反馈机制
## T0:Flyway 迁移 + DiagnosisSession 实体加字段
**文件**:
- `src/main/resources/db/migration/V008__add_answer_to_diagnosis_session.sql`(新建)
- `src/main/java/com/superbiz/agent/domain/entity/DiagnosisSession.java`(加字段)
**迁移脚本**:
```sql
ALTER TABLE diagnosis_session ADD COLUMN answer LONGTEXT COMMENT 'Agent 返回给用户的完整答案';
```
**实体**:在 `DiagnosisSession` 加:
```java
@Column(name = "answer", columnDefinition = "LONGTEXT")
private String answer;
```
**验收标准**:应用启动不报 schema validation 错误;`diagnosis_session` 表有 answer 列
---
— LLM 自评 + 规则兜底
**文件**:`src/main/java/com/superbiz/agent/service/EvaluationService.java`
**实现**:
- `@Service @Async` 标注
- `evaluate(String sessionId, String answer)` 方法:
1. 从 `DiagnosisSessionRepository` 加载 session(含 stepCount、toolCallCount、status)
2. 调用 ChatModel 做 LLM 自评,Prompt 见下
3. 解析 JSON → 写入 `selfEvaluation`
4. 失败时走规则兜底
- 规则兜底逻辑:`computeRuleScore(session)` → 返回 JSON 字符串
**LLM 自评 Prompt(系统提示)**:
```
你是一个 AI 回答质量评估器。
请根据以下信息,评估这次 AI 回答的置信度(0-100分):
- 用户原始问题:{query}
- AI 的回答:{answer}
- 工具调用次数:{toolCallCount}
- 推理步数:{stepCount}
只返回一个 JSON,格式如下,不要输出任何其他内容:
{"confidence": <0-100的整数>, "reasoning": "<评估依据,50字以内>"}
```
**验收标准**:
- LLM 正常时:DB selfEvaluation 包含 confidence 和 reasoning,source = "llm"
- LLM 失败时:DB selfEvaluation 包含 confidence 和 source = "rule"
---
## T2:ChatService 后置调用 EvaluationService
**文件**:`src/main/java/com/superbiz/agent/service/ChatService.java`
**实现**:
- 在 `executeChat` 的 `session.setStatus("SUCCESS")` 之后,追加 `session.setAnswer(answer)` 写入完整答案,再注入 EvaluationService 调用 `evaluate(sessionId, answer)`
- 在 `executeChatComplex` 的 SUCCESS 分支同样补充 `session.setAnswer(answer)`
- 注意:EvaluationService 是 @Async,调用方不等待返回值
**验收标准**:发送一次 chat 请求后,数秒内 DB self_evaluation 非 null
---
## T3:FeedbackController + FeedbackService
**文件**:
- `src/main/java/com/superbiz/agent/controller/FeedbackController.java`(新建)
- `src/main/java/com/superbiz/agent/service/FeedbackService.java`(新建)
- `src/main/java/com/superbiz/agent/dto/FeedbackRequest.java`(新建)
- `src/main/java/com/superbiz/agent/dto/FeedbackResponse.java`(新建)
**FeedbackService.submitFeedback(sessionId, feedback)**:
1. 加载 session,sessionId 不存在抛异常
2. 校验 feedback 值(useful/not_useful)
3. 更新 `DiagnosisSession.feedback`
4. if useful:调用 `CaseLibraryService.createFromSession(session)`
5. if not_useful:更新 `DiagnosisSession.status = "BAD_CASE"`
6. 保存 session
7. 返回 FeedbackResponse
**幂等逻辑(useful 重复提交)**:
- 调用 `CaseLibraryRepository.findByDiagnosisId(sessionId)` 检查
- 已存在则返回已有 caseId,不重复插入
**FeedbackController**:
```
POST /api/feedback
@RequestBody FeedbackRequest
@ResponseBody FeedbackResponse
```
**验收标准**:
- useful:返回 200,feedback 字段有值,case_library 新增一行
- not_useful:返回 200,status = BAD_CASE
- 非法 feedback 值:返回 400
---
## T4:CaseLibraryService — createFromSession
**文件**:`src/main/java/com/superbiz/agent/service/CaseLibraryService.java`(新建)
**实现**:
- `createFromSession(DiagnosisSession session)` → `CaseLibrary`
- 直接从 `session.getAnswer()` 取完整答案
- answer 为空时用占位文本 `query + "\n(自动提取失败,请人工补充)"`
- 填写 CaseLibrary 各字段,save 后返回 caseId
**验收标准**:case_library 行的 diagnosis_id = sessionId,root_cause 非空
---
## T5:Spring @Async 配置
**文件**:检查项目是否已有 `@EnableAsync`,若无则在 `SessionConfiguration` 或新建 `AsyncConfig` 中添加
**验收标准**:EvaluationService 中 @Async 方法可被正确调度(不抛 bean 配置错误)
---
## T6:集成验证
验证步骤:
1. 启动服务,POST /api/chat,发送一条问题
2. 查 `diagnosis_session` 表,确认 self_evaluation 有值
3. POST /api/feedback `{"sessionId": "xxx", "feedback": "useful"}`,确认 case_library 新增
4. POST /api/feedback `{"sessionId": "yyy", "feedback": "not_useful"}`,确认 status = BAD_CASE
5. 重复步骤 3,确认不重复创建 case_library
@@ -0,0 +1 @@
committed
@@ -0,0 +1,157 @@
# Design: session-dedup-knowledge-map
## 1. 整体架构
本 change 包含两个独立但互补的部分:
```
Part A: 工具层去重
LookupKnowledgeTool
├── 维护 ConcurrentHashMap<sessionId, Set<filePath>>(JVM 内)
├── 每次检索前过滤已召回文档
└── SessionContextHolder.clear() 时同步清理
Part B: 知识图谱
┌─────────────────────────────────────────────┐
│ 文档上传 (DocumentManagementService) │
│ → LLM 生成 doc.covers + doc.when_to_retrieve │
│ → 存入 api_document.metadata │
│ → 触发域级重算 (KnowledgeDomainService) │
└─────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────┐
│ 域级聚合 (KnowledgeDomainService) │
│ → 读取同域所有文档的 when_to_retrieve │
│ → LLM 生成 domain.when_to_retrieve │
│ → 存入 knowledge_domain 表 │
└─────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────┐
│ 启动 (KnowledgeIndexService.loadIndex) │
│ → 加载 knowledge_domain 表 │
│ → 某域无记录则触发域级生成 │
└─────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────┐
│ Planner prompt (ChatService) │
│ → 注入 knowledge map(域级) │
│ → Planner 做粗粒度检索决策 │
└─────────────────────────────────────────────┘
```
---
## 2. 数据结构定义
### 2.1 Frontmatter 新增字段
```yaml
# 新增两个字段,其余不变
covers: ["支付失败排查", "扣款无回调"] # List<String>:业务场景标签,Planner 决策用
when_to_retrieve: "用户描述支付失败、超时时" # String:文档级检索时机,LLM 上传时生成
```
对应 `Frontmatter.java` 新增两个字段:
- `List<String> covers`
- `String whenToRetrieve`
对应 `KnowledgeEntry.java` 新增两个字段(同上)。
### 2.2 knowledge_domain 表(新表)
```sql
CREATE TABLE knowledge_domain (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
domain_id VARCHAR(64) NOT NULL UNIQUE, -- category 值,如 "payment"
description VARCHAR(256), -- 域描述(聚合自文档 summary)
when_to_retrieve TEXT, -- 域级检索时机(LLM 生成)
document_count INT DEFAULT 0, -- 该域当前文档数
updated_at DATETIME,
created_at DATETIME
);
```
### 2.3 knowledge map 结构(注入 Planner 的 YAML 文本)
```yaml
available_knowledge_domains:
- domain_id: "payment"
description: "支付链路问题排查"
when_to_retrieve: "用户问题涉及支付、退款、对账时检索;优先检索一次,勿重复"
documents:
- title: "支付失败排查手册"
covers: ["支付超时", "扣款无回调"]
- title: "退款处理指南"
covers: ["退款未到账", "退款状态异常"]
- domain_id: "infrastructure"
...
```
---
## 3. 新增组件
### 3.1 KnowledgeDomainService(新类)
职责:域级聚合与存储
```
buildDomainSummary(category)
→ 读取同域所有 KnowledgeEntry(含 when_to_retrieve)
→ 拼装 prompt,调用 LLM
→ 写入 knowledge_domain 表
buildKnowledgeMap()
→ 读取所有 knowledge_domain 记录
→ 拼装 YAML 文本(含 documents 列表)
→ 返回 String(供 Planner prompt 注入)
onDocumentChange(category)
→ 调用 buildDomainSummary(category)(只重算受影响域)
```
### 3.2 RetrievedDocTracker(新类,或内联入 LookupKnowledgeTool)
职责:session 级已召回文档追踪
```
ConcurrentHashMap<String, Set<String>> retrieved
key: sessionId
value: Set<filePath>
isAlreadyRetrieved(sessionId, filePath) → boolean
markRetrieved(sessionId, filePath)
clearSession(sessionId) ← 由 SessionContextHolder.clear() 触发
```
---
## 4. 改动文件清单
| 文件 | 改动类型 | 说明 |
|---|---|---|
| `LookupResult.java` | 修改 | 新增 `message` 字段(去重提示文本) |
| `Frontmatter.java` | 修改 | 新增 `covers`、`whenToRetrieve` |
| `KnowledgeEntry.java` | 修改 | 新增 `covers`、`whenToRetrieve` |
| `FrontmatterParser.java` | 修改 | 解析新字段 |
| `DocumentManagementService.java` | 修改 | upload 时调 LLM 生成文档级字段;upload/delete 后触发域级重算 |
| `KnowledgeIndexService.java` | 修改 | loadIndex 时加载域级数据;若域无记录则触发生成 |
| `KnowledgeDomainService.java` | 新增 | 域聚合、LLM 调用、DB 读写、buildKnowledgeMap |
| `KnowledgeDomain.java`(entity) | 新增 | knowledge_domain 表映射 |
| `KnowledgeDomainRepository.java` | 新增 | JPA Repository |
| `LookupKnowledgeTool.java` | 修改 | 集成 RetrievedDocTracker,检索前过滤,检索后标记 |
| `SessionContextHolder.java` | 修改 | clear() 时通知 RetrievedDocTracker |
| `RetrievedDocTracker.java` | 新增 | session 级去重状态管理 |
| `ChatService.java` | 修改 | buildChatPlannerAgent 注入 knowledge map |
| `chat-planner-prompt.md` | 修改 | 添加 knowledge map 使用规则 |
| `V009__add_knowledge_domain.sql` | 新增 | Flyway 建表脚本 |
---
## 5. 关键决策记录
1. **域级 when_to_retrieve 存 DB**:避免每次重启调 LLM;文档变更时只重算受影响域
2. **文档级 when_to_retrieve 存 metadata JSON**:沿用现有 frontmatter 存储路径,无需新字段
3. **RetrievedDocTracker 独立于 SessionContextHolder**:SessionContextHolder 只持有 sessionId,Tracker 是业务状态,职责分离;clear() 时通过 Tracker.clearSession() 联动
4. **Planner 只看域级**:文档级 when_to_retrieve 留 Executor 筛选(Phase 2),MVP 不暴露给 Planner
5. **LLM 调用同步执行**:上传时同步生成,接受约 1-2s 延迟,保证数据库和 L0 索引立即一致
@@ -0,0 +1,61 @@
# Proposal: session-dedup-knowledge-map
## 问题
1. **ISS-001 重复召回**:`LookupKnowledgeTool` 每次调用完全无状态,同一 session 中同一文档可被重复召回 13+ 次,浪费 token、压缩上下文窗口、导致 `tool_call_count` 虚高。
2. **Planner 缺少全局视野**:Planner 不知道知识库里有哪些域,只能靠 Executor 反复试探,导致低效的"盲目检索"模式。
## 建议方案
### Part A:工具层去重(彻底修复 ISS-001)
在 `LookupKnowledgeTool` 的 session 维度维护已召回文档 ID 集合。
每次检索时,过滤掉已召回的文档;相同 query 命中相同文档则直接跳过(返回"已在上下文中"提示)。
状态存储:`ConcurrentHashMap<sessionId, Set<docKey>>`,生命周期随 session(`SessionContextHolder.clear()` 时清理)。
### Part B:知识图谱注入 Planner
启动时(`KnowledgeIndexService.loadIndex()` 完成后),将 L0 索引中的所有 `KnowledgeEntry` 聚合为域级摘要(knowledge map)。
每次构建 Planner prompt 时(`buildChatPlannerAgent()`),将 knowledge map 注入 system prompt,让 Planner 有"知识边界"。
聚合策略:按 `category` 字段分组,生成结构:
```
available_knowledge_domains:
- domain_id: "payment"
description: "..."
covers: [...]
document_count: N
when_to_retrieve: "..."
```
知识图谱的 `description` / `when_to_retrieve` 字段来源于:
- 选项 1:直接聚合 KnowledgeEntry 的 title/summary
- 选项 2:文档 frontmatter 中新增 `domain_description` / `when_to_retrieve` 字段
- 选项 3:上传时 LLM 自动生成这两个字段
## 范围
**In scope**:
- `LookupKnowledgeTool`:添加 session 级去重状态管理
- `KnowledgeIndexService`:添加 `buildKnowledgeMap()` 方法
- `ChatService.buildChatPlannerAgent()`:注入 knowledge map 到 prompt
- `chat-planner-prompt.md`:添加如何使用 knowledge map 的指令
**Out of scope**(本次不做):
- `EvaluationService.tool_call_count` 的统计口径调整(去重后虚高问题自然消失,但评分规则不改)
- RRF 混合重排
- 文档 frontmatter 自动生成(上传时 LLM 生成,留 Phase 2)
## 风险
- Part A 引入 JVM 内存 Map,高并发时多 session 并发需线程安全
- Part B knowledge map 注入 Planner prompt 会增加每次请求的 token 消耗(固定开销)
- 文档 `category` 字段缺失或不规范时,聚合结果可能混乱
## 上下文约束
- `SessionContextHolder` 是 ThreadLocal,异步路径不安全(已知限制,Part A 需确认同步路径)
- `EvaluationService` 依赖 `tool_call_count`,去重会降低此值(是修复,不是回归)
- `KnowledgeEntry` 已有 `category` 字段,但当前数据库中的文档是否都有 `category` 需确认
@@ -0,0 +1,87 @@
# Functional Spec: session-dedup-knowledge-map
## REQ-01:工具层去重(Part A)
**触发**:`LookupKnowledgeTool.lookupKnowledge(query)` 被调用
**行为**:
1. 从 `SessionContextHolder.getSessionId()` 获取当前 sessionId;若为 null(非会话上下文)跳过去重逻辑,正常检索
2. L0+L1 检索完成后,将结果中已在 `RetrievedDocTracker` 中标记的 filePath 过滤掉
3. 若过滤后 L0 结果为空、L1 结果也为空(全部已召回),返回 `LookupResult.found=false`,并在结果中附带提示文本:"以下文档已在本会话中检索过:[列表],无需重复召回"
4. 未被过滤的文档正常返回后,将其 filePath 写入 `RetrievedDocTracker`
5. `SessionContextHolder.clear()` 调用时,`RetrievedDocTracker.clearSession(sessionId)` 同步清理
**验收**:
- 同一 session 内同一文档第二次命中时,返回去重提示而非完整文档内容
- 不同 session 之间互不影响
- sessionId 为 null 时不影响正常检索流程
---
## REQ-02:文档级 LLM 字段生成(Part B - 文档级)
**触发**:`DocumentManagementService.uploadDocument()` 完成 frontmatter 解析后
**行为**:
1. 若文档 frontmatter 中已包含 `covers` 和 `whenToRetrieve`,跳过 LLM 生成(作者手动填写优先)
2. 否则,调用 LLM,输入为文档 title + summary + 正文前 1000 字符
3. Prompt 要求 LLM 返回 JSON:`{"covers": [...], "whenToRetrieve": "..."}`
4. 解析结果,回填到 `Frontmatter` 对象
5. 序列化存入 `api_document.metadata`;同步更新 `KnowledgeEntry` 写入 L0 索引
6. LLM 调用失败时,`covers` 置为空列表,`whenToRetrieve` 置为 summary(降级),不阻断上传流程
**验收**:
- 上传后 `api_document.metadata` 中包含 `covers` 和 `whenToRetrieve` 字段
- frontmatter 已有这两个字段时不覆盖
- LLM 调用异常时文档仍上传成功,字段降级填充
---
## REQ-03:域级聚合与存储(Part B - 域级)
**触发**:文档上传成功后;文档删除后;`loadIndex()` 时发现某域在 `knowledge_domain` 表无记录
**行为**:
1. `KnowledgeDomainService.onDocumentChange(category)` 读取该 category 下所有 `KnowledgeEntry` 的 title + covers + whenToRetrieve
2. 调用 LLM,生成域级 `when_to_retrieve`(要求 LLM 识别域内文档边界,输出含区分语义的路由描述)
3. 写入 `knowledge_domain` 表(upsert by domain_id),同时更新 `document_count`
4. LLM 调用失败时,`domain.when_to_retrieve` 保留上次 DB 记录;若无历史记录则置为空字符串
**验收**:
- 上传文档后,对应 category 的 `knowledge_domain` 记录被更新
- 删除文档后,对应 category 的 `document_count` 减少,`when_to_retrieve` 重新生成
- `loadIndex()` 时无 DB 记录的域自动触发生成
---
## REQ-04:knowledge map 注入 Planner(Part B - 注入)
**触发**:`ChatService.buildChatPlannerAgent()` 调用时
**行为**:
1. 调用 `KnowledgeDomainService.buildKnowledgeMap()` 生成 YAML 文本
2. YAML 结构:域列表,每个域包含 domain_id、description、when_to_retrieve、documents(title + covers)
3. 若 knowledge_domain 表为空(无任何域记录),跳过注入,不修改 prompt
4. 注入位置:Planner system prompt 末尾,独立区块
**Planner prompt 附加规则**:
- 制定步骤时,先查看 `available_knowledge_domains`,按 `when_to_retrieve` 判断是否需要检索该域
- 每个域最多指示 Executor 检索一次;已检索过的域不再安排检索步骤
**验收**:
- Planner prompt 包含 `available_knowledge_domains` 区块
- 无域记录时 prompt 不包含该区块(不注入空结构)
- knowledge map 文本长度 < 1000 字符(6 个文档场景下)
---
## REQ-05:Frontmatter 字段扩展
**行为**:
- `Frontmatter.java` 新增 `List<String> covers` 和 `String whenToRetrieve`
- `KnowledgeEntry.java` 新增同名字段
- `FrontmatterParser.java` 解析 `covers`(YAML 数组)和 `when_to_retrieve`(YAML 字符串)
**验收**:
- 现有文档(无新字段)上传/解析不报错,字段为 null 或空列表
- 含新字段的文档正确解析
@@ -0,0 +1,74 @@
# Tasks: session-dedup-knowledge-map
## T1:数据层基础
**T1-1:新增 knowledge_domain 表** ✅
- 创建 `src/main/resources/db/migration/V009__add_knowledge_domain.sql`
- 字段:id、domain_id(unique)、description、when_to_retrieve(TEXT)、document_count、created_at、updated_at
**T1-2:新增 KnowledgeDomain 实体和 Repository** ✅
- `KnowledgeDomain.java`:JPA 实体,对应 knowledge_domain 表
- `KnowledgeDomainRepository.java`:`findByDomainId(String)` + save
---
## T2:Frontmatter 扩展
**T2-1:Frontmatter.java / KnowledgeEntry.java 新增字段** ✅
- `Frontmatter`:新增 `List<String> covers`、`String whenToRetrieve`
- `KnowledgeEntry`:新增 `List<String> covers`、`String whenToRetrieve`
**T2-2:FrontmatterParser 解析新字段** ✅
- 解析 YAML 中的 `covers`(List)和 `when_to_retrieve`(String)
**T2-3:KnowledgeIndexService 替换为 Jackson 解析** ✅
- 全量替换手写 extractJsonValue/extractJsonArray 为 `objectMapper.readValue(metadata, Frontmatter.class)`
**T2-4:LookupResult 新增 message 字段** ✅
- 新增 `String message` 字段,去重时填入提示
---
## T3:工具层去重(Part A)
**T3-1:新增 RetrievedDocTracker** ✅
- `RetrievedDocTracker.java`:Spring `@Component`,`ConcurrentHashMap<String, Set<String>>`
- 方法:`isAlreadyRetrieved`、`markRetrieved`、`clearSession`
**T3-2:ChatService.finally 联动 Tracker** ✅
- `executeChat` / `executeChatComplex` 的 finally 块显式调用 `retrievedDocTracker.clearSession(sessionId)`
**T3-3:LookupKnowledgeTool 集成去重** ✅
- 注入 `RetrievedDocTracker`,检索后过滤已召回文档,全部已召回时返回去重提示
---
## T4:文档级 LLM 生成(Part B 文档级)
**T4-1:新增 DocumentFieldEnricher + 上传时调用** ✅
- `DocumentFieldEnricher.java`:调用 LLM 生成 covers / whenToRetrieve
- `DocumentManagementService.uploadDocument()` frontmatter 解析后调用 enrich
- 失败时降级(covers=空列表,whenToRetrieve=summary),不阻断上传
---
## T5:域级聚合(Part B 域级)
**T5-1:新增 KnowledgeDomainService** ✅
- `buildDomainSummary`:同域文档聚合 → LLM → upsert knowledge_domain
- `buildKnowledgeMap`:全量 knowledge_domain → YAML 字符串
- `onDocumentChange`:触发 buildDomainSummary
**T5-2:loadIndex 触发域生成 + 文档变更触发域重算** ✅
- `KnowledgeIndexService.loadIndex` 末尾:无 DB 记录的域自动触发生成
- `DocumentManagementService.uploadDocument` / `deleteDocument` 末尾:调用 `onDocumentChange`
---
## T6:Planner 注入(Part B 注入)
**T6-1:ChatService 注入 knowledge map** ✅
- `buildChatPlannerAgent()` 注入 `KnowledgeDomainService.buildKnowledgeMap()` 到 prompt
**T6-2:chat-planner-prompt.md 新增规则** ✅
- 新增知识库检索规则区块,要求 Planner 按 when_to_retrieve 决策、每域最多一次
@@ -0,0 +1,6 @@
Archive-ready for executor-action-memory-relevance
Created: 2026-07-01
Tasks complete: 7/7
Verification: static + script + manual passed
Unverified: PRECISE scenario, domain_retrieved scenario (low risk)
@@ -0,0 +1,189 @@
# Design: executor-action-memory-relevance
## 架构设计
### 整体数据流
```
用户问题
→ Supervisor → Planner(规划查哪些域)
→ Supervisor → Executor(自主调用 lookup_knowledge)
↓
LookupKnowledgeTool
├─ L0 精确匹配 → l0Matches (含 category)
├─ L1 语义检索 → l1Results (含 L2 score)
├─ 归一化层 → computeRelevanceLevel(l0Count, l1TopScore)
│ L2 距离 → similarity = 1 - min(score, 2.0) / 2.0
│ L0 唯一匹配 → PRECISE
│ L0 命中 + L1 similarity ≥ 0.75 → HIGHLY_RELEVANT
│ 仅 L1 similarity ≥ 0.75 → HIGHLY_RELEVANT
│ L0 多匹配 + L1 similarity [0.5, 0.75) → REFERENCE
│ 仅 L1 similarity [0.5, 0.75) → REFERENCE
├─ 域级行动记忆 → RetrievedDocTracker.markRetrieved(sessionId, domain, filePath)
│ getRetrievedDomains(sessionId) → retrievedDomainsThisSession
├─ 文档级去重 → 保留现有逻辑
└─ 组装 LookupResult(含 relevanceLevel, completenessHint, retrievedDomainsThisSession)
↓
LLM 看到:
relevanceLevel: PRECISE
completenessHint: "知识库中不存在比上述结果更精准的文档"
retrievedDomainsThisSession: ["infrastructure", "api"]
```
### Agent 边界(保持清晰)
| Agent | 知道什么 | 不知道什么 |
|-------|---------|-----------|
| Planner | 全域知识边界(knowledge map) | 执行细节、检索结果 |
| Executor | 自己的行动记忆(已检索域列表) | 全域知识边界(不注入 knowledge map) |
行动记忆通过**工具返回值**传递,不通过 prompt 注入。
### 数据结构设计
#### 1. RetrievedDocTracker 升级
```java
// 现有:sessionId → Set<filePath>(文档级)
ConcurrentHashMap<String, Set<String>> retrieved
// 新增:sessionId → { domain → Set<filePath> }(域级 + 文档级)
ConcurrentHashMap<String, Map<String, Set<String>>> sessionRetrievals
```
方法列表:
- `markRetrieved(sessionId, domain, filePath)` — 一次记录两层
- `isDocRetrieved(sessionId, filePath)` → boolean — 文档级去重(替代现有 isAlreadyRetrieved)
- `isDomainRetrieved(sessionId, domain)` → boolean — 域级检查(Phase 2 硬限制用)
- `getRetrievedDomains(sessionId)` → List<String> — 行动记忆(返回给 LLM)
- `clearSession(sessionId)` — 清理(不变)
#### 2. LookupResult 扩展
```java
@Data @Builder
public class LookupResult {
boolean found;
PrimaryResult primary; // 不变,不暴露原始分数
SupplementResult supplement; // 不变,不暴露原始分数
// ---- 新增 ----
String relevanceLevel; // PRECISE / HIGHLY_RELEVANT / REFERENCE
String completenessHint; // 兜底信号
List<String> retrievedDomainsThisSession; // 行动记忆
String message; // 不变
}
```
**PrimaryResult 和 SupplementResult 不加任何分数字段**。原始分数在归一化层内部消化。
#### 3. 归一化计算
`RelevanceNormalizer`(LookupKnowledgeTool 内部静态方法):
```
输入:l0MatchCount, l1TopScore (L2 距离)
输出:RelevanceAssessment { relevanceLevel, completenessHint }
归一化公式(BGE-M3 输出 L2 归一化单位向量,已实测验证):
similarity = 1 - min(l2Score, maxL2Distance) / maxL2Distance
maxL2Distance 默认 2.0,yml 可覆盖
判定逻辑:
if l0MatchCount == 1 → PRECISE
if l0MatchCount > 1 && l1Similarity >= highlyRelevantThreshold → HIGHLY_RELEVANT
if l0MatchCount == 0 && l1Similarity >= highlyRelevantThreshold → HIGHLY_RELEVANT
if l0MatchCount > 1 && l1Similarity >= referenceThreshold → REFERENCE
if l0MatchCount == 0 && l1Similarity >= referenceThreshold → REFERENCE
else → 无结果
completenessHint 映射:
PRECISE → "知识库中不存在比上述结果更精准的文档"
HIGHLY_RELEVANT → "当前结果已高度相关,继续检索不太可能找到更精准的文档"
REFERENCE → "当前结果为相关参考,如需更精准信息请明确缺少的具体维度"
```
配置项(application.yml):
```yaml
retrieval:
normalization:
max-l2-distance: 2.0 # L2 距离上界(单位向量 = 2.0)
highly-relevant-threshold: 0.75 # similarity ≥ 0.75 → HIGHLY_RELEVANT
reference-threshold: 0.5 # similarity ≥ 0.5 → REFERENCE
```
#### 4. 入库记录扩展
`tool_invocation` 表新增列:
| 列名 | 类型 | 说明 |
|------|------|------|
| `relevance_level` | VARCHAR(20) | PRECISE / HIGHLY_RELEVANT / REFERENCE / DEDUPED |
| `dedup_reason` | VARCHAR(32) | doc_retrieved / domain_retrieved / null |
`retrieval_details` JSON 扩展:
```json
{
"l0_match_count": 2,
"l0_titles": ["MySQL连接池配置", "HikariCP参数调优"],
"l1_top_score": 0.52,
"l1_top_similarity": 0.74,
"l1_match_count": 3,
"l1_scores": [0.52, 0.68, 0.91],
"relevance_level": "HIGHLY_RELEVANT",
"completeness_hint": "当前结果已高度相关...",
"retrieved_domains": ["infrastructure"],
"dedup_reason": null
}
```
原始 L2 score 和归一化后的 similarity 都入库,保留可观测性。
### Executor Prompt 设计
不加 knowledge map,只加基于行动记忆的行为规则:
```markdown
## 检索约束
### 1. 判断重复:基于已检索上下文
每次 lookup_knowledge 返回值中包含 retrievedDomainsThisSession,
表示本次会话已检索过的知识域。如果当前问题与已检索域语义重叠,
**禁止再次调用 lookup_knowledge**。
### 2. 重复了该怎么办
如果当前想检索的内容与【已检索上下文】语义相似:
- 禁止换关键词重新检索
- 直接基于已有事实回答
- 如果信息不足,先明确指出缺少什么具体维度
(如:"缺少 HikariCP 具体配置参数"、"缺少连接池耗尽的日志样例"),
再针对该维度进行一次定向补充检索——而非盲目换词重查
### 3. 合法出口:允许信息不全时给出结论
如果你认为已有信息足以回答核心问题,即使细节不全,
也请直接给出结论并说明局限性(如:"基于已有信息,连接池配置建议如下,
但具体参数值需结合实际负载调整")。
**不查全不会被追责,重复检索才会被惩罚。**
### 4. 利用质量信号判断
- relevanceLevel=PRECISE → 信息精准,直接使用,不再检索
- relevanceLevel=HIGHLY_RELEVANT + 域已在 retrievedDomainsThisSession → 禁止再次调用
- relevanceLevel=REFERENCE → 先指出缺什么维度,再定向补充一次
- completenessHint 是知识库给你的天花板信号,信任它
```
### 关键决策
1. **L0/L1 原始分数不暴露给 LLM** — 在归一化层内部消化,避免 LLM 混淆尺度
2. **BGE-M3 L2 归一化已实测验证** — 范数 1.00000002,maxL2Distance=2.0 是数学硬上界
3. **行动记忆通过工具返回值传递** — 不通过 prompt 注入,不修改 ReactAgent prompt 构建方式
4. **不给 Executor knowledge map** — 保持 Agent 边界:Planner 知道全域,Executor 只知道自己做了什么
5. **Phase 2 域级硬限制暂不实施** — 先观察 prompt 约束 + 归一化信号的效果
### 接口影响分级
| 变更 | 级别 | 说明 |
|------|------|------|
| RetrievedDocTracker 数据结构升级 | L2 内部接口 | 消费者只有 LookupKnowledgeTool,在同一实现范围内 |
| LookupResult 新增 3 个字段 | L2 内部接口 | 消费者是 LLM(工具返回值),无跨模块调用方 |
| tool_invocation 表新增 2 列 | L2 内部接口 | Flyway 迁移,nullable,不影响现有查询 |
| chat-executor-prompt.md 更新 | L1 内部实现 | Prompt 文本变更,不改变接口 |
@@ -0,0 +1,89 @@
# Proposal: executor-action-memory-relevance
## 问题
ISS-002:Executor 在单次会话中调用 `lookup_knowledge` 20+ 次,大部分是同域换变体的冗余调用。
根因:
1. **行动记忆缺失**:Executor 不知道自己已经检索过哪些域,反复用不同关键词查同一个域
2. **质量信号缺失**:检索结果没有归一化质量等级,LLM 无法判断"结果够不够"
3. **Prompt 约束缺失**:现有 executor prompt 要求"所有需要外部信息的地方都必须调用工具",没有"放弃检索"的合法出口
## 建议方案
### 1. 行动记忆(通过工具返回值传递)
`RetrievedDocTracker` 数据结构升级:`Map<sessionId, Map<domain, Set<filePath>>>`。
每次 `lookup_knowledge` 返回值附带 `retrievedDomainsThisSession`,让 Executor 知道自己本次会话已检索过哪些域。
**不给 Executor knowledge map**——保持 Agent 边界清晰:Planner 知道全域(规划查哪个域),Executor 只知道自己做了什么(执行检索 + 基于结果推理)。
### 2. 归一化质量等级(封装 L0/L1 分数差异)
在 `LookupKnowledgeTool` 内部新增归一化层,将 L0 匹配数和 L1 score 统一为三个等级:
| 等级 | 含义 | LLM 应做什么 |
|------|------|-------------|
| `PRECISE` | 精准命中 | 直接使用,不再检索 |
| `HIGHLY_RELEVANT` | 高度相关 | 综合推理,大概率不需要继续查 |
| `REFERENCE` | 相关参考 | 可参考,如需更精准请明确缺什么维度 |
归一化逻辑:
- L0 唯一匹配 → PRECISE
- L0 命中 + L1 高分 → HIGHLY_RELEVANT
- L0 多匹配 + L1 中分 → HIGHLY_RELEVANT
- L0 多匹配 + 无 L1 → REFERENCE
- 仅 L1 命中 → 按 score 分 HIGHLY_RELEVANT / REFERENCE
**L0/L1 原始分数不返回给 LLM**,只在归一化层内部使用。原始分数入库(`tool_invocation.retrieval_details`)保留可观测性。
### 3. 兜底信号(completenessHint)
每次返回附带 `completenessHint`,给 LLM "天花板"信号:
| relevanceLevel | completenessHint |
|----------------|-----------------|
| PRECISE | "知识库中不存在比上述结果更精准的文档" |
| HIGHLY_RELEVANT | "当前结果已高度相关,继续检索不太可能找到更精准的文档" |
| REFERENCE | "当前结果为相关参考,如需更精准信息请明确缺少的具体维度" |
### 4. Executor prompt 重写检索约束
- 基于 `retrievedDomainsThisSession` 判断重复(不是"不要重复",而是"重复了该怎么办")
- 给 LLM 合法出口:"不查全不会被追责,重复检索才会被惩罚"
- 利用 `relevanceLevel` + `completenessHint` 判断质量
### 5. 入库可观测性
`tool_invocation` 表新增 `relevance_level` 和 `dedup_reason` 列。
`retrieval_details` JSON 扩展:加入归一化等级、兜底信号、已检索域、去重原因、L1 top score。
## 范围
- `LookupKnowledgeTool`:归一化层 + 行动记忆注入 + 域级拦截
- `RetrievedDocTracker`:数据结构升级(域级记录)
- `LookupResult`:新增 `relevanceLevel`、`completenessHint`、`retrievedDomainsThisSession`
- `chat-executor-prompt.md`:检索约束重写
- `ToolInvocation` 实体 + V010 迁移:新增列
- `LookupKnowledgeTool.saveToolInvocation()`:扩展入库字段
## 非目标
- 不给 Executor 注入 knowledge map(保持 Agent 边界)
- 不修改 Planner prompt 或 Planner 逻辑
- 不修改 `PrimaryResult`/`SupplementResult` 的字段(不暴露原始分数给 LLM)
- Phase 2 域级硬限制暂不实施,先观察 prompt 约束效果
## 风险
1. L1 score 阈值(0.3/0.7)需要根据实际 embedding 分布调优,当前为初始值
2. 归一化等级可能让 LLM 过早停止检索——需实测观察 REFERENCE 场景下的行为
3. Prompt 约束仍依赖 LLM 遵守——如果效果不足,需启用 Phase 2 域级硬限制
## 来自 devflow 的上下文约束
- 前序 change `session-dedup-knowledge-map`:已实现文档级去重(RetrievedDocTracker + filePath)和 Planner knowledge map 注入
- ISS-001:文档级重复召回已修复
- glossary:ReactAgent 是自主决策工具调用的 Agent,不受外部流程控制
- JPA ddl-auto 使用 validate 模式,表结构修改必须通过 Flyway 迁移
@@ -0,0 +1,110 @@
# Functional Spec: executor-action-memory-relevance
## FS-1: L2 距离归一化
### 需求
LookupKnowledgeTool 内部将 L1 的 L2 距离归一化为 [0,1] 区间的 similarity 值,基于 BGE-M3 输出为 L2 归一化单位向量(已实测验证,范数=1.00000002)。
### 可观察行为
- 归一化公式:`similarity = 1 - min(l2Score, maxL2Distance) / maxL2Distance`
- `maxL2Distance` 默认 2.0,可通过 `retrieval.normalization.max-l2-distance` 覆盖
- 归一化阈值可通过 `retrieval.normalization.highly-relevant-threshold` 和 `retrieval.normalization.reference-threshold` 配置
- 归一化计算在 LookupKnowledgeTool 内部完成,不暴露原始分数给 LLM
### 验收标准
- [ ] L2 score=0 → similarity=1.0
- [ ] L2 score=1.0 → similarity=0.5
- [ ] L2 score=2.0 → similarity=0.0
- [ ] L2 score=3.0(超出上界)→ similarity=0.0(min 函数截断)
- [ ] 配置项可通过 yml 覆盖默认值
## FS-2: 归一化质量等级判定
### 需求
基于 L0 匹配数和归一化后的 L1 similarity,输出三等级 relevanceLevel + completenessHint。
### 可观察行为
- L0 唯一匹配 → PRECISE + "知识库中不存在比上述结果更精准的文档"
- L0 命中 + L1 similarity ≥ 0.75 → HIGHLY_RELEVANT + "当前结果已高度相关,继续检索不太可能找到更精准的文档"
- 仅 L1 similarity ≥ 0.75 → HIGHLY_RELEVANT + 对应 hint
- L0 多匹配 + L1 similarity [0.5, 0.75) → REFERENCE + "当前结果为相关参考,如需更精准信息请明确缺少的具体维度"
- 仅 L1 similarity [0.5, 0.75) → REFERENCE + 对应 hint
- L1 similarity < 0.5 → 不视为有效结果
- 无 L0 且无 L1 → found=false
### 验收标准
- [ ] L0 matchCount=1 → relevanceLevel=PRECISE
- [ ] L0 matchCount=2, L1 similarity=0.8 → relevanceLevel=HIGHLY_RELEVANT
- [ ] L0 matchCount=0, L1 similarity=0.8 → relevanceLevel=HIGHLY_RELEVANT
- [ ] L0 matchCount=3, L1 similarity=0.6 → relevanceLevel=REFERENCE
- [ ] L0 matchCount=0, L1 similarity=0.4 → found=false 或 supplement 被过滤
- [ ] 每个 relevanceLevel 对应正确的 completenessHint
## FS-3: 域级行动记忆
### 需求
RetrievedDocTracker 升级为域级 + 文档级双层记录,支持查询当前会话已检索的域列表。
### 可观察行为
- `markRetrieved(sessionId, domain, filePath)` 一次记录两层
- `isDocRetrieved(sessionId, filePath)` 返回文档级去重结果
- `isDomainRetrieved(sessionId, domain)` 返回域级检查结果
- `getRetrievedDomains(sessionId)` 返回已检索域列表
- `clearSession(sessionId)` 清理所有记录
- 现有 `isAlreadyRetrieved(sessionId, filePath)` 语义不变(内部委托给 isDocRetrieved)
### 验收标准
- [ ] markRetrieved("s1", "infrastructure", "a.md") 后,isDocRetrieved("s1", "a.md")=true
- [ ] markRetrieved("s1", "infrastructure", "a.md") 后,isDomainRetrieved("s1", "infrastructure")=true
- [ ] markRetrieved("s1", "infrastructure", "a.md") 后,getRetrievedDomains("s1")=["infrastructure"]
- [ ] markRetrieved("s1", "api", "b.md") 后,getRetrievedDomains("s1")=["infrastructure","api"]
- [ ] clearSession("s1") 后,所有方法返回空/false
- [ ] 线程安全:ConcurrentHashMap + ConcurrentHashMap 内层
## FS-4: LookupResult 返回值扩展
### 需求
LookupResult 新增 relevanceLevel、completenessHint、retrievedDomainsThisSession 三个字段,让 LLM 获得行动记忆和质量信号。
### 可观察行为
- 每次 lookup_knowledge 返回值包含这三个新字段
- PrimaryResult 和 SupplementResult 不变,不暴露原始分数
- 去重拦截时,返回值仍包含 retrievedDomainsThisSession(让 LLM 知道已检索了哪些域)
### 验收标准
- [ ] 正常检索返回时,LookupResult 包含 relevanceLevel + completenessHint + retrievedDomainsThisSession
- [ ] 文档级去重拦截时,LookupResult.message 包含去重提示,retrievedDomainsThisSession 不为 null
- [ ] PrimaryResult 和 SupplementResult 无新增分数字段
## FS-5: Executor Prompt 检索约束
### 需求
重写 chat-executor-prompt.md 的检索规则,从"必须调用工具"改为"基于行动记忆和质量信号判断是否需要检索"。
### 可观察行为
- Prompt 不包含 knowledge map
- Prompt 包含 4 条检索约束(判断重复、重复了该怎么办、合法出口、利用质量信号)
- 原有规则"所有需要外部信息的地方,都必须调用对应的工具"被替换
### 验收标准
- [ ] Executor prompt 不包含 knowledge map 内容
- [ ] Executor prompt 包含"禁止换关键词重新检索"约束
- [ ] Executor prompt 包含"不查全不会被追责"合法出口
- [ ] Executor prompt 包含 relevanceLevel 行为指导
## FS-6: 入库可观测性
### 需求
tool_invocation 表新增 relevance_level 和 dedup_reason 列,retrieval_details JSON 扩展。
### 可观察行为
- 每次 lookup_knowledge 调用后,tool_invocation 记录包含 relevance_level 和 dedup_reason
- retrieval_details JSON 包含 l1_top_similarity(归一化后值)、relevance_level、completeness_hint、retrieved_domains、dedup_reason
- 历史数据新列为 null,不影响现有查询
### 验收标准
- [ ] V010 迁移脚本成功执行
- [ ] 新增 relevance_level 列 VARCHAR(20) nullable
- [ ] 新增 dedup_reason 列 VARCHAR(32) nullable
- [ ] saveToolInvocation() 写入新字段
- [ ] SQL 可查询归一化等级分布:`SELECT relevance_level, COUNT(*) FROM tool_invocation WHERE tool_name='lookup_knowledge' GROUP BY relevance_level`
@@ -0,0 +1,89 @@
# Tasks: executor-action-memory-relevance
## T1: RetrievedDocTracker 域级升级
**文件**: `src/main/java/com/superbiz/agent/tool/RetrievedDocTracker.java`
**改动**:
- 数据结构从 `ConcurrentHashMap<sessionId, Set<filePath>>` 升级为 `ConcurrentHashMap<sessionId, Map<domain, Set<filePath>>>`
- 新增 `markRetrieved(sessionId, domain, filePath)`
- 新增 `isDocRetrieved(sessionId, filePath)` — 从内层 Map 的 values 中查找 filePath
- 新增 `isDomainRetrieved(sessionId, domain)` — 检查 domain key 存在
- 新增 `getRetrievedDomains(sessionId)` → `List<String>`
- `isAlreadyRetrieved(sessionId, filePath)` 保留(委托给 isDocRetrieved,向后兼容)
- `clearSession(sessionId)` 清理外层 key
**验收**: FS-3 所有验收标准通过
## T2: LookupResult 新增字段
**文件**: `src/main/java/com/superbiz/agent/dto/LookupResult.java`
**改动**:
- 新增 `String relevanceLevel`
- 新增 `String completenessHint`
- 新增 `List<String> retrievedDomainsThisSession`
**验收**: 编译通过,字段存在且类型正确
## T3: 归一化计算逻辑
**文件**: `src/main/java/com/superbiz/agent/tool/LookupKnowledgeTool.java`
**改动**:
- 新增配置类或字段读取 `retrieval.normalization.max-l2-distance`(默认 2.0)、`highly-relevant-threshold`(默认 0.75)、`reference-threshold`(默认 0.5)
- 新增私有方法 `computeRelevance(int l0MatchCount, float l1TopScore)` → 返回包含 `relevanceLevel` + `completenessHint` 的 record/内部类
- L2 距离归一化:`similarity = 1 - min(l1TopScore, maxL2Distance) / maxL2Distance`
- 判定逻辑按 design.md 中的优先级实现
**验收**: FS-1 + FS-2 所有验收标准通过
## T4: LookupKnowledgeTool 集成归一化 + 行动记忆
**文件**: `src/main/java/com/superbiz/agent/tool/LookupKnowledgeTool.java`
**改动**:
- `lookupKnowledge()` 方法中,在 Step 4(组装结果)后、Step 5(去重过滤)前,调用 `computeRelevance()` 计算 relevanceLevel 和 completenessHint
- 从 l0Matches 提取 domain(`l0Matches.get(0).getCategory()`),L1 结果尝试从 metadata JSON 解析 category(兜底)
- markRetrieved 调用从 `markRetrieved(sessionId, docKey)` 改为 `markRetrieved(sessionId, domain, docKey)`
- 去重拦截时(文档级),LookupResult 也附带 retrievedDomainsThisSession
- LookupResult.builder() 中设置三个新字段
**验收**: FS-4 所有验收标准通过;日志中可看到 relevanceLevel 和 completenessHint 输出
## T5: Executor Prompt 重写
**文件**: `src/main/resources/prompts/chat-executor-prompt.md`
**改动**:
- 将"所有需要外部信息的地方,都必须调用对应的工具"替换为"需要外部信息时调用工具,但须遵守下方的检索约束"
- 新增"## 检索约束"区块,包含 4 条规则(判断重复、重复了该怎么办、合法出口、利用质量信号)
- 不注入 knowledge map
**验收**: FS-5 所有验收标准通过
## T6: 入库可观测性
**文件**:
- `src/main/resources/db/migration/V010__add_relevance_level_to_tool_invocation.sql`
- `src/main/java/com/superbiz/agent/domain/entity/ToolInvocation.java`
- `src/main/java/com/superbiz/agent/tool/LookupKnowledgeTool.java`(saveToolInvocation 方法)
**改动**:
- V010: ALTER TABLE tool_invocation ADD relevance_level VARCHAR(20), ADD dedup_reason VARCHAR(32)
- ToolInvocation 实体新增 `relevanceLevel` 和 `dedupReason` 字段
- saveToolInvocation() 中:
- 设置 `inv.setRelevanceLevel(...)` 和 `inv.setDedupReason(...)`
- retrieval_details JSON 扩展:新增 l1_top_similarity、relevance_level、completeness_hint、retrieved_domains、dedup_reason 字段
- 去重拦截时,dedupReason 设为 "doc_retrieved";域级拦截时设为 "domain_retrieved"
**验收**: FS-6 所有验收标准通过
## T7: BGE-M3 归一化验证测试
**文件**: `src/test/java/com/superbiz/agent/service/FullPipelineSmokeTest.java`
**改动**:
- 已完成:embeddingBgeM3Works() 中新增 L2 范数断言(范数=1.00000002,测试已通过)
**验收**: 测试通过,范数断言 |norm - 1.0| < 0.01
@@ -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 {
}
@@ -87,15 +87,16 @@ public class ChatController {
// 根据问题复杂度自动选择单 Agent 或多 Agent
logger.info("开始 ReactAgent 对话(支持自动工具调用)");
String fullAnswer = chatService.executeChatWithStrategy(chatModel, toolCallbacks,
ChatService.ChatResult result = chatService.executeChatWithStrategy(chatModel, toolCallbacks,
request.getQuestion(), history);
String fullAnswer = result.answer();
// 更新会话历史
session.addMessage(request.getQuestion(), fullAnswer);
logger.info("已更新会话历史 - SessionId: {}, 当前消息对数: {}",
request.getId(), session.getMessagePairCount());
return ResponseEntity.ok(ApiResponse.success(ChatResponse.success(fullAnswer)));
return ResponseEntity.ok(ApiResponse.success(ChatResponse.success(fullAnswer, result.sessionId())));
} catch (Exception e) {
logger.error("对话失败", e);
@@ -537,11 +538,13 @@ public class ChatController {
private boolean success;
private String answer;
private String errorMessage;
private String sessionId;
public static ChatResponse success(String answer) {
public static ChatResponse success(String answer, String sessionId) {
ChatResponse response = new ChatResponse();
response.setSuccess(true);
response.setAnswer(answer);
response.setSessionId(sessionId);
return response;
}
@@ -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);
}
}
@@ -54,6 +54,9 @@ public class DiagnosisSession {
@Column(name = "tool_call_count")
private Integer toolCallCount;
@Column(name = "answer", columnDefinition = "LONGTEXT")
private String answer;
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "self_evaluation", columnDefinition = "JSON")
private String selfEvaluation;
@@ -0,0 +1,51 @@
package com.superbiz.agent.domain.entity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Entity
@Table(name = "knowledge_domain")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class KnowledgeDomain {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "domain_id", unique = true, nullable = false, length = 64)
private String domainId;
@Column(name = "description", length = 256)
private String description;
@Column(name = "when_to_retrieve", columnDefinition = "TEXT")
private String whenToRetrieve;
@Column(name = "document_count", nullable = false)
private int documentCount;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
}
@@ -61,6 +61,12 @@ public class ToolInvocation {
@Column(name = "is_truncated")
private Boolean isTruncated;
@Column(name = "relevance_level", length = 20)
private String relevanceLevel;
@Column(name = "dedup_reason", length = 32)
private String dedupReason;
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "retrieval_details", columnDefinition = "JSON")
private String retrievalDetails;
@@ -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;
}
@@ -59,4 +59,14 @@ public class Frontmatter {
* 最后更新日期(预留字段)
*/
private LocalDate lastUpdated;
/**
* 业务场景标签,供 Planner 决策用(LLM 上传时自动生成)
*/
private List<String> covers;
/**
* 文档级检索时机(LLM 上传时自动生成)
*/
private String whenToRetrieve;
}
@@ -43,4 +43,14 @@ public class KnowledgeEntry {
* 章节锚点(预留字段,MVP 不使用)
*/
private Map<String, String> sections;
/**
* 业务场景标签,供 Planner 决策用
*/
private List<String> covers;
/**
* 文档级检索时机
*/
private String whenToRetrieve;
}
@@ -26,4 +26,24 @@ public class LookupResult {
* 补充结果(L1 语义检索)
*/
private SupplementResult supplement;
/**
* 归一化质量等级:PRECISE / HIGHLY_RELEVANT / REFERENCE
*/
private String relevanceLevel;
/**
* 兜底信号:告诉 LLM 知识库的"天花板"
*/
private String completenessHint;
/**
* 本次会话已检索过的域列表(行动记忆)
*/
private List<String> retrievedDomainsThisSession;
/**
* 系统消息(如去重提示)
*/
private String message;
}
@@ -0,0 +1,13 @@
package com.superbiz.agent.repository;
import com.superbiz.agent.domain.entity.KnowledgeDomain;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface KnowledgeDomainRepository extends JpaRepository<KnowledgeDomain, Long> {
Optional<KnowledgeDomain> findByDomainId(String domainId);
}
@@ -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;
});
}
}
@@ -16,8 +16,10 @@ import com.superbiz.agent.hook.TokenUsageHolder;
import com.superbiz.agent.repository.AgentStepRepository;
import com.superbiz.agent.repository.DiagnosisSessionRepository;
import com.superbiz.agent.tool.LookupKnowledgeTool;
import com.superbiz.agent.tool.RetrievedDocTracker;
import com.superbiz.agent.util.QuestionComplexity;
import com.superbiz.agent.util.SessionContextHolder;
import com.superbiz.agent.service.KnowledgeDomainService;
import jakarta.annotation.PostConstruct;
import org.slf4j.Logger;
@@ -46,6 +48,9 @@ public class ChatService {
private static final Logger logger = LoggerFactory.getLogger(ChatService.class);
/** 封装 answer + 后端生成的 sessionId,用于 feedback 关联 */
public record ChatResult(String answer, String sessionId) {}
@Autowired
private InternalDocsTools internalDocsTools;
@@ -73,6 +78,15 @@ public class ChatService {
@Autowired
private AgentStepRepository agentStepRepository;
@Autowired
private EvaluationService evaluationService;
@Autowired
private RetrievedDocTracker retrievedDocTracker;
@Autowired
private KnowledgeDomainService knowledgeDomainService;
/** 多 Agent Chat 的 Prompt */
private String chatPlannerPrompt;
private String chatExecutorPrompt;
@@ -233,9 +247,9 @@ public class ChatService {
* 执行 ReactAgent 对话(非流式)
* @param agent ReactAgent 实例
* @param question 用户问题
* @return AI 回复
* @return ChatResult(answer + sessionId)
*/
public String executeChat(ReactAgent agent, String question) throws GraphRunnerException {
public ChatResult executeChat(ReactAgent agent, String question) throws GraphRunnerException {
logger.info("========================================");
logger.info("📝 用户问题: {}", question);
@@ -267,20 +281,24 @@ public class ChatService {
// 更新诊断会话
session.setStatus("SUCCESS");
session.setAnswer(answer);
session.setTotalDurationMs((int) duration);
backfillSessionMetrics(session);
diagnosisSessionRepository.save(session);
evaluationService.evaluate(sessionId, answer);
logger.info("⏱️ 总耗时: {} ms", duration);
logger.info("📏 输出长度: {} 字符", answer.length());
logger.info("========================================");
return answer;
return new ChatResult(answer, sessionId);
} catch (Exception e) {
session.setStatus("FAILED");
diagnosisSessionRepository.save(session);
throw e;
} finally {
retrievedDocTracker.clearSession(sessionId);
SessionContextHolder.clear();
}
}
@@ -291,9 +309,9 @@ public class ChatService {
* @param toolCallbacks 工具回调
* @param question 用户问题
* @param history 历史消息
* @return AI 回复
* @return ChatResult(answer + sessionId)
*/
public String executeChatWithStrategy(ChatModel chatModel, ToolCallback[] toolCallbacks,
public ChatResult executeChatWithStrategy(ChatModel chatModel, ToolCallback[] toolCallbacks,
String question, List<Map<String, String>> history) throws GraphRunnerException {
if (QuestionComplexity.isComplex(question)) {
logger.info("📊 问题判定为复杂,使用多 Agent(Planner + Executor)执行");
@@ -309,7 +327,7 @@ public class ChatService {
/**
* 多 Agent 复杂对话执行(Planner + Executor + Supervisor)
*/
public String executeChatComplex(ChatModel chatModel, ToolCallback[] toolCallbacks,
public ChatResult executeChatComplex(ChatModel chatModel, ToolCallback[] toolCallbacks,
String question, List<Map<String, String>> history) throws GraphRunnerException {
String sessionId = UUID.randomUUID().toString().substring(0, 8);
long startTime = System.currentTimeMillis();
@@ -356,21 +374,25 @@ public class ChatService {
}
session.setStatus("SUCCESS");
session.setAnswer(answer);
session.setTotalDurationMs((int) duration);
backfillSessionMetrics(session);
diagnosisSessionRepository.save(session);
evaluationService.evaluate(sessionId, answer);
logger.info("⏱️ 多 Agent 总耗时: {} ms", duration);
logger.info("📏 输出长度: {} 字符", answer.length());
return answer;
return new ChatResult(answer, sessionId);
} catch (Exception e) {
session.setStatus("FAILED");
diagnosisSessionRepository.save(session);
logger.error("多 Agent 执行失败", e);
return "执行失败: " + e.getMessage();
return new ChatResult("执行失败: " + e.getMessage(), sessionId);
} finally {
retrievedDocTracker.clearSession(sessionId);
SessionContextHolder.clear();
}
}
@@ -378,6 +400,13 @@ public class ChatService {
private ReactAgent buildChatPlannerAgent(ChatModel chatModel, ToolCallback[] toolCallbacks,
List<Map<String, String>> history) {
StringBuilder prompt = new StringBuilder(chatPlannerPrompt);
// 注入 knowledge map
String knowledgeMap = knowledgeDomainService.buildKnowledgeMap();
if (!knowledgeMap.isBlank()) {
prompt.append("\n\n## 可用知识库\n\n").append(knowledgeMap);
}
if (!history.isEmpty()) {
prompt.append("\n\n--- 对话历史 ---\n");
for (Map<String, String> msg : history) {
@@ -390,7 +419,6 @@ public class ChatService {
.description("负责拆解问题、规划步骤")
.model(chatModel)
.systemPrompt(prompt.toString())
// Planner 不注入工具,只能规划不能执行
.hooks(new AgentLoggingHook(agentStepRepository, "planner"))
.outputKey("planner_plan")
.build();
@@ -0,0 +1,135 @@
package com.superbiz.agent.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.superbiz.agent.dto.Frontmatter;
import com.superbiz.agent.dto.KnowledgeEntry;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Service;
import jakarta.annotation.PostConstruct;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
/**
* 文档字段补全服务
* 上传时调用 LLM 生成 covers 和 whenToRetrieve
*/
@Slf4j
@Service
public class DocumentFieldEnricher {
@Autowired
private ChatModel chatModel;
@Autowired
private ObjectMapper objectMapper;
@Autowired
private KnowledgeIndexService knowledgeIndexService;
private String promptTemplate;
@PostConstruct
public void init() {
try {
promptTemplate = new String(
new ClassPathResource("prompts/doc-field-enricher-prompt.md").getInputStream().readAllBytes(),
StandardCharsets.UTF_8);
log.info("DocumentFieldEnricher prompt 加载成功");
} catch (IOException e) {
log.error("加载 doc-field-enricher-prompt.md 失败", e);
throw new RuntimeException("Failed to load doc-field-enricher prompt", e);
}
}
public void enrich(Frontmatter frontmatter, String bodyText) {
enrich(frontmatter, bodyText, null);
}
/**
* 为 Frontmatter 补全 covers 和 whenToRetrieve
* 若已有值则跳过;LLM 失败时降级,不阻断主流程
*
* @param frontmatter 待补全的 frontmatter
* @param bodyText 文档正文
* @param category 文档所属域(用于查找同域其他文档)
*/
public void enrich(Frontmatter frontmatter, String bodyText, String category) {
if (frontmatter == null) return;
boolean needsCovers = frontmatter.getCovers() == null || frontmatter.getCovers().isEmpty();
boolean needsWhen = frontmatter.getWhenToRetrieve() == null || frontmatter.getWhenToRetrieve().isBlank();
if (!needsCovers && !needsWhen) {
log.debug("covers 和 whenToRetrieve 已存在,跳过 LLM 生成");
return;
}
try {
String snippet = bodyText != null && bodyText.length() > 1000
? bodyText.substring(0, 1000) : (bodyText != null ? bodyText : "");
String sameDomainDocs = buildSameDomainDocs(frontmatter.getTitle(), category);
String promptText = String.format(promptTemplate,
frontmatter.getTitle(),
frontmatter.getSummary(),
sameDomainDocs,
snippet);
String response = chatModel.call(new Prompt(promptText))
.getResult().getOutput().getText();
// 提取 JSON 部分(防止模型输出多余文本)
String json = extractJson(response);
JsonNode node = objectMapper.readTree(json);
if (needsCovers && node.has("covers")) {
List<String> covers = new ArrayList<>();
node.get("covers").forEach(n -> covers.add(n.asText()));
frontmatter.setCovers(covers);
log.debug("LLM 生成 covers: {}", covers);
}
if (needsWhen && node.has("whenToRetrieve")) {
frontmatter.setWhenToRetrieve(node.get("whenToRetrieve").asText());
log.debug("LLM 生成 whenToRetrieve: {}", frontmatter.getWhenToRetrieve());
}
} catch (Exception e) {
log.warn("LLM 生成文档字段失败,降级处理: title={}", frontmatter.getTitle(), e);
if (needsCovers) frontmatter.setCovers(List.of());
if (needsWhen) frontmatter.setWhenToRetrieve(frontmatter.getSummary());
}
}
private String extractJson(String text) {
if (text == null) return "{}";
int start = text.indexOf('{');
int end = text.lastIndexOf('}');
if (start == -1 || end == -1 || end <= start) return "{}";
return text.substring(start, end + 1);
}
/**
* 构建同域其他文档标题列表(供 LLM 做排除判断)
*/
private String buildSameDomainDocs(String currentTitle, String category) {
if (category == null || category.isBlank()) return "(无同域文档信息)";
List<String> otherTitles = knowledgeIndexService.getAllEntries().stream()
.filter(e -> category.equals(e.getCategory()))
.map(KnowledgeEntry::getTitle)
.filter(t -> t != null && !t.equals(currentTitle))
.collect(Collectors.toList());
if (otherTitles.isEmpty()) return "(无同域其他文档)";
return String.join("、", otherTitles);
}
}
@@ -58,6 +58,12 @@ public class DocumentManagementService {
@Autowired
private KnowledgeIndexService knowledgeIndexService;
@Autowired
private DocumentFieldEnricher documentFieldEnricher;
@Autowired
private KnowledgeDomainService knowledgeDomainService;
@Autowired
private ObjectMapper objectMapper;
@@ -123,6 +129,8 @@ public class DocumentManagementService {
if (frontmatterParser.hasFrontmatter(text)) {
frontmatter = frontmatterParser.parse(text);
if (frontmatter != null) {
// LLM 补全 covers / whenToRetrieve(已有值则跳过)
documentFieldEnricher.enrich(frontmatter, text, category);
log.info("解析到frontmatter: title={}, keywords={}, time={}ms",
frontmatter.getTitle(), frontmatter.getKeywords(), System.currentTimeMillis() - frontmatterStart);
} else {
@@ -196,12 +204,17 @@ public class DocumentManagementService {
.summary(frontmatter.getSummary())
.category(category)
.sections(frontmatter.getSections())
.covers(frontmatter.getCovers())
.whenToRetrieve(frontmatter.getWhenToRetrieve())
.build();
knowledgeIndexService.addToIndex(entry);
log.info("文档已加入L0索引: docId={}, title={}", docId, frontmatter.getTitle());
}
// 触发域级聚合重算
knowledgeDomainService.onDocumentChange(category);
long totalTime = System.currentTimeMillis() - startTime;
log.info("文档上传完成: docId={}, fileName={}, hasFrontmatter={}, totalTime={}ms",
docId, fileName, frontmatter != null, totalTime);
@@ -367,6 +380,31 @@ public class DocumentManagementService {
// 删除元数据
apiDocumentRepository.delete(doc);
log.info("文档已删除,docId: {}", docId);
// 触发域级聚合重算
String category = doc.getFilePath() != null
? resolveCategory(doc.getFilePath()) : null;
if (category != null) {
knowledgeDomainService.onDocumentChange(category);
}
}
/**
* 转换为响应 DTO
*/
/**
* 从 filePath 解析 category(取 knowledge_base/{category}/... 中的 category 段)
*/
private String resolveCategory(String filePath) {
try {
java.nio.file.Path p = java.nio.file.Paths.get(filePath);
// filePath 形如 knowledge_base/payment/xxx.md,取倒数第二段
int nameCount = p.getNameCount();
if (nameCount >= 2) {
return p.getName(nameCount - 2).toString();
}
} catch (Exception ignored) {}
return null;
}
/**
@@ -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();
}
}
@@ -65,6 +65,8 @@ public class FrontmatterParser {
.sections((Map<String, String>) map.get("sections"))
.version((String) map.get("version"))
.author((String) map.get("author"))
.covers((java.util.List<String>) map.get("covers"))
.whenToRetrieve((String) map.get("when_to_retrieve"))
.build();
// 4. 验证必填字段
@@ -0,0 +1,188 @@
package com.superbiz.agent.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.superbiz.agent.domain.entity.KnowledgeDomain;
import com.superbiz.agent.dto.KnowledgeEntry;
import com.superbiz.agent.repository.KnowledgeDomainRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Service;
import jakarta.annotation.PostConstruct;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
/**
* 知识域服务
* 负责域级聚合、LLM 生成域级 when_to_retrieve 以及 knowledge map 构建
*/
@Slf4j
@Service
public class KnowledgeDomainService {
@Autowired
private KnowledgeDomainRepository knowledgeDomainRepository;
@Autowired
private KnowledgeIndexService knowledgeIndexService;
@Autowired
private ChatModel chatModel;
@Autowired
private ObjectMapper objectMapper;
private String domainPromptTemplate;
@PostConstruct
public void init() {
try {
domainPromptTemplate = new String(
new ClassPathResource("prompts/domain-summary-prompt.md").getInputStream().readAllBytes(),
StandardCharsets.UTF_8);
log.info("KnowledgeDomainService prompt 加载成功");
} catch (IOException e) {
log.error("加载 domain-summary-prompt.md 失败", e);
throw new RuntimeException("Failed to load domain-summary prompt", e);
}
}
/**
* 文档变更后重算指定域的 when_to_retrieve
*/
public void onDocumentChange(String category) {
if (category == null || category.isBlank()) return;
List<KnowledgeEntry> entries = knowledgeIndexService.getAllEntries().stream()
.filter(e -> category.equals(e.getCategory()))
.collect(Collectors.toList());
buildDomainSummary(category, entries);
}
/**
* 聚合同域文档,调用 LLM 生成域级摘要,写入 DB
*/
public void buildDomainSummary(String category, List<KnowledgeEntry> entries) {
if (entries.isEmpty()) {
knowledgeDomainRepository.findByDomainId(category).ifPresent(d -> {
d.setDocumentCount(0);
knowledgeDomainRepository.save(d);
});
return;
}
// 构建文档列表描述
StringBuilder docList = new StringBuilder();
for (KnowledgeEntry entry : entries) {
docList.append("- 文档:").append(entry.getTitle()).append("\n");
if (entry.getWhenToRetrieve() != null) {
docList.append(" 适用场景:").append(entry.getWhenToRetrieve()).append("\n");
}
if (entry.getCovers() != null && !entry.getCovers().isEmpty()) {
docList.append(" 覆盖:").append(String.join("、", entry.getCovers())).append("\n");
}
}
String description = entries.stream()
.map(KnowledgeEntry::getSummary)
.filter(s -> s != null && !s.isBlank())
.findFirst().orElse(category);
String whenToRetrieve = null;
try {
String otherDomainsInfo = buildOtherDomainsInfo(category);
String promptText = String.format(domainPromptTemplate, category, docList, otherDomainsInfo);
whenToRetrieve = chatModel.call(new Prompt(promptText))
.getResult().getOutput().getText();
log.info("LLM 生成域级 when_to_retrieve: domain={}, result={}", category, whenToRetrieve);
} catch (Exception e) {
log.warn("LLM 生成域级 when_to_retrieve 失败,保留旧值: domain={}", category, e);
Optional<KnowledgeDomain> existing = knowledgeDomainRepository.findByDomainId(category);
whenToRetrieve = existing.map(KnowledgeDomain::getWhenToRetrieve).orElse("");
}
KnowledgeDomain domain = knowledgeDomainRepository.findByDomainId(category)
.orElse(KnowledgeDomain.builder().domainId(category).build());
domain.setDescription(description.length() > 255 ? description.substring(0, 255) : description);
domain.setWhenToRetrieve(whenToRetrieve);
domain.setDocumentCount(entries.size());
knowledgeDomainRepository.save(domain);
}
/**
* 构建注入 Planner 的 knowledge map YAML 文本
*/
public String buildKnowledgeMap() {
List<KnowledgeDomain> domains = knowledgeDomainRepository.findAll();
if (domains.isEmpty()) return "";
List<KnowledgeEntry> allEntries = knowledgeIndexService.getAllEntries();
Map<String, List<KnowledgeEntry>> byCategory = allEntries.stream()
.filter(e -> e.getCategory() != null)
.collect(Collectors.groupingBy(KnowledgeEntry::getCategory));
StringBuilder yaml = new StringBuilder("available_knowledge_domains:\n");
for (KnowledgeDomain domain : domains) {
yaml.append(" - domain_id: \"").append(domain.getDomainId()).append("\"\n");
if (domain.getDescription() != null) {
yaml.append(" description: \"").append(domain.getDescription()).append("\"\n");
}
if (domain.getWhenToRetrieve() != null && !domain.getWhenToRetrieve().isBlank()) {
yaml.append(" when_to_retrieve: \"")
.append(domain.getWhenToRetrieve().replace("\"", "'")).append("\"\n");
}
yaml.append(" document_count: ").append(domain.getDocumentCount()).append("\n");
List<KnowledgeEntry> domainEntries = byCategory.getOrDefault(domain.getDomainId(), List.of());
if (!domainEntries.isEmpty()) {
yaml.append(" documents:\n");
for (KnowledgeEntry entry : domainEntries) {
yaml.append(" - title: \"").append(entry.getTitle()).append("\"\n");
if (entry.getCovers() != null && !entry.getCovers().isEmpty()) {
yaml.append(" covers: ").append(entry.getCovers()).append("\n");
}
}
}
}
return yaml.toString();
}
/**
* 构建其他域的摘要信息(用于 LLM 域级 prompt 的边界判断)
* 优先使用其他域的 when_to_retrieve(边界信号),而非 description
*/
private String buildOtherDomainsInfo(String currentCategory) {
List<KnowledgeDomain> allDomains = knowledgeDomainRepository.findAll();
StringBuilder sb = new StringBuilder();
for (KnowledgeDomain d : allDomains) {
if (d.getDomainId().equals(currentCategory)) continue;
sb.append("- ").append(d.getDomainId());
if (d.getWhenToRetrieve() != null && !d.getWhenToRetrieve().isBlank()) {
sb.append(":").append(d.getWhenToRetrieve());
} else if (d.getDescription() != null && !d.getDescription().isBlank()) {
sb.append("(").append(d.getDescription()).append(")");
}
sb.append("\n");
}
// 如果 DB 里还没有其他域的记录(首次启动),从 L0 索引补充
if (sb.isEmpty()) {
knowledgeIndexService.getAllEntries().stream()
.map(KnowledgeEntry::getCategory)
.filter(c -> c != null && !c.isBlank() && !c.equals(currentCategory))
.distinct()
.forEach(c -> sb.append("- ").append(c).append("\n"));
}
return sb.isEmpty() ? "(无其他域信息)" : sb.toString();
}
}
@@ -1,12 +1,17 @@
package com.superbiz.agent.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.superbiz.agent.domain.entity.ApiDocument;
import com.superbiz.agent.repository.ApiDocumentRepository;
import com.superbiz.agent.dto.Frontmatter;
import com.superbiz.agent.dto.KnowledgeEntry;
import com.superbiz.agent.repository.ApiDocumentRepository;
import com.superbiz.agent.repository.KnowledgeDomainRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.annotation.Lazy;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Service;
import jakarta.annotation.PostConstruct;
@@ -14,12 +19,9 @@ import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.stream.Collectors;
import java.util.stream.Stream;
/**
* 知识库索引服务
@@ -35,26 +37,28 @@ public class KnowledgeIndexService {
@Autowired
private ApiDocumentRepository apiDocumentRepository;
/**
* 内存索引(线程安全)
*/
@Autowired
private ObjectMapper objectMapper;
@Autowired
private KnowledgeDomainRepository knowledgeDomainRepository;
@Lazy
@Autowired
private KnowledgeDomainService knowledgeDomainService;
private final List<KnowledgeEntry> knowledgeIndex = new CopyOnWriteArrayList<>();
/**
* 启动时从数据库加载索引
*/
@PostConstruct
public void loadIndex() {
log.info("开始从数据库加载知识库索引");
try {
// 从数据库读取所有已索引的文档
List<ApiDocument> documents = apiDocumentRepository.findAll();
int loaded = 0;
for (ApiDocument doc : documents) {
try {
// 从 metadata JSON 中提取信息
KnowledgeEntry entry = parseDocumentToEntry(doc);
if (entry != null) {
knowledgeIndex.add(entry);
@@ -73,28 +77,43 @@ public class KnowledgeIndexService {
}
/**
* 将 ApiDocument 转换为 KnowledgeEntry
* 应用就绪后,检查各域是否有 knowledge_domain 记录,无则触发生成
* 使用 ApplicationReadyEvent 而非 PostConstruct,避免循环依赖
*/
@EventListener(ApplicationReadyEvent.class)
public void onApplicationReady() {
try {
knowledgeIndex.stream()
.map(KnowledgeEntry::getCategory)
.filter(c -> c != null && !c.isBlank())
.distinct()
.forEach(category -> {
if (knowledgeDomainRepository.findByDomainId(category).isEmpty()) {
log.info("域 {} 无 knowledge_domain 记录,触发生成", category);
knowledgeDomainService.onDocumentChange(category);
}
});
} catch (Exception e) {
log.error("域级记录生成失败", e);
}
}
private KnowledgeEntry parseDocumentToEntry(ApiDocument doc) {
if (doc.getMetadata() == null || doc.getMetadata().isEmpty()) {
return null;
}
try {
// 简单的 JSON 解析
String metadata = doc.getMetadata();
String title = extractJsonValue(metadata, "title");
String summary = extractJsonValue(metadata, "summary");
String category = extractJsonValue(metadata, "category");
List<String> keywords = extractJsonArray(metadata, "keywords");
Frontmatter frontmatter = objectMapper.readValue(doc.getMetadata(), Frontmatter.class);
return KnowledgeEntry.builder()
.filePath(doc.getFilePath())
.title(title != null ? title : doc.getApiName())
.keywords(keywords)
.summary(summary)
.category(category)
.title(frontmatter.getTitle() != null ? frontmatter.getTitle() : doc.getApiName())
.keywords(frontmatter.getKeywords())
.summary(frontmatter.getSummary())
.category(frontmatter.getCategory())
.covers(frontmatter.getCovers())
.whenToRetrieve(frontmatter.getWhenToRetrieve())
.build();
} catch (Exception e) {
@@ -103,54 +122,6 @@ public class KnowledgeIndexService {
}
}
/**
* 从 JSON 字符串中提取值
*/
private String extractJsonValue(String json, String key) {
String pattern = "\"" + key + "\":\"";
int startIndex = json.indexOf(pattern);
if (startIndex == -1) {
return null;
}
startIndex += pattern.length();
int endIndex = json.indexOf("\"", startIndex);
if (endIndex == -1) {
return null;
}
return json.substring(startIndex, endIndex);
}
/**
* 从 JSON 字符串中提取数组
*/
private List<String> extractJsonArray(String json, String key) {
String pattern = "\"" + key + "\":[";
int startIndex = json.indexOf(pattern);
if (startIndex == -1) {
return Collections.emptyList();
}
startIndex += pattern.length();
int endIndex = json.indexOf("]", startIndex);
if (endIndex == -1) {
return Collections.emptyList();
}
String arrayContent = json.substring(startIndex, endIndex);
return Arrays.stream(arrayContent.split(","))
.map(s -> s.trim().replaceAll("^\"|\"$", ""))
.filter(s -> !s.isEmpty())
.collect(Collectors.toList());
}
/**
* L0 精确匹配
*
* @param query 查询关键词
* @return 匹配的文档列表
*/
public List<KnowledgeEntry> exactMatch(String query) {
long startTime = System.currentTimeMillis();
@@ -172,13 +143,6 @@ public class KnowledgeIndexService {
return results;
}
/**
* 关键词匹配逻辑(不区分大小写)
*
* @param entry 索引条目
* @param query 查询关键词(小写)
* @return true 如果匹配
*/
private boolean matchesKeywords(KnowledgeEntry entry, String query) {
if (entry.getKeywords() == null || entry.getKeywords().isEmpty()) {
return false;
@@ -186,7 +150,6 @@ public class KnowledgeIndexService {
for (String keyword : entry.getKeywords()) {
String keywordLower = keyword.toLowerCase();
// query 包含 keyword 或 keyword 包含 query
if (query.contains(keywordLower) || keywordLower.contains(query)) {
return true;
}
@@ -195,16 +158,8 @@ public class KnowledgeIndexService {
return false;
}
/**
* 读取文档内容
*
* @param filePath 文件相对路径(如 api/payment-errors.md)
* @param maxChars 最大字符数
* @return 文档内容(前 maxChars 字符),失败返回 null
*/
public String readDocument(String filePath, int maxChars) {
try {
// 拼接完整路径:knowledge_base + 相对路径
Path fullPath = Paths.get(knowledgeBasePath, filePath);
String content = Files.readString(fullPath);
@@ -220,32 +175,24 @@ public class KnowledgeIndexService {
}
}
/**
* 添加文档到索引(上传时调用)
*
* @param entry 知识库条目
*/
public void addToIndex(KnowledgeEntry entry) {
knowledgeIndex.add(entry);
log.debug("文档已添加到 L0 索引: title={}", entry.getTitle());
}
/**
* 从索引中移除文档(删除时调用)
*
* @param filePath 文件路径
*/
public void removeFromIndex(String filePath) {
knowledgeIndex.removeIf(e -> e.getFilePath().equals(filePath));
log.debug("文档已从 L0 索引移除: {}", filePath);
}
/**
* 获取索引大小
*
* @return 索引中的文档数量
*/
public int getIndexSize() {
return knowledgeIndex.size();
}
/**
* 获取所有索引条目(供域聚合使用)
*/
public List<KnowledgeEntry> getAllEntries() {
return List.copyOf(knowledgeIndex);
}
}
@@ -1,5 +1,6 @@
package com.superbiz.agent.tool;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.superbiz.agent.domain.entity.ToolInvocation;
import com.superbiz.agent.dto.*;
import com.superbiz.agent.repository.ToolInvocationRepository;
@@ -9,6 +10,7 @@ import com.superbiz.agent.util.SessionContextHolder;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.util.List;
@@ -17,11 +19,29 @@ import java.util.stream.Collectors;
/**
* 知识库查询工具
* 提供给 Agent 的混合检索工具(L0 + L1)
* 内置归一化层:将 L0 匹配数 + L1 L2 距离归一化为统一质量等级
*/
@Slf4j
@Component
public class LookupKnowledgeTool {
private static final String LEVEL_PRECISE = "PRECISE";
private static final String LEVEL_HIGHLY_RELEVANT = "HIGHLY_RELEVANT";
private static final String LEVEL_REFERENCE = "REFERENCE";
private static final String HINT_PRECISE = "知识库中不存在比上述结果更精准的文档";
private static final String HINT_HIGHLY_RELEVANT = "当前结果已高度相关,继续检索不太可能找到更精准的文档";
private static final String HINT_REFERENCE = "当前结果为相关参考,如需更精准信息请明确缺少的具体维度";
@Value("${retrieval.normalization.max-l2-distance:2.0}")
private double maxL2Distance;
@Value("${retrieval.normalization.highly-relevant-threshold:0.75}")
private double highlyRelevantThreshold;
@Value("${retrieval.normalization.reference-threshold:0.5}")
private double referenceThreshold;
@Autowired
private KnowledgeIndexService knowledgeIndexService;
@@ -31,6 +51,12 @@ public class LookupKnowledgeTool {
@Autowired
private ToolInvocationRepository toolInvocationRepository;
@Autowired
private RetrievedDocTracker retrievedDocTracker;
@Autowired
private ObjectMapper objectMapper;
/**
* 查询知识库文档
*
@@ -47,7 +73,6 @@ public class LookupKnowledgeTool {
"4) 配置说明 - 查询系统配置、中间件参数,例如 'HikariCP'、'Redis 集群配置'。" +
"参数 query: 查询关键词或描述")
public LookupResult lookupKnowledge(String query) {
// 生成请求ID用于追踪
String requestId = java.util.UUID.randomUUID().toString().substring(0, 8);
long startTime = System.currentTimeMillis();
@@ -66,7 +91,7 @@ public class LookupKnowledgeTool {
log.info("[L0 精确匹配] 找到文档:");
for (int i = 0; i < Math.min(3, l0Matches.size()); i++) {
KnowledgeEntry entry = l0Matches.get(i);
log.info(" - [{}] 标题: {}, 路径: {}", i+1, entry.getTitle(), entry.getFilePath());
log.info(" - [{}] 标题: {}, 路径: {}, 域: {}", i+1, entry.getTitle(), entry.getFilePath(), entry.getCategory());
}
}
@@ -88,73 +113,201 @@ public class LookupKnowledgeTool {
log.info("[L1 语义检索] 找到文档:");
for (int i = 0; i < Math.min(3, l1Results.size()); i++) {
VectorSearchService.SearchResult result = l1Results.get(i);
log.info(" - [{}] 文档ID: {}, 相似度得分: {}", i+1, result.getId(), result.getScore());
log.info(" - [{}] 文档ID: {}, L2距离: {}", i+1, result.getId(), String.format("%.4f", result.getScore()));
}
}
} else {
log.info("[L1 语义检索] L0唯一匹配,跳过L1检索");
}
// Step 4: 组装结果
LookupResult result = buildResult(l0Matches, l1Results, highConfidence);
// Step 4: 归一化质量等级判定
float l1TopScore = (l1Results != null && !l1Results.isEmpty()) ? l1Results.get(0).getScore() : Float.MAX_VALUE;
RelevanceAssessment assessment = computeRelevance(l0Matches.size(), l1TopScore);
log.info("[归一化] relevanceLevel={}, completenessHint={}", assessment.level, assessment.hint);
if (l1TopScore != Float.MAX_VALUE) {
double similarity = normalizeL2(l1TopScore);
log.info("[归一化] L2距离={}, similarity={}", String.format("%.4f", l1TopScore), String.format("%.4f", similarity));
}
// 记录结构化结果摘要(替代原始 MD 内容预览)
// Step 5: 组装结果
LookupResult result = buildResult(l0Matches, l1Results, highConfidence);
result.setRelevanceLevel(assessment.level);
result.setCompletenessHint(assessment.hint);
// Step 6: session 级去重过滤 + 域级行动记忆
String sessionId = SessionContextHolder.getSessionId();
String domain = extractDomain(l0Matches, l1Results);
if (sessionId != null && result.isFound()) {
String docKey = extractDocKey(result);
if (docKey != null && retrievedDocTracker.isAlreadyRetrieved(sessionId, docKey)) {
log.info("[去重] 文档已在本会话中检索过,跳过: {}", docKey);
List<String> retrievedDomains = retrievedDocTracker.getRetrievedDomains(sessionId);
saveToolInvocation(query, l0Matches, l1Results, highConfidence, startTime, result, domain, "doc_retrieved");
return LookupResult.builder()
.found(false)
.message("文档已在本会话中检索过,无需重复召回: " + docKey)
.relevanceLevel(assessment.level)
.completenessHint(assessment.hint)
.retrievedDomainsThisSession(retrievedDomains)
.build();
}
if (docKey != null) {
retrievedDocTracker.markRetrieved(sessionId, domain, docKey);
}
}
// 附加行动记忆
if (sessionId != null) {
result.setRetrievedDomainsThisSession(retrievedDocTracker.getRetrievedDomains(sessionId));
}
// 记录结构化结果摘要
long totalTime = System.currentTimeMillis() - startTime;
log.info("----------------------------------------");
log.info("<<< [工具返回] lookup_knowledge");
log.info("<<< 结果: found={}, 耗时: {}ms (L0={}ms, L1={}ms)",
result.isFound(), totalTime, l0Time,
l1Results != null ? System.currentTimeMillis() - startTime - l0Time : 0);
log.info("<<< 结果: found={}, relevanceLevel={}, 耗时: {}ms",
result.isFound(), result.getRelevanceLevel(), totalTime);
log.info("<<< 行动记忆: retrievedDomainsThisSession={}", result.getRetrievedDomainsThisSession());
// L0 精确匹配摘要
if (!l0Matches.isEmpty()) {
KnowledgeEntry top = l0Matches.get(0);
log.info("<<< [L0 主结果] 标题: {}", top.getTitle());
log.info("<<< [L0 主结果] 来源: {}", top.getFilePath());
log.info("<<< [L0 主结果] 域: {}", top.getCategory());
if (top.getSummary() != null) {
log.info("<<< [L0 主结果] 摘要: {}", top.getSummary());
}
if (top.getKeywords() != null && !top.getKeywords().isEmpty()) {
log.info("<<< [L0 主结果] 关键词: {}", String.join(", ", top.getKeywords()));
}
// 内容概况:长度 + 章节数
String content = result.getPrimary() != null ? result.getPrimary().getContent() : null;
if (content != null) {
int headingCount = countMdHeadings(content);
log.info("<<< [L0 主结果] 内容: {} 字符, {} 个章节",
content.length(), headingCount);
log.info("<<< [L0 主结果] 内容: {} 字符, {} 个章节", content.length(), headingCount);
}
}
// L1 语义检索摘要
if (l1Results != null && !l1Results.isEmpty()) {
VectorSearchService.SearchResult topL1 = l1Results.get(0);
log.info("<<< [L1 补充] 来源: {}", topL1.getMetadata() != null ? topL1.getMetadata() : topL1.getId());
log.info("<<< [L1 补充] 相似度: {}", String.format("%.4f", topL1.getScore()));
if (topL1.getContent() != null) {
String snippet = extractFirstMeaningfulLine(topL1.getContent(), 120);
log.info("<<< [L1 补充] 内容片段: {}", snippet);
log.info("<<< [L1 补充] 片段长度: {} 字符", topL1.getContent().length());
}
log.info("<<< [L1 补充] L2距离: {}, similarity: {}",
String.format("%.4f", topL1.getScore()),
String.format("%.4f", normalizeL2(topL1.getScore())));
}
log.info("========================================");
// 记录 tool_invocation(持久化检索明细)
saveToolInvocation(query, l0Matches, l1Results, highConfidence, startTime, result);
// 记录 tool_invocation
saveToolInvocation(query, l0Matches, l1Results, highConfidence, startTime, result, domain, null);
return result;
}
// ==================== 归一化层 ====================
/**
* L2 距离 Min-Max 归一化到 [0,1] similarity
* BGE-M3 输出 L2 归一化单位向量,L2 距离硬上界 = 2.0
* similarity = 1 - min(score, maxL2Distance) / maxL2Distance
* score=0 → 1.0(完全相同),score=2.0 → 0.0(完全相反)
*/
double normalizeL2(float l2Score) {
double clamped = Math.min(l2Score, maxL2Distance);
return 1.0 - clamped / maxL2Distance;
}
/**
* 归一化质量等级判定
*
* @param l0MatchCount L0 匹配数
* @param l1TopScore L1 最高分(L2 距离),无 L1 结果时传 Float.MAX_VALUE
* @return RelevanceAssessment(level + hint)
*/
RelevanceAssessment computeRelevance(int l0MatchCount, float l1TopScore) {
double l1Similarity = (l1TopScore != Float.MAX_VALUE) ? normalizeL2(l1TopScore) : 0.0;
// L0 唯一匹配 → PRECISE
if (l0MatchCount == 1) {
return new RelevanceAssessment(LEVEL_PRECISE, HINT_PRECISE);
}
// L0 命中 + L1 高分 → HIGHLY_RELEVANT
if (l0MatchCount > 1 && l1Similarity >= highlyRelevantThreshold) {
return new RelevanceAssessment(LEVEL_HIGHLY_RELEVANT, HINT_HIGHLY_RELEVANT);
}
// 仅 L1 高分 → HIGHLY_RELEVANT
if (l0MatchCount == 0 && l1Similarity >= highlyRelevantThreshold) {
return new RelevanceAssessment(LEVEL_HIGHLY_RELEVANT, HINT_HIGHLY_RELEVANT);
}
// L0 多匹配 + L1 中分 → REFERENCE
if (l0MatchCount > 1 && l1Similarity >= referenceThreshold) {
return new RelevanceAssessment(LEVEL_REFERENCE, HINT_REFERENCE);
}
// 仅 L1 中分 → REFERENCE
if (l0MatchCount == 0 && l1Similarity >= referenceThreshold) {
return new RelevanceAssessment(LEVEL_REFERENCE, HINT_REFERENCE);
}
// L0 多匹配 + 无 L1 / L1 低分 → REFERENCE(L0 命中本身有价值)
if (l0MatchCount > 1) {
return new RelevanceAssessment(LEVEL_REFERENCE, HINT_REFERENCE);
}
// 无有效结果
return new RelevanceAssessment(null, null);
}
/**
* 归一化评估结果
*/
record RelevanceAssessment(String level, String hint) {}
// ==================== 域提取 ====================
/**
* 从检索结果中提取域信息
* 优先使用 L0 的 category,兜底从 L1 metadata 解析
*/
private String extractDomain(List<KnowledgeEntry> l0Matches, List<VectorSearchService.SearchResult> l1Results) {
// 优先 L0
if (l0Matches != null && !l0Matches.isEmpty()) {
String category = l0Matches.get(0).getCategory();
if (category != null && !category.isBlank()) {
return category;
}
}
// 兜底 L1:从 metadata JSON 中解析 category
if (l1Results != null && !l1Results.isEmpty()) {
try {
String metadata = l1Results.get(0).getMetadata();
if (metadata != null && metadata.contains("category")) {
var node = objectMapper.readTree(metadata);
if (node.has("category")) {
return node.get("category").asText();
}
}
} catch (Exception e) {
log.debug("L1 metadata 解析 category 失败: {}", e.getMessage());
}
}
return null;
}
// ==================== 入库 ====================
/**
* 保存工具调用明细到 tool_invocation 表
*/
private void saveToolInvocation(String query, List<KnowledgeEntry> l0Matches,
List<VectorSearchService.SearchResult> l1Results,
boolean highConfidence, long startTime, LookupResult result) {
boolean highConfidence, long startTime,
LookupResult result, String domain, String dedupReason) {
try {
String sessionId = SessionContextHolder.getSessionId();
if (sessionId == null) return; // 非会话上下文不记录
if (sessionId == null) return;
boolean hasL0 = l0Matches != null && !l0Matches.isEmpty();
boolean hasL1 = l1Results != null && !l1Results.isEmpty();
@@ -181,7 +334,7 @@ public class LookupKnowledgeTool {
layer = null;
}
// 拼接 output_preview(前500字符)
// output_preview
if (result != null && result.getPrimary() != null && result.getPrimary().getContent() != null) {
String content = result.getPrimary().getContent();
outputLength = content.length();
@@ -202,24 +355,48 @@ public class LookupKnowledgeTool {
}
}
// 构建检索明细 JSON
// L1 top score + similarity
float l1TopScore = (hasL1) ? l1Results.get(0).getScore() : -1;
double l1TopSimilarity = (hasL1) ? normalizeL2(l1TopScore) : -1;
// 构建检索明细 JSON(扩展版)
StringBuilder details = new StringBuilder("{");
if (hasL0) {
details.append("\"l0_match_count\":").append(l0Count).append(",");
details.append("\"l0_titles\":[");
for (int i = 0; i < Math.min(3, l0Matches.size()); i++) {
if (i > 0) details.append(",");
details.append("\"").append(escapeJson(l0Matches.get(i).getTitle())).append("\"");
}
details.append("]");
details.append("],");
}
if (hasL1) {
if (hasL0) details.append(",");
details.append("\"l1_top_score\":").append(String.format("%.4f", l1TopScore)).append(",");
details.append("\"l1_top_similarity\":").append(String.format("%.4f", l1TopSimilarity)).append(",");
details.append("\"l1_match_count\":").append(l1Count).append(",");
details.append("\"l1_scores\":[");
for (int i = 0; i < Math.min(3, l1Results.size()); i++) {
if (i > 0) details.append(",");
details.append(l1Results.get(i).getScore());
details.append(String.format("%.4f", l1Results.get(i).getScore()));
}
details.append("]");
details.append("],");
}
// 归一化信息
if (result != null && result.getRelevanceLevel() != null) {
details.append("\"relevance_level\":\"").append(result.getRelevanceLevel()).append("\",");
details.append("\"completeness_hint\":\"").append(escapeJson(result.getCompletenessHint())).append("\",");
}
// 域信息
if (domain != null) {
details.append("\"retrieved_domains\":[\"").append(escapeJson(domain)).append("\"],");
}
// 去重原因
if (dedupReason != null) {
details.append("\"dedup_reason\":\"").append(dedupReason).append("\",");
}
// 移除末尾逗号
if (details.charAt(details.length() - 1) == ',') {
details.setLength(details.length() - 1);
}
details.append("}");
@@ -234,12 +411,15 @@ public class LookupKnowledgeTool {
.l1MatchCount(hasL1 ? l1Count : null)
.isTruncated(truncated)
.retrievalDetails(details.toString())
.relevanceLevel(result != null ? result.getRelevanceLevel() : null)
.dedupReason(dedupReason)
.durationMs((int) duration)
.success(true)
.build();
toolInvocationRepository.save(inv);
log.debug("tool_invocation 已保存: sessionId={}, layer={}, duration={}ms", sessionId, layer, duration);
log.debug("tool_invocation 已保存: sessionId={}, layer={}, relevanceLevel={}, duration={}ms",
sessionId, layer, result != null ? result.getRelevanceLevel() : null, duration);
} catch (Exception e) {
log.error("保存 tool_invocation 失败", e);
}
@@ -254,14 +434,8 @@ public class LookupKnowledgeTool {
.replace("\t", "\\t");
}
/**
* 组装查询结果
*
* @param l0Matches L0 匹配结果
* @param l1Results L1 检索结果
* @param highConfidence 是否高置信度
* @return 组装后的结果
*/
// ==================== 结果组装 ====================
private LookupResult buildResult(
List<KnowledgeEntry> l0Matches,
List<VectorSearchService.SearchResult> l1Results,
@@ -274,8 +448,6 @@ public class LookupKnowledgeTool {
if (l0Matches != null && !l0Matches.isEmpty()) {
KnowledgeEntry first = l0Matches.get(0);
boolean hasL1 = l1Results != null && !l1Results.isEmpty();
// 场景决策:唯一匹配或 L1 无结果 → LLM 需要正文内容;多匹配且有 L1 → 只需元数据
boolean needFullContent = highConfidence || !hasL1;
String content = needFullContent
? buildCompactSummary(first)
@@ -287,7 +459,7 @@ public class LookupKnowledgeTool {
.source(first.getFilePath())
.matchType("exact_L0")
.confidence(highConfidence ? "high" : "low")
.availableSections(null) // MVP 返回 null
.availableSections(null)
.build();
log.debug("L0结果已构建: source={}, contentLength={}", first.getFilePath(), content.length());
} else {
@@ -310,16 +482,12 @@ public class LookupKnowledgeTool {
}
builder.supplement(supplement);
// 判断是否找到结果(primary 或 supplement 至少有一个)
boolean found = (primary != null) || (supplement != null);
builder.found(found);
return builder.build();
}
/**
* 统计 MD 文档中的章节数(二级标题 ## 数量)
*/
private int countMdHeadings(String content) {
if (content == null) return 0;
return (int) content.lines()
@@ -327,15 +495,10 @@ public class LookupKnowledgeTool {
.count();
}
/**
* 构建紧凑文档摘要(替代原始 MD 全文,节省上下文窗口)
* 组合:title/summary + 章节结构 + 正文片段(~500 字符)
*/
private String buildCompactSummary(KnowledgeEntry entry) {
String rawContent = knowledgeIndexService.readDocument(entry.getFilePath(), 2000);
if (rawContent == null) return null;
// 跳过 YAML frontmatter 得到正文
String body = rawContent;
if (body.startsWith("---")) {
int end = body.indexOf("---", 3);
@@ -345,14 +508,11 @@ public class LookupKnowledgeTool {
}
StringBuilder sb = new StringBuilder();
// 1. 元数据头(始终包含)
sb.append("文档: ").append(entry.getTitle()).append("\n");
if (entry.getSummary() != null) {
sb.append("摘要: ").append(entry.getSummary()).append("\n");
}
// 2. 章节结构(## 标题列表)
String headings = body.lines()
.filter(l -> l.trim().startsWith("##"))
.map(l -> " - " + l.trim().replaceAll("^#+\\s*", ""))
@@ -362,13 +522,11 @@ public class LookupKnowledgeTool {
}
sb.append("---\n");
// 3. 正文片段(去标题行、去空行,智能截断)
String textContent = body.lines()
.filter(l -> !l.trim().startsWith("#") && !l.trim().isEmpty())
.collect(Collectors.joining("\n"))
.trim();
// 短文档保留更多内容,长文档节省上下文
int maxBodyChars = body.length() < 500 ? 800 : 500;
if (textContent.length() > maxBodyChars) {
sb.append(textContent, 0, maxBodyChars).append("...");
@@ -379,10 +537,6 @@ public class LookupKnowledgeTool {
return sb.toString();
}
/**
* 构建纯元数据摘要(不读文件,仅用内存索引信息)
* 多匹配且有 L1 补充时使用,L0 只需告知 LLM 命中了哪些文档
*/
private String buildMetadataOnlySummary(KnowledgeEntry entry) {
StringBuilder sb = new StringBuilder();
sb.append("文档: ").append(entry.getTitle()).append("\n");
@@ -396,14 +550,10 @@ public class LookupKnowledgeTool {
return sb.toString();
}
/**
* 提取 MD 内容中第一个有意义的文本行(跳过 frontmatter 和标题行)
*/
private String extractFirstMeaningfulLine(String content, int maxLen) {
if (content == null || content.isBlank()) return "(空)";
String text = content.trim();
// 跳过 YAML frontmatter (--- ... ---)
if (text.startsWith("---")) {
int end = text.indexOf("---", 3);
if (end != -1) {
@@ -411,7 +561,6 @@ public class LookupKnowledgeTool {
}
}
// 查找第一个非空、非标题行
String[] lines = text.split("\n");
for (String line : lines) {
String tl = line.trim();
@@ -420,7 +569,6 @@ public class LookupKnowledgeTool {
}
}
// 兜底:第一行非空行
for (String line : lines) {
if (!line.trim().isEmpty()) {
String tl = line.trim();
@@ -430,4 +578,14 @@ public class LookupKnowledgeTool {
return "(无有效内容)";
}
private String extractDocKey(LookupResult result) {
if (result.getPrimary() != null && result.getPrimary().getSource() != null) {
return result.getPrimary().getSource();
}
if (result.getSupplement() != null && result.getSupplement().getSource() != null) {
return result.getSupplement().getSource();
}
return null;
}
}
@@ -0,0 +1,89 @@
package com.superbiz.agent.tool;
import org.springframework.stereotype.Component;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
/**
* session 级已召回文档追踪器
* 支持文档级去重 + 域级行动记忆
*
* 数据结构:sessionId → { domain → Set<filePath> }
* - 域级:控制"不要重复查同域",提供行动记忆给 LLM
* - 文档级:控制"不要重复召回同文档"(替代原有单层结构)
*/
@Component
public class RetrievedDocTracker {
// key: sessionId, value: { domain → Set<filePath> }
private final ConcurrentHashMap<String, Map<String, Set<String>>> sessionRetrievals = new ConcurrentHashMap<>();
/**
* 记录一次检索(域级 + 文档级)
*/
public void markRetrieved(String sessionId, String domain, String filePath) {
if (sessionId == null || filePath == null) return;
sessionRetrievals.computeIfAbsent(sessionId,
k -> new ConcurrentHashMap<>())
.computeIfAbsent(domain != null ? domain : "_unknown",
d -> Collections.newSetFromMap(new ConcurrentHashMap<>()))
.add(filePath);
}
/**
* 文档级去重:检查 filePath 是否已在本会话中检索过
*/
public boolean isDocRetrieved(String sessionId, String filePath) {
if (sessionId == null || filePath == null) return false;
Map<String, Set<String>> domains = sessionRetrievals.get(sessionId);
if (domains == null) return false;
return domains.values().stream().anyMatch(docs -> docs.contains(filePath));
}
/**
* 域级检查:检查 domain 是否已在本会话中检索过
*/
public boolean isDomainRetrieved(String sessionId, String domain) {
if (sessionId == null || domain == null) return false;
Map<String, Set<String>> domains = sessionRetrievals.get(sessionId);
return domains != null && domains.containsKey(domain);
}
/**
* 获取本次会话已检索的域列表(行动记忆,返回给 LLM)
*/
public List<String> getRetrievedDomains(String sessionId) {
if (sessionId == null) return List.of();
Map<String, Set<String>> domains = sessionRetrievals.get(sessionId);
if (domains == null) return List.of();
return List.copyOf(domains.keySet());
}
/**
* 向后兼容:文档级去重(委托给 isDocRetrieved)
*/
public boolean isAlreadyRetrieved(String sessionId, String filePath) {
return isDocRetrieved(sessionId, filePath);
}
/**
* 向后兼容:旧版 markRetrieved(domain 设为 null,归入 _unknown)
*/
public void markRetrieved(String sessionId, String filePath) {
markRetrieved(sessionId, null, filePath);
}
/**
* 清理会话
*/
public void clearSession(String sessionId) {
if (sessionId == null) return;
sessionRetrievals.remove(sessionId);
}
}
+7
View File
@@ -119,6 +119,13 @@ document:
rag:
top-k: 3 # 检索返回的最相似文档数量
# 检索归一化配置
retrieval:
normalization:
max-l2-distance: 2.0 # L2 距离上界(BGE-M3 单位向量 = 2.0)
highly-relevant-threshold: 0.75 # similarity >= 0.75 → HIGHLY_RELEVANT
reference-threshold: 0.5 # similarity >= 0.5 → REFERENCE
# Prometheus 配置
prometheus:
base-url: http://localhost:9090
@@ -0,0 +1 @@
ALTER TABLE diagnosis_session ADD COLUMN answer LONGTEXT COMMENT 'Agent 返回给用户的完整答案';
@@ -0,0 +1,9 @@
CREATE TABLE knowledge_domain (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
domain_id VARCHAR(64) NOT NULL UNIQUE COMMENT 'category 值,如 payment/infrastructure',
description VARCHAR(256) COMMENT '域描述,聚合自文档 summary',
when_to_retrieve TEXT COMMENT '域级检索时机,LLM 聚合生成',
document_count INT NOT NULL DEFAULT 0 COMMENT '该域当前文档数',
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='知识域元数据,存储域级检索策略';
@@ -0,0 +1,6 @@
-- V010: 新增 relevance_level 和 dedup_reason 列到 tool_invocation 表
-- 用于检索归一化等级和去重原因的可观测性
ALTER TABLE tool_invocation
ADD COLUMN relevance_level VARCHAR(20) COMMENT '归一化质量等级:PRECISE/HIGHLY_RELEVANT/REFERENCE/DEDUPED',
ADD COLUMN dedup_reason VARCHAR(32) COMMENT '去重原因:doc_retrieved/domain_retrieved/null';
@@ -2,11 +2,38 @@
## 职责
- 按步骤执行具体的查询任务
- 使用知识库查询、日志查询等工具获取信息
- 将执行结果汇总,给出完整的最终答案
- 需要外部信息时调用工具,但须遵守下方的检索约束
- 不要凭记忆回答,必须基于工具返回的真实数据
- 执行完成后,综合所有结果给出完整的答案
## 规则
- 按顺序执行,不可跳过步骤
- 所有需要外部信息的地方,都必须调用对应的工具
- 不要凭记忆回答,必须基于工具返回的真实数据
- 执行完成后,综合所有结果给出完整的答案
## 检索约束
### 1. 判断重复:基于已检索上下文
每次 lookup_knowledge 返回值中包含 `retrievedDomainsThisSession`,
表示本次会话已检索过的知识域。如果当前问题与已检索域语义重叠,
**禁止再次调用 lookup_knowledge**。
### 2. 重复了该怎么办
如果当前想检索的内容与【已检索上下文】语义相似:
- 禁止换关键词重新检索
- 直接基于已有事实回答
- 如果信息不足,先明确指出缺少什么具体维度
(如:"缺少 HikariCP 具体配置参数"、"缺少连接池耗尽的日志样例"),
再针对该维度进行一次定向补充检索——而非盲目换词重查
### 3. 合法出口:允许信息不全时给出结论
如果你认为已有信息足以回答核心问题,即使细节不全,
也请直接给出结论并说明局限性(如:"基于已有信息,连接池配置建议如下,
但具体参数值需结合实际负载调整")。
**不查全不会被追责,重复检索才会被惩罚。**
### 4. 利用质量信号判断
- relevanceLevel=PRECISE → 信息精准,直接使用,不再检索
- relevanceLevel=HIGHLY_RELEVANT + 域已在 retrievedDomainsThisSession → 禁止再次调用
- relevanceLevel=REFERENCE → 先指出缺什么维度,再定向补充一次
- completenessHint 是知识库给你的天花板信号,信任它
@@ -18,3 +18,9 @@
- 每个步骤应该是一个可以独立执行的任务
- 步骤要具体可操作,不要模糊
- 如果问题需要查知识库,明确在步骤中说明要查什么
## 知识库检索规则
- 制定步骤前,先查看下方 `available_knowledge_domains`(如果存在)
- 根据每个域的 `when_to_retrieve` 判断是否需要检索该域
- 每个域最多安排一次检索步骤;已覆盖的域不要重复安排
- 如果用户问题与某个域无关,不要安排对该域的检索
@@ -0,0 +1,31 @@
你是知识库文档标注助手。为文档生成两个检索辅助字段。
## 生成规则
1. covers:该文档覆盖的业务场景(3-5 个简洁中文短语)
2. whenToRetrieve:用一句话描述何时检索此文档,不超过 40 字
- 必须包含正向场景(什么问题查本文档)
- 必须包含反向排除(什么问题容易误判但不应查本文档)
- 格式:"正向场景;不包含反向排除词"
## Few-shot 示例
文档标题:支付失败排查手册
同域其他文档:退款处理指南
→ {"covers": ["支付超时", "扣款无回调", "支付网关报错"], "whenToRetrieve": "支付失败/超时/无回调时检索;不含退款对账问题"}
文档标题:退款处理指南
同域其他文档:支付失败排查手册
→ {"covers": ["退款未到账", "退款状态异常", "退款被拒"], "whenToRetrieve": "退款异常/未到账时检索;不含支付失败问题"}
文档标题:MySQL 连接池配置
同域其他文档:Redis 缓存配置指南、Flyway 数据库迁移
→ {"covers": ["连接池耗尽", "数据库OOM", "慢查询"], "whenToRetrieve": "连接池/数据库性能问题时检索;不含Redis缓存或迁移问题"}
## 待分析文档
文档标题:%s
文档摘要:%s
同域其他文档:%s
文档内容节选:
%s
请严格返回 JSON 格式,不要有其他内容:
{"covers": ["场景1", "场景2", ...], "whenToRetrieve": "描述"}
@@ -0,0 +1,21 @@
你是知识库域级路由设计助手。根据域内文档和系统其他域信息,生成一条域级检索指引。
## 当前域:%s
## 域内文档
%s
## 系统其他域(用于判断边界)
%s
## 生成要求
1. 用一句话描述"什么场景下 Planner 应该选择检索这个域"
2. 必须覆盖该域所有文档的适用场景,做合理抽象(不要简单拼接)
3. 必须包含明确的边界判断:
- 正向:什么问题属于这个域
- 反向:什么问题容易被误判为属于这个域,但实际应该检索其他域
4. 反向边界必须引用其他域的 domain_id,格式:"X类问题查{domain_id}"
5. 不超过 60 字
6. 语言要让 Planner 能做"检索/不检索"的二分判断
只返回纯文本指引,不要 JSON 或 markdown。
+63
View File
@@ -634,6 +634,10 @@ class SuperBizAgentApp {
const chatResponse = data.data;
if (chatResponse && chatResponse.success) {
// 保存后端返回的 sessionId,用于 feedback 提交
if (chatResponse.sessionId) {
this.lastSessionId = chatResponse.sessionId;
}
// 成功:添加实际响应消息(即使 answer 为空也显示)
const answer = chatResponse.answer || '(无回复内容)';
this.addMessage('assistant', answer);
@@ -848,6 +852,12 @@ class SuperBizAgentApp {
}
messageContentWrapper.appendChild(messageContent);
// assistant 消息末尾加反馈栏(流式消息完成后由 handleStreamComplete 添加)
if (type === 'assistant' && !isStreaming) {
messageContentWrapper.appendChild(this.createFeedbackBar(this.lastSessionId));
}
messageDiv.appendChild(messageContentWrapper);
if (this.chatMessages) {
@@ -866,6 +876,54 @@ class SuperBizAgentApp {
return messageDiv;
}
// 创建反馈栏,sessionId 闭包绑定,避免多轮对话时错位
createFeedbackBar(sessionId) {
const bar = document.createElement('div');
bar.className = 'feedback-bar';
const usefulBtn = document.createElement('button');
usefulBtn.className = 'feedback-btn';
usefulBtn.title = '有用';
usefulBtn.innerHTML = `<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3H14z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
const notUsefulBtn = document.createElement('button');
notUsefulBtn.className = 'feedback-btn';
notUsefulBtn.title = '无用';
notUsefulBtn.innerHTML = `<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M10 15v4a3 3 0 0 0 3 3l4-9V2H5.72a2 2 0 0 0-2 1.7l-1.38 9a2 2 0 0 0 2 2.3H10z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M17 2h2.67A2.31 2.31 0 0 1 22 4v7a2.31 2.31 0 0 1-2.33 2H17" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
usefulBtn.addEventListener('click', () => this.submitFeedback('useful', bar, sessionId));
notUsefulBtn.addEventListener('click', () => this.submitFeedback('not_useful', bar, sessionId));
bar.appendChild(usefulBtn);
bar.appendChild(notUsefulBtn);
return bar;
}
// 提交反馈
async submitFeedback(feedback, barElement, sessionId) {
if (!sessionId) return;
barElement.querySelectorAll('.feedback-btn').forEach(btn => btn.disabled = true);
try {
const response = await fetch(`${this.apiBaseUrl}/feedback`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId, feedback })
});
const data = await response.json();
if (data.success) {
barElement.innerHTML = `<span class="feedback-done">${feedback === 'useful' ? '已标记为有用' : '已标记为无用'}</span>`;
} else {
barElement.querySelectorAll('.feedback-btn').forEach(btn => btn.disabled = false);
this.showNotification('反馈提交失败', 'error');
}
} catch (e) {
barElement.querySelectorAll('.feedback-btn').forEach(btn => btn.disabled = false);
this.showNotification('反馈提交失败: ' + e.message, 'error');
}
}
// 添加带加载动画的消息
addLoadingMessage(content) {
const messageDiv = document.createElement('div');
@@ -946,12 +1004,17 @@ class SuperBizAgentApp {
handleStreamComplete(assistantMessageElement, fullResponse) {
if (assistantMessageElement) {
assistantMessageElement.classList.remove('streaming');
const messageContentWrapper = assistantMessageElement.querySelector('.message-content-wrapper');
const messageContent = assistantMessageElement.querySelector('.message-content');
if (messageContent) {
messageContent.innerHTML = this.renderMarkdown(fullResponse);
// 高亮代码块
this.highlightCodeBlocks(messageContent);
}
// 流式完成后追加反馈栏
if (messageContentWrapper && !messageContentWrapper.querySelector('.feedback-bar')) {
messageContentWrapper.appendChild(this.createFeedbackBar(this.lastSessionId));
}
}
// 保存流式消息到历史记录
if (fullResponse) {
+49
View File
@@ -1163,3 +1163,52 @@ body {
right: 0;
}
}
/* 反馈栏 */
.feedback-bar {
display: flex;
align-items: center;
gap: 6px;
margin-top: 8px;
padding-top: 6px;
}
.feedback-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: 1px solid #e8eaed;
border-radius: 6px;
background: transparent;
color: #9aa0a6;
cursor: pointer;
transition: border-color 0.2s, color 0.2s, background 0.2s;
padding: 0;
flex-shrink: 0;
}
.feedback-btn svg {
width: 14px;
height: 14px;
}
.feedback-btn:hover {
border-color: #1a73e8;
color: #1a73e8;
background: #e8f0fe;
}
.feedback-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
pointer-events: none;
}
.feedback-done {
font-size: 0.75rem;
color: #34a853;
font-weight: 500;
}
@@ -86,8 +86,15 @@ class FullPipelineSmokeTest {
boolean hasNonZero = vector.stream().anyMatch(v -> Math.abs(v) > 1e-6);
assertTrue(hasNonZero, "向量不能全为零");
// L2 范数校验:BGE-M3 输出应为 L2 归一化的单位向量
double norm = Math.sqrt(vector.stream().mapToDouble(v -> (double) v * v).sum());
System.out.println("维度: " + vector.size());
System.out.println("前5维: " + vector.subList(0, Math.min(5, vector.size())));
System.out.println("L2 范数: " + String.format("%.10f", norm));
System.out.println("是否归一化 (|norm - 1.0| < 0.01): " + (Math.abs(norm - 1.0) < 0.01));
assertEquals(1.0, norm, 0.01, "BGE-M3 向量应为 L2 归一化单位向量,实际范数=" + norm);
System.out.println("Embedding ✓");
}