feat(session): 会话存储体系实现 & Chat多Agent路由

- 新增诊断会话(diagnosis_session/agent_step/tool_invocation)三表
- AgentLoggingHook 持久化 agent_step,记录决策链和耗时
- LookupKnowledgeTool 写入 tool_invocation,记录L0/L1检索质量
- TokenTrackingChatModel 捕获真实token用量
- Chat接口支持意图路由:简单问题单Agent,复杂问题多Agent(Planner+Executor)
- Prompt外置到 src/main/resources/prompts/
- 删除旧 diagnosis_record 表及相关文件
- 新增SessionContextHolder(ThreadLocal传递sessionId)
- QuestionComplexity 复杂度判断工具
- 测试覆盖三张新表的Repository
This commit is contained in:
zhuyongxin
2026-06-26 16:22:05 +08:00
parent a74ccea5be
commit 0d9cce75f9
33 changed files with 1941 additions and 470 deletions
@@ -0,0 +1,88 @@
# 会话存储 — 决策记录
## Question Pool
### 术语维度
| # | 问题 | 类型 | 状态 |
|---|------|------|:----:|
| Q1 | AgentLoggingHook 如何获得 Repository 访问能力? | evidence-driven | ✅ 已查证 |
| Q2 | AiOpsService 当前是否使用了 AgentLoggingHook? | evidence-driven | ✅ 已查证 |
### 边界维度
| # | 问题 | 类型 | 状态 |
|---|------|------|:----:|
| Q3 | Hook 中写 DB 是否同步?要不要一步到位做异步? | user-interview | ✅ 已确认 |
| Q4 | AiOpsService 的 Supervisor 步骤是否单独记录? | user-interview | ✅ 已确认 |
### 验收维度
| # | 问题 | 类型 | 状态 |
|---|------|------|:----:|
| Q5 | tool_invocation 的 output_preview 截断多长合适? | 默认 | 500 字符 |
### 技术实现维度
| # | 问题 | 类型 | 状态 |
|---|------|------|:----:|
| Q6 | LookupKnowledgeTool 如何获取当前 sessionId 和 stepId? | **待解决** | ⚠️ 未确认 |
## Evidence-Driven 查证
### E1: AgentLoggingHook 创建方式
**证据**:ChatService 第 180 行 `.hooks(new AgentLoggingHook())` — 直接 new 创建,非 Spring 管理。
**结论**:Hook 不是 Spring Bean,无法注入 Repository。AiOpsService 的 Planner/Executor 也没有加 Hook。
**影响**:需要改造为 @Component + 构造注入,并在 AiOpsService 中补齐。
### E2: 项目异步基础设施
**证据**:全局搜索 `@Async`、`@EnableAsync`、`CompletableFuture`、`TaskExecutor` — 均无匹配。
**结论**:项目没有异步执行基础设施。
**影响**:MVP 阶段 Hook 内同步写 DB,后续再优化。
## User-Interview 确认
### U1: Hook 改造方式
**问题**:AgentLoggingHook 怎样获得 Repository 访问能力?
**选项**:
1. 改造为 Spring Bean(@Component + 构造注入)
2. 保持 POJO,从外部传 Repository
**用户答复**:改为 Hook(Spring Bean)
**确认状态**:✅ 已确认
### U2: AiOpsService 记录粒度
**问题**:Supervisor 内部的步骤记录范围?
**选项**:
1. 只记子 Agent(Planner/Executor)步骤
2. 全量记录(含 Supervisor)
**用户答复**:接受建议,只记子 Agent
**确认状态**:✅ 已确认
## 开放问题
### O1: LookupKnowledgeTool 获取 sessionId
LookupKnowledgeTool 是 `@Component`,通过 Spring AI 的 `@Tool` 注解暴露给 Agent。它不直接参与 Agent Hook 调用链,**无法直接从 RunnableConfig 读取 sessionId**。
可能的方案:
1. **ThreadLocal** — ChatService/AiOpsService 在执行前设置当前 sessionId 到 ThreadLocal,工具中读取。简单,但需注意清理。
2. **从 agent_step 反查** — 工具调用后根据时间戳和 session 关联查找最近的 step。不准确。
3. **RequestContextHolder** — 利用 Spring 的请求上下文。仅限 Web 请求上下文有效。
**建议方案**:ThreadLocal。在 ChatService/AiOpsService 执行入口设置,AgentLoggingHook 和 LookupKnowledgeTool 都从 ThreadLocal 读取。
**用户确认**:✅ 同意 ThreadLocal 方案
@@ -0,0 +1,93 @@
# 会话存储体系 — 设计文档
## 架构概览
```
用户请求
│
▼
ChatService.executeChat() / AiOpsService.executeAiOpsAnalysis()
│ ┌── 创建 diagnosis_session (status=RUNNING)
│
▼
Agent Loop(带 AgentLoggingHook)
│
├── beforeModel() → 创建 agent_step(记录 model_input 摘要)
├── afterModel() → 更新 agent_step(记录 model_output、token_count、工具调用决策)
│
├── 工具执行(如 lookup_knowledge)
│ └── 写入 tool_invocation(L0/L1 明细、耗时、是否截断)
│
└── 循环直到模型不再调用工具
│
▼
更新 diagnosis_session (status=SUCCESS/FAILED,汇总指标)
```
## 表结构
### diagnosis_session
| 字段 | 类型 | 说明 |
|------|------|------|
| id | BIGINT PK AUTO_INC | 自增主键 |
| session_id | VARCHAR(64) UNIQUE | 会话唯一 ID |
| query | TEXT | 用户原始问题 |
| status | VARCHAR(16) DEFAULT 'PENDING' | PENDING/RUNNING/SUCCESS/FAILED |
| agent_flow | VARCHAR(32) | CHAT / AI_OPS |
| total_duration_ms | INT | 总耗时 |
| total_token_count | INT | 总 Token 消耗 |
| step_count | INT | Agent 步数 |
| tool_call_count | INT | 工具调用次数 |
| self_evaluation | JSON | 自评估信号 |
| feedback | VARCHAR(16) | 用户反馈 |
| created_at | DATETIME | 创建时间 |
| updated_at | DATETIME | 更新时间 |
### agent_step
| 字段 | 类型 | 说明 |
|------|------|------|
| id | BIGINT PK AUTO_INC | 自增主键 |
| session_id | VARCHAR(64) | 关联 diagnosis_session |
| step_index | INT | 当前 Agent 的第几步 |
| agent_name | VARCHAR(32) | intelligent_assistant / planner / executor |
| model_input | JSON | 模型输入摘要 [{role, content_truncated}] |
| model_output | JSON | 模型输出摘要 {text, tool_calls} |
| thought | TEXT | Agent 思考过程文本 |
| has_tool_call | BOOLEAN | 本轮是否调用了工具 |
| duration_ms | INT | 本轮耗时 |
| token_count | INT | 本轮 Token 消耗 |
| created_at | DATETIME | 创建时间 |
### tool_invocation
| 字段 | 类型 | 说明 |
|------|------|------|
| id | BIGINT PK AUTO_INC | 自增主键 |
| session_id | VARCHAR(64) | 关联 diagnosis_session |
| step_id | BIGINT | 关联 agent_step.id(可为空) |
| tool_name | VARCHAR(64) | lookup_knowledge / 等 |
| input_params | JSON | 工具入参 |
| output_preview | TEXT | 输出前 500 字符 |
| output_length | INT | 输出总字符数 |
| retrieval_layer | VARCHAR(8) | L0 / L1 / L0+L1 |
| l0_match_count | INT | L0 匹配数 |
| l1_match_count | INT | L1 匹配数 |
| is_truncated | BOOLEAN | 内容是否被截断 |
| retrieval_details | JSON | L0 标题列表、L1 分数等 |
| duration_ms | INT | 工具执行耗时 |
| success | BOOLEAN | 是否成功 |
| error_message | TEXT | 失败原因 |
| created_at | DATETIME | 创建时间 |
## 关键设计决策
| 决策 | 选择 | 理由 |
|------|------|------|
| Hook 创建方式 | Spring Bean (@Component) | 需要注入 Repository |
| DB 写入时机 | 同步(Hook 内部直接写入) | MVP 阶段简化,后续可异步化 |
| session_id 向 Hook 传递 | 通过 RunnableConfig 的 metadata 携带 | Spring AI Alibaba Agent Framework 原生支持 |
| session_id 向 Tool 传递 | ThreadLocal(SessionContextHolder 工具类) | Tool 不在 Hook 调用链中,无法获取 RunnableConfig |
| tool_invocation 关联 agent_step | 通过 step_id 外键(不加约束) | 允许 tool_invocation 独立于 agent_step 写入 |
| AiOps 多 Agent 记录 | 每个子 Agent 独立 Hook 实例 | 各自维护 step_index 计数器 |
@@ -0,0 +1,70 @@
# 会话存储体系
## 问题
当前 `diagnosis_record` 单表无法支撑通用会话存储需求:
1. 字段语义耦合在"告警分析"领域(fault_category、error_code 等),ChatService 通用问答场景无法使用
2. 缺少 Agent 决策链维度(两个 Agent 的多轮思考过程无法区分和追溯)
3. 检索质量不可评估(L0/L1 命中层、截断信息、召回内容长度无记录)
4. 指标不完整(缺 token 用量、自评信号、采纳率)
## 建议方案
将单表拆分为三表体系,用 `session_id` 关联:
```
diagnosis_session (1)
└── agent_step (0:N) —— 每次 Agent 决策
└── tool_invocation (0:N) —— 每步中的工具调用
```
### 三表职责
| 表 | 职责 | 示例查询 |
|---|---|---|
| diagnosis_session | 诊断级元数据 + 汇总指标 | "某次诊断的总耗时和 Token 消耗" |
| agent_step | 决策链:每步 Agent 的输入输出摘要 | "Planner 的思考过程和工具调用决策" |
| tool_invocation | 工具调用明细 + 检索质量 | "lookup_knowledge 的 L0/L1 命中分布" |
### 集成点
1. `AgentLoggingHook` → 写入 `agent_step`
2. `LookupKnowledgeTool` → 写入 `tool_invocation`
3. `ChatService` / `AiOpsService` → 创建/更新 `diagnosis_session`
## 范围
- 新建 3 张表(Flyway 迁移)
- 新建 3 个 JPA Entity + 3 个 Repository
- 改造 AgentLoggingHook、LookupKnowledgeTool、ChatService、AiOpsService
- 现有 `diagnosis_record` 表保持不动
## 非目标
- 不涉及 UI 层面的会话展示
- 不涉及历史数据迁移
- 不涉及 diagnosis_record 的合并或废弃
## 上下文约束
- Flyway 迁移脚本命名:V005__create_diagnosis_session.sql 起
- JPA ddl-auto 使用 validate 模式
- JSON 列使用 `@JdbcTypeCode(SqlTypes.JSON)`(同现有 diagnosis_record 的 tool_calls 字段)
- 已有 SessionManager/Redis 会话机制不变,新表作为持久化补充
## 已确认的设计决策
| 决策 | 结论 | 来源 |
|------|------|------|
| AgentLoggingHook 创建方式 | 改造为 Spring Bean(@Component + 构造注入) | grill user-interview |
| AiOpsService 钩子范围 | Planner 和 Executor 各加 AgentLoggingHook | grill user-interview |
| Supervisor 步骤记录 | 不单独记录,由子 Agent 步骤覆盖 | grill user-interview |
| tool_invocation 截断长度 | 500 字符 | proposal 默认 |
| sessionId 传递机制 | ThreadLocal(SessionContextHolder) | grill user-interview |
| AiOps 步骤记录 | 只记 Planner/Executor,不记 Supervisor | grill user-interview |
## 风险
- AgentLoggingHook 目前是同步写日志,新增 DB 写可能影响 Agent 响应时间 → 考虑异步写入或先同步后优化
- tool_invocation 的 output_preview 截断长度需合理(建议 500 字符)
@@ -0,0 +1,153 @@
# 会话存储 — 功能规格
## Requirement 1:三张新表的 DDL
**路径**:`src/main/resources/db/migration/V005__create_session_storage.sql`
**内容**:
- 创建 `diagnosis_session` 表(DDL 见 design.md)
- 创建 `agent_step` 表(DDL 见 design.md)
- 创建 `tool_invocation` 表(DDL 见 design.md)
- 三条 DDL 写在同一个迁移文件中
**验收标准**:
- [ ] Flyway migrate 后三张表均存在
- [ ] 表结构字段类型、索引与设计一致
- [ ] JSON 列使用 `JSON` 类型(MySQL 8+)
---
## Requirement 2:JPA Entity + Repository
### 2.1 实体类
**路径**:
- `src/main/java/com/superbiz/agent/domain/entity/DiagnosisSession.java`
- `src/main/java/com/superbiz/agent/domain/entity/AgentStep.java`
- `src/main/java/com/superbiz/agent/domain/entity/ToolInvocation.java`
**要求**:
- 使用 `@Entity` + `@Table(name = "...")` 映射
- JSON 字段使用 `@JdbcTypeCode(SqlTypes.JSON)`(同现有 `DiagnosisRecord.toolCalls`)
- `@PrePersist` 自动填充 `createdAt`
- 使用 Lombok `@Data @Builder @NoArgsConstructor @AllArgsConstructor`
### 2.2 Repository 接口
**路径**:
- `src/main/java/com/superbiz/agent/repository/DiagnosisSessionRepository.java`
- `src/main/java/com/superbiz/agent/repository/AgentStepRepository.java`
- `src/main/java/com/superbiz/agent/repository/ToolInvocationRepository.java`
**要求**:
- 继承 `JpaRepository`
- `DiagnosisSessionRepository`:`findBySessionId(String sessionId)`
- `AgentStepRepository`:`findBySessionIdOrderByStepIndex(String sessionId)`、`countBySessionId(String sessionId)`
- `ToolInvocationRepository`:`findBySessionId(String sessionId)`、`findByToolName(String toolName)`
**验收标准**:
- [ ] 3 个 Entity 编译通过
- [ ] 3 个 Repository 编译通过
- [ ] 自定义查询方法命名符合 Spring Data JPA 规范
---
## Requirement 3:AgentLoggingHook 改造为 Spring Bean
**路径**:`src/main/java/com/superbiz/agent/hook/AgentLoggingHook.java`
**变更**:
- 类上加 `@Component` 注解
- 不再通过 new 创建实例
- 构造注入 `AgentStepRepository`
- beforeModel:创建 `AgentStep` 记录,设置 `modelInput`,记录开始时间到 `RunnableConfig`
- afterModel:更新对应 `AgentStep`,设置 `modelOutput`、`thought`、`hasToolCall`、`durationMs`、`tokenCount`
- `modelInput` 和 `modelOutput` 只存摘要(前 500 字符),不存完整消息体
**session_id 传递机制**:
- 调用方(ChatService/AiOpsService)通过 `RunnableConfig.metadata()` 传入 `sessionId`
- Hook 从 `config.getMetadata("sessionId")` 读取
**验收标准**:
- [ ] Hook 可注入 AgentStepRepository
- [ ] beforeModel 创建 agent_step 记录并写入 DB
- [ ] afterModel 更新对应 agent_step 记录
- [ ] model_input/output 摘要不超过 500 字符
- [ ] 从 RunnableConfig 正确读取 sessionId
- [ ] 原日志输出行为保持不变
---
## Requirement 4:ChatService 集成
**路径**:`src/main/java/com/superbiz/agent/service/ChatService.java`
**变更**:
- 注入 `DiagnosisSessionRepository`
- `executeChat()` 中:
- 执行前:创建 `DiagnosisSession`(status=RUNNING),生成 `sessionId`,生成 `agent_flow=CHAT`
- 通过 `RunnableConfig` 将 sessionId 传给 Hook
- 执行后:更新 `DiagnosisSession`(status=SUCCESS/FAILED,汇总 step_count、tool_call_count、total_duration_ms)
- 不再通过 `new AgentLoggingHook()` 创建 Hook,改为注入 Bean 的 Hook
**验收标准**:
- [ ] executeChat 执行前后分别创建和更新 diagnosis_session
- [ ] sessionId 通过 RunnableConfig 正确传递给 Hook
- [ ] 汇总指标(duration、step_count)正确写入
- [ ] 异常路径正确设置 status=FAILED
---
## Requirement 5:AiOpsService 集成
**路径**:`src/main/java/com/superbiz/agent/service/AiOpsService.java`
**变更**:
- 注入 `DiagnosisSessionRepository` 和 `AgentLoggingHook`
- `executeAiOpsAnalysis()` 中:
- 执行前:创建 `DiagnosisSession`(status=RUNNING, agent_flow=AI_OPS)
- 构建 Planner 和 Executor 时传入 `AgentLoggingHook` 实例(使用注入的 Bean)
- 通过 `RunnableConfig` 将 sessionId 传给 Hook
- 执行后:更新 `DiagnosisSession`(汇总指标)
- Supervisor 不加 Hook
**验收标准**:
- [ ] AiOpsService 执行前后分别创建和更新 diagnosis_session
- [ ] Planner 和 Executor 各带 AgentLoggingHook
- [ ] 两个 Hook 使用相同的 sessionId
- [ ] Supervisor 不产生 agent_step 记录
---
## Requirement 6:LookupKnowledgeTool 写入 tool_invocation
**路径**:`src/main/java/com/superbiz/agent/tool/LookupKnowledgeTool.java`
**变更**:
- 注入 `ToolInvocationRepository`
- `lookupKnowledge()` 执行后:
- 创建 `ToolInvocation` 记录
- 写入 `toolName=lookup_knowledge`、`inputParams`(query)、`outputPreview`(前 500 字符)
- 写入检索质量:`retrievalLayer`、`l0MatchCount`、`l1MatchCount`、`isTruncated`、`retrievalDetails`
- 写入 `durationMs`、`success`
- `sessionId` 和 `stepId` 如何获取需要方案设计(见开放问题)
**验收标准**:
- [ ] lookup_knowledge 每次调用后创建 tool_invocation 记录
- [ ] 检索质量字段(L0/L1 明细)正确写入
- [ ] 工具执行失败的场景正确记录
---
## Requirement 7:构造注入适配(无 @Async)
**路径**:所有涉及新增 Repository 注入的类
**要求**:
- 所有新注入使用构造注入(`@RequiredArgsConstructor` 或显式构造器)
- 不在 MV 阶段引入 @Async 异步基础设施
- Hook 中的 DB 写入是同步的,作为已知的技术债记录
**验收标准**:
- [ ] 没有使用 @Autowired 字段注入新 Repository(保持项目已有风格)
- [ ] 没有引入 @Async / @EnableAsync
+151
View File
@@ -0,0 +1,151 @@
# 会话存储 — 任务拆解
## 切片 1:Flyway 迁移脚本
**文件**: `src/main/resources/db/migration/V005__create_session_storage.sql`
**内容**:创建 diagnosis_session、agent_step、tool_invocation 三张表
**验收标准**:
- [x] 三张表均通过 Flyway 创建成功
- [x] 字段类型、索引、JSON 列定义正确
- [x] 回滚脚本可选(不做强制要求)
---
## 切片 2:JPA 实体类
**文件**:
- `src/main/java/com/superbiz/agent/domain/entity/DiagnosisSession.java`
- `src/main/java/com/superbiz/agent/domain/entity/AgentStep.java`
- `src/main/java/com/superbiz/agent/domain/entity/ToolInvocation.java`
**内容**:三个 Entity,使用 @JdbcTypeCode(SqlTypes.JSON) 映射 JSON 列
**验收标准**:
- [x] 编译通过,无 JPA 映射错误
- [x] Entity 字段与 DDL 对齐
- [x] Lombok 注解完整
---
## 切片 3:JPA Repository
**文件**:
- `src/main/java/com/superbiz/agent/repository/DiagnosisSessionRepository.java`
- `src/main/java/com/superbiz/agent/repository/AgentStepRepository.java`
- `src/main/java/com/superbiz/agent/repository/ToolInvocationRepository.java`
**内容**:三个 Repository,含自定义查询方法
**验收标准**:
- [x] 编译通过
- [x] 自定义方法命名正确
- [x] 可在 Spring 中自动注入
---
## 切片 4:SessionContextHolder 工具类
**文件**: `src/main/java/com/superbiz/agent/util/SessionContextHolder.java`
**内容**:基于 ThreadLocal 的 sessionId 传递工具
```java
public class SessionContextHolder {
private static final ThreadLocal<String> SESSION_ID = new ThreadLocal<>();
public static void setSessionId(String sessionId) { SESSION_ID.set(sessionId); }
public static String getSessionId() { return SESSION_ID.get(); }
public static void clear() { SESSION_ID.remove(); }
}
```
**验收标准**:
- [x] 编译通过
- [x] set/get/clear 在同一线程内正常工作
---
## 切片 5:AgentLoggingHook 改造为 Spring Bean
**文件**: `src/main/java/com/superbiz/agent/hook/AgentLoggingHook.java`
**内容**:
- 加 @Component 注解
- 构造注入 AgentStepRepository
- beforeModel 创建 agent_step
- afterModel 更新 agent_step
- 从 RunnableConfig 读取 sessionId
**验收标准**:
- [x] 编译通过
- [x] beforeModel 写入 agent_step 到 DB
- [x] afterModel 更新正确行
- [x] 原日志行为不变
---
## 切片 6:ChatService 集成
**文件**: `src/main/java/com/superbiz/agent/service/ChatService.java`
**内容**:
- 注入 DiagnosisSessionRepository
- executeChat 前后创建/更新 diagnosis_session
- 通过 RunnableConfig 传递 sessionId
**验收标准**:
- [x] 每次 executeChat 产生一条 diagnosis_session 记录
- [x] sessionId 可被 Hook 读取
- [x] status、duration 等汇总指标正确
---
## 切片 7:AiOpsService 集成
**文件**: `src/main/java/com/superbiz/agent/service/AiOpsService.java`
**内容**:
- 注入 DiagnosisSessionRepository 和 AgentLoggingHook
- executeAiOpsAnalysis 前后创建/更新 diagnosis_session
- Planner 和 Executor 各加 AgentLoggingHook
- Supervisor 不加 Hook
**验收标准**:
- [x] 每次 executeAiOpsAnalysis 产生一条 diagnosis_session 记录
- [x] Planner 执行产生 agent_step 记录
- [x] Executor 执行产生 agent_step 记录
- [x] Supervisor 不产生 agent_step 记录
---
## 切片 8:LookupKnowledgeTool 集成
**文件**: `src/main/java/com/superbiz/agent/tool/LookupKnowledgeTool.java`
**内容**:
- 注入 ToolInvocationRepository
- 执行后写入 tool_invocation 记录
- 记录 L0/L1 检索质量
**验收标准**:
- [x] 每次 lookup_knowledge 调用写入一条 tool_invocation
- [x] retrieval_layer / l0_match_count 等字段正确
- [x] 异常场景 success=false
---
## 切片 9:测试
**文件**:
- `src/test/java/com/superbiz/agent/repository/DiagnosisSessionRepositoryTest.java`
- `src/test/java/com/superbiz/agent/repository/AgentStepRepositoryTest.java`
- `src/test/java/com/superbiz/agent/repository/ToolInvocationRepositoryTest.java`
**内容**:
- Repository 单元测试(CRUD + 自定义查询)
- 集成测试需要运行环境(后续补充)
**验收标准**:
- [x] Repository 测试通过
@@ -83,16 +83,12 @@ public class ChatController {
// 记录可用工具 // 记录可用工具
chatService.logAvailableTools(); chatService.logAvailableTools();
ToolCallback[] toolCallbacks = tools != null ? tools.getToolCallbacks() : new ToolCallback[0];
// 根据问题复杂度自动选择单 Agent 或多 Agent
logger.info("开始 ReactAgent 对话(支持自动工具调用)"); logger.info("开始 ReactAgent 对话(支持自动工具调用)");
String fullAnswer = chatService.executeChatWithStrategy(chatModel, toolCallbacks,
// 构建系统提示词(包含历史消息) request.getQuestion(), history);
String systemPrompt = chatService.buildSystemPrompt(history);
// 创建 ReactAgent
ReactAgent agent = chatService.createReactAgent(chatModel, systemPrompt);
// 执行对话
String fullAnswer = chatService.executeChat(agent, request.getQuestion());
// 更新会话历史 // 更新会话历史
session.addMessage(request.getQuestion(), fullAnswer); session.addMessage(request.getQuestion(), fullAnswer);
@@ -0,0 +1,64 @@
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;
/**
* Agent 决策步骤实体
* 对应表: agent_step
*/
@Entity
@Table(name = "agent_step", indexes = {
@Index(name = "idx_session_step", columnList = "session_id, step_index"),
@Index(name = "idx_agent_name", columnList = "agent_name")
})
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AgentStep {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "session_id", nullable = false, length = 64)
private String sessionId;
@Column(name = "step_index", nullable = false)
private Integer stepIndex;
@Column(name = "agent_name", nullable = false, length = 32)
private String agentName;
@Column(name = "model_input", columnDefinition = "TEXT")
private String modelInput;
@Column(name = "model_output", columnDefinition = "TEXT")
private String modelOutput;
@Column(name = "thought", columnDefinition = "TEXT")
private String thought;
@Column(name = "has_tool_call")
private Boolean hasToolCall;
@Column(name = "duration_ms")
private Integer durationMs;
@Column(name = "token_count")
private Integer tokenCount;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
}
}
@@ -1,128 +0,0 @@
package com.superbiz.agent.domain.entity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import com.superbiz.agent.domain.enums.DiagnosisStatus;
import com.superbiz.agent.domain.enums.FaultCategory;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
/**
* 诊断记录实体
* 对应表: diagnosis_record
*/
@Entity
@Table(name = "diagnosis_record", indexes = {
@Index(name = "idx_business_id", columnList = "business_id"),
@Index(name = "idx_trace_id", columnList = "trace_id"),
@Index(name = "idx_session_id", columnList = "session_id"),
@Index(name = "idx_fault_category", columnList = "fault_category"),
@Index(name = "idx_error_code", columnList = "error_code"),
@Index(name = "idx_created_at", columnList = "created_at"),
@Index(name = "idx_status", columnList = "status")
})
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class DiagnosisRecord {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "diagnosis_id", unique = true, nullable = false, length = 64)
private String diagnosisId;
// 关联信息
@Column(name = "session_id", length = 64)
private String sessionId;
@Column(name = "business_id", length = 128)
private String businessId;
@Column(name = "trace_id", length = 64)
private String traceId;
// 故障分类
@Enumerated(EnumType.STRING)
@Column(name = "fault_category", length = 32, columnDefinition = "VARCHAR(32)")
private FaultCategory faultCategory;
@Column(name = "fault_source", length = 128)
private String faultSource;
@Column(name = "fault_target", length = 256)
private String faultTarget;
// 错误信息
@Column(name = "error_code", length = 64)
private String errorCode;
@Column(name = "error_message", columnDefinition = "TEXT")
private String errorMessage;
@Column(name = "stack_trace", columnDefinition = "TEXT")
private String stackTrace;
// 诊断结果
@Column(name = "problem_type", length = 32)
private String problemType;
@Column(name = "root_cause", columnDefinition = "TEXT")
private String rootCause;
@Column(name = "solution", columnDefinition = "TEXT")
private String solution;
@Column(name = "report_markdown", columnDefinition = "TEXT")
private String reportMarkdown;
// 评估指标
@Enumerated(EnumType.STRING)
@Column(name = "status", length = 16, columnDefinition = "VARCHAR(16)")
private DiagnosisStatus status = DiagnosisStatus.PENDING;
@Column(name = "confidence")
private Integer confidence;
@Column(name = "duration")
private Integer duration;
// 用户反馈
@Column(name = "feedback", length = 16)
private String feedback;
// 调试字段 - JSON 类型
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "tool_calls", columnDefinition = "JSON")
private List<Map<String, Object>> toolCalls;
// 元数据
@Column(name = "created_by", length = 64)
private String createdBy;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at")
private LocalDateTime updatedAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
}
@@ -0,0 +1,80 @@
package com.superbiz.agent.domain.entity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import java.time.LocalDateTime;
/**
* 诊断会话实体
* 对应表: diagnosis_session
*/
@Entity
@Table(name = "diagnosis_session", indexes = {
@Index(name = "idx_created_at", columnList = "created_at"),
@Index(name = "idx_status", columnList = "status"),
@Index(name = "idx_agent_flow", columnList = "agent_flow")
})
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class DiagnosisSession {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "session_id", unique = true, nullable = false, length = 64)
private String sessionId;
@Column(name = "query", nullable = false, columnDefinition = "TEXT")
private String query;
@Column(name = "status", length = 16)
private String status = "PENDING";
@Column(name = "agent_flow", length = 32)
private String agentFlow;
@Column(name = "total_duration_ms")
private Integer totalDurationMs;
@Column(name = "total_token_count")
private Integer totalTokenCount;
@Column(name = "step_count")
private Integer stepCount;
@Column(name = "tool_call_count")
private Integer toolCallCount;
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "self_evaluation", columnDefinition = "JSON")
private String selfEvaluation;
@Column(name = "feedback", length = 16)
private String feedback;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at")
private LocalDateTime updatedAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
}
@@ -0,0 +1,84 @@
package com.superbiz.agent.domain.entity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import java.time.LocalDateTime;
/**
* 工具调用明细实体
* 对应表: tool_invocation
*/
@Entity
@Table(name = "tool_invocation", indexes = {
@Index(name = "idx_session_id", columnList = "session_id"),
@Index(name = "idx_tool_name", columnList = "tool_name"),
@Index(name = "idx_retrieval_layer", columnList = "retrieval_layer")
})
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ToolInvocation {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "session_id", nullable = false, length = 64)
private String sessionId;
@Column(name = "step_id")
private Long stepId;
@Column(name = "tool_name", nullable = false, length = 64)
private String toolName;
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "input_params", nullable = false, columnDefinition = "JSON")
private String inputParams;
@Column(name = "output_preview", columnDefinition = "TEXT")
private String outputPreview;
@Column(name = "output_length")
private Integer outputLength;
@Column(name = "retrieval_layer", length = 8)
private String retrievalLayer;
@Column(name = "l0_match_count")
private Integer l0MatchCount;
@Column(name = "l1_match_count")
private Integer l1MatchCount;
@Column(name = "is_truncated")
private Boolean isTruncated;
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "retrieval_details", columnDefinition = "JSON")
private String retrievalDetails;
@Column(name = "duration_ms")
private Integer durationMs;
@Column(name = "success")
private Boolean success;
@Column(name = "error_message", columnDefinition = "TEXT")
private String errorMessage;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
}
}
@@ -1,21 +0,0 @@
package com.superbiz.agent.domain.enums;
/**
* 诊断状态枚举
*/
public enum DiagnosisStatus {
PENDING("待处理"),
RUNNING("诊断中"),
SUCCESS("成功"),
FAILED("失败");
private final String description;
DiagnosisStatus(String description) {
this.description = description;
}
public String getDescription() {
return description;
}
}
@@ -5,23 +5,39 @@ import com.alibaba.cloud.ai.graph.agent.hook.messages.AgentCommand;
import com.alibaba.cloud.ai.graph.agent.hook.HookPosition; import com.alibaba.cloud.ai.graph.agent.hook.HookPosition;
import com.alibaba.cloud.ai.graph.agent.hook.HookPositions; import com.alibaba.cloud.ai.graph.agent.hook.HookPositions;
import com.alibaba.cloud.ai.graph.RunnableConfig; import com.alibaba.cloud.ai.graph.RunnableConfig;
import com.superbiz.agent.domain.entity.AgentStep;
import com.superbiz.agent.repository.AgentStepRepository;
import com.superbiz.agent.util.SessionContextHolder;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.messages.Message; import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.AssistantMessage; import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.UserMessage; import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.messages.ToolResponseMessage; import org.springframework.ai.chat.messages.ToolResponseMessage;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/** /**
* Agent 日志 Hook * Agent 日志 Hook
* 用于记录 Agent 的思考过程、消息流转 * 记录 Agent 的思考过程、消息流转 + 持久化 agent_step 到 DB
*/ */
@Slf4j @Slf4j
@HookPositions({HookPosition.BEFORE_MODEL, HookPosition.AFTER_MODEL}) @HookPositions({HookPosition.BEFORE_MODEL, HookPosition.AFTER_MODEL})
public class AgentLoggingHook extends MessagesModelHook { public class AgentLoggingHook extends MessagesModelHook {
private int modelCallCount = 0; private final AgentStepRepository agentStepRepository;
private final String agentName;
/** 每个 session 的步数计数器:sessionId → stepIndex */
private final ConcurrentHashMap<String, Integer> stepCounters = new ConcurrentHashMap<>();
/** beforeModel → afterModel 中间状态:sessionId_stepIndex → {stepId, startTime} */
private final ConcurrentHashMap<String, Map<String, Object>> pendingSteps = new ConcurrentHashMap<>();
public AgentLoggingHook(AgentStepRepository agentStepRepository, String agentName) {
this.agentStepRepository = agentStepRepository;
this.agentName = agentName;
}
@Override @Override
public String getName() { public String getName() {
@@ -30,9 +46,12 @@ public class AgentLoggingHook extends MessagesModelHook {
@Override @Override
public AgentCommand beforeModel(List<Message> previousMessages, RunnableConfig config) { public AgentCommand beforeModel(List<Message> previousMessages, RunnableConfig config) {
modelCallCount++; String sessionId = SessionContextHolder.getSessionId();
int stepIndex = stepCounters.merge(sessionId, 0, (old, one) -> old + 1);
log.info("========================================"); log.info("========================================");
log.info("*** [Agent 思考] 第 {} 轮思考开始", modelCallCount); log.info("*** [Agent 思考] 第 {} 轮思考开始", stepIndex + 1);
log.info("*** [Agent 思考] 当前消息数量: {}", previousMessages.size()); log.info("*** [Agent 思考] 当前消息数量: {}", previousMessages.size());
// 打印最后几条消息 // 打印最后几条消息
@@ -40,28 +59,51 @@ public class AgentLoggingHook extends MessagesModelHook {
if (lastN > 0) { if (lastN > 0) {
log.info("*** [Agent 思考] 最近 {} 条消息:", lastN); log.info("*** [Agent 思考] 最近 {} 条消息:", lastN);
List<Message> recentMessages = previousMessages.subList(previousMessages.size() - lastN, previousMessages.size()); List<Message> recentMessages = previousMessages.subList(previousMessages.size() - lastN, previousMessages.size());
for (int i = 0; i < recentMessages.size(); i++) { for (int i = 0; i < recentMessages.size(); i++) {
Message msg = recentMessages.get(i); Message msg = recentMessages.get(i);
String role = getMessageRole(msg); String role = getMessageRole(msg);
log.info(" [{}] 角色: {}, 类型: {}", i + 1, role, msg.getClass().getSimpleName()); log.info(" [{}] 角色: {}, 类型: {}", i + 1, role, msg.getClass().getSimpleName());
// Message 接口可能没有直接的 getContent() 方法,跳过内容打印
// 具体内容会在工具调用日志中体现
} }
} }
log.info("*** [Agent 思考] 准备调用模型..."); log.info("*** [Agent 思考] 准备调用模型...");
log.info("========================================"); log.info("========================================");
// 不修改消息,直接返回 // 持久化 agent_step(beforeModel:先创建,先记 model_input 摘要)
if (sessionId != null) {
try {
String modelInputSummary = buildModelInputSummary(previousMessages);
AgentStep step = AgentStep.builder()
.sessionId(sessionId)
.stepIndex(stepIndex)
.agentName(agentName)
.modelInput(modelInputSummary)
.build();
AgentStep saved = agentStepRepository.save(step);
// 记录中间状态供 afterModel 使用
pendingSteps.put(sessionId + "_" + stepIndex, Map.of(
"stepId", saved.getId(),
"startTime", System.currentTimeMillis()
));
log.debug("agent_step 已创建: sessionId={}, stepIndex={}, id={}", sessionId, stepIndex, saved.getId());
} catch (Exception e) {
log.error("保存 agent_step 失败", e);
// 不中断 Agent 执行
}
}
return new AgentCommand(previousMessages); return new AgentCommand(previousMessages);
} }
@Override @Override
public AgentCommand afterModel(List<Message> previousMessages, RunnableConfig config) { public AgentCommand afterModel(List<Message> previousMessages, RunnableConfig config) {
String sessionId = SessionContextHolder.getSessionId();
log.info("========================================"); log.info("========================================");
log.info("*** [Agent 思考] 第 {} 轮思考完成", modelCallCount); log.info("*** [Agent 思考] 第 {} 轮思考完成", stepCounters.getOrDefault(sessionId, 0));
// 查找最后一条 AssistantMessage(模型的回复) // 查找最后一条 AssistantMessage(模型的回复)
AssistantMessage lastAssistant = null; AssistantMessage lastAssistant = null;
@@ -72,7 +114,15 @@ public class AgentLoggingHook extends MessagesModelHook {
} }
} }
boolean hasToolCall = false;
if (lastAssistant != null) { if (lastAssistant != null) {
// 调试:打印 metadata
if (lastAssistant.getMetadata() != null && !lastAssistant.getMetadata().isEmpty()) {
log.info("*** [Agent 思考] 模型返回 metadata: {}", lastAssistant.getMetadata());
} else {
log.info("*** [Agent 思考] 模型返回 metadata: (空)");
}
// 打印模型返回的文本内容 // 打印模型返回的文本内容
String textContent = extractTextContent(lastAssistant); String textContent = extractTextContent(lastAssistant);
if (textContent != null && !textContent.isEmpty()) { if (textContent != null && !textContent.isEmpty()) {
@@ -84,6 +134,7 @@ public class AgentLoggingHook extends MessagesModelHook {
// 检查是否有工具调用 // 检查是否有工具调用
if (lastAssistant.getToolCalls() != null && !lastAssistant.getToolCalls().isEmpty()) { if (lastAssistant.getToolCalls() != null && !lastAssistant.getToolCalls().isEmpty()) {
hasToolCall = true;
log.info("*** [Agent 思考] 模型决定调用 {} 个工具:", log.info("*** [Agent 思考] 模型决定调用 {} 个工具:",
lastAssistant.getToolCalls().size()); lastAssistant.getToolCalls().size());
lastAssistant.getToolCalls().forEach(toolCall -> { lastAssistant.getToolCalls().forEach(toolCall -> {
@@ -100,84 +151,178 @@ public class AgentLoggingHook extends MessagesModelHook {
log.info("========================================"); log.info("========================================");
// 不修改消息,直接返回 // 更新 agent_step(afterModel:补全 model_output、耗时等)
if (sessionId != null) {
int stepIndex = stepCounters.getOrDefault(sessionId, 0);
String stepKey = sessionId + "_" + stepIndex;
Map<String, Object> pending = pendingSteps.remove(stepKey);
if (pending != null) {
try {
Long stepId = (Long) pending.get("stepId");
long startTime = (long) pending.get("startTime");
int durationMs = (int) (System.currentTimeMillis() - startTime);
AgentStep step = agentStepRepository.findById(stepId).orElse(null);
if (step != null) {
String thought = extractTextContent(lastAssistant);
if (thought != null && thought.length() > 2000) {
thought = thought.substring(0, 2000);
}
step.setThought(thought);
step.setHasToolCall(hasToolCall);
step.setDurationMs(durationMs);
if (lastAssistant != null) {
String outputSummary = buildModelOutputSummary(lastAssistant);
step.setModelOutput(outputSummary);
// 读取实际 token 用量(由 TokenTrackingChatModel 写入)
Integer tokenCount = TokenUsageHolder.get();
if (tokenCount != null) {
step.setTokenCount(tokenCount);
}
}
agentStepRepository.save(step);
log.debug("agent_step 已更新: sessionId={}, stepIndex={}, duration={}ms",
sessionId, stepIndex, durationMs);
}
} catch (Exception e) {
log.error("更新 agent_step 失败", e);
}
}
}
// 清理 token 上下文
TokenUsageHolder.clear();
return new AgentCommand(previousMessages); return new AgentCommand(previousMessages);
} }
/**
* 构建模型输入摘要(前 N 条消息的 role + 截断内容)
*/
private String buildModelInputSummary(List<Message> messages) {
StringBuilder sb = new StringBuilder();
int maxMessages = Math.min(messages.size(), 5);
for (int i = messages.size() - maxMessages; i < messages.size(); i++) {
Message msg = messages.get(i);
String role = getMessageRole(msg);
String content = msg.toString();
if (content.length() > 200) {
content = content.substring(0, 200) + "...";
}
sb.append("[").append(role).append("] ").append(content).append("\n");
}
String result = sb.toString();
if (result.length() > 500) {
result = result.substring(0, 500) + "...";
}
return result;
}
/**
* 构建模型输出摘要
*/
private String buildModelOutputSummary(AssistantMessage message) {
String text = extractTextContent(message);
if (text == null) {
text = "";
}
if (text.length() > 500) {
text = text.substring(0, 500) + "...";
}
StringBuilder sb = new StringBuilder();
sb.append("{\"text\":\"").append(escapeJson(text)).append("\"");
if (message.getToolCalls() != null && !message.getToolCalls().isEmpty()) {
sb.append(",\"toolCalls\":[");
for (int i = 0; i < message.getToolCalls().size(); i++) {
if (i > 0) sb.append(",");
sb.append("{\"name\":\"").append(escapeJson(message.getToolCalls().get(i).name()))
.append("\",\"arguments\":").append(message.getToolCalls().get(i).arguments()).append("}");
}
sb.append("]");
}
sb.append("}");
return sb.toString();
}
private String escapeJson(String s) {
if (s == null) return "";
return s.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("\n", "\\n")
.replace("\r", "\\r")
.replace("\t", "\\t");
}
/** /**
* 提取 AssistantMessage 的文本内容 * 提取 AssistantMessage 的文本内容
*/ */
private String extractTextContent(AssistantMessage message) { private String extractTextContent(AssistantMessage message) {
if (message == null) return null;
try { try {
// 方法 1: 尝试通过反射获取 text 字段 // 方法 1: 反射获取 text 字段
try { try {
java.lang.reflect.Field textField = message.getClass().getDeclaredField("text"); java.lang.reflect.Field textField = message.getClass().getDeclaredField("text");
textField.setAccessible(true); textField.setAccessible(true);
Object value = textField.get(message); Object value = textField.get(message);
if (value != null) { if (value != null) {
String text = value.toString();
log.debug("通过 text 字段提取成功"); log.debug("通过 text 字段提取成功");
return text; return value.toString();
} }
} catch (NoSuchFieldException e) { } catch (NoSuchFieldException e) {
// text 字段不存在,尝试下一种方法 // 尝试下一种方法
} }
// 方法 2: 尝试 content 字段 // 方法 2: 反射获取 content 字段
try { try {
java.lang.reflect.Field contentField = message.getClass().getDeclaredField("content"); java.lang.reflect.Field contentField = message.getClass().getDeclaredField("content");
contentField.setAccessible(true); contentField.setAccessible(true);
Object value = contentField.get(message); Object value = contentField.get(message);
if (value != null) { if (value != null) {
String text = value.toString();
log.debug("通过 content 字段提取成功"); log.debug("通过 content 字段提取成功");
return text; return value.toString();
} }
} catch (NoSuchFieldException e) { } catch (NoSuchFieldException e) {
// content 字段不存在,尝试下一种方法 // 尝试下一种方法
} }
// 方法 3: 尝试调用 getText() 方法 // 方法 3: 调用 getText() 方法
try { try {
java.lang.reflect.Method getTextMethod = message.getClass().getMethod("getText"); java.lang.reflect.Method getTextMethod = message.getClass().getMethod("getText");
Object value = getTextMethod.invoke(message); Object value = getTextMethod.invoke(message);
if (value != null) { if (value != null) {
String text = value.toString();
log.debug("通过 getText() 方法提取成功"); log.debug("通过 getText() 方法提取成功");
return text; return value.toString();
} }
} catch (NoSuchMethodException e) { } catch (NoSuchMethodException e) {
// getText() 方法不存在,尝试下一种方法 // 尝试下一种方法
} }
// 方法 4: 尝试调用 getContent() 方法 // 方法 4: 调用 getContent() 方法
try { try {
java.lang.reflect.Method getContentMethod = message.getClass().getMethod("getContent"); java.lang.reflect.Method getContentMethod = message.getClass().getMethod("getContent");
Object value = getContentMethod.invoke(message); Object value = getContentMethod.invoke(message);
if (value != null) { if (value != null) {
String text = value.toString();
log.debug("通过 getContent() 方法提取成功"); log.debug("通过 getContent() 方法提取成功");
return text; return value.toString();
} }
} catch (NoSuchMethodException e) { } catch (NoSuchMethodException e) {
// getContent() 方法不存在 // 方法不存在
} }
// 方法 5: 打印所有字段和方法,帮助调试 // 方法 5: 打印类结构信息
log.warn("无法提取 AssistantMessage 文本内容,打印类信息:"); log.warn("无法提取 AssistantMessage 文本内容,打印类信息:");
log.warn("类名: {}", message.getClass().getName()); log.warn("类名: {}", message.getClass().getName());
log.warn("字段列表:"); log.warn("字段列表:");
for (java.lang.reflect.Field field : message.getClass().getDeclaredFields()) { for (java.lang.reflect.Field field : message.getClass().getDeclaredFields()) {
log.warn(" - {}: {}", field.getName(), field.getType().getSimpleName()); log.warn(" - {}: {}", field.getName(), field.getType().getSimpleName());
} }
log.warn("方法列表:");
for (java.lang.reflect.Method method : message.getClass().getMethods()) {
if (method.getName().startsWith("get") && method.getParameterCount() == 0) {
log.warn(" - {}(): {}", method.getName(), method.getReturnType().getSimpleName());
}
}
// 方法 6: 最后尝试 toString() // 方法 6: toString() 兜底
String toString = message.toString(); String toString = message.toString();
if (toString != null && !toString.startsWith("AssistantMessage@")) { if (toString != null && !toString.startsWith("AssistantMessage@")) {
log.debug("通过 toString() 提取"); log.debug("通过 toString() 提取");
@@ -0,0 +1,45 @@
package com.superbiz.agent.hook;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.prompt.Prompt;
import reactor.core.publisher.Flux;
/**
* ChatModel 包装器 — 捕获每次模型调用的实际 token 用量
* 通过 TokenUsageHolder 传递给 AgentLoggingHook
*/
public class TokenTrackingChatModel implements ChatModel {
private final ChatModel delegate;
public TokenTrackingChatModel(ChatModel delegate) {
this.delegate = delegate;
}
@Override
public ChatResponse call(Prompt prompt) {
ChatResponse response = delegate.call(prompt);
captureTokenUsage(response);
return response;
}
@Override
public Flux<ChatResponse> stream(Prompt prompt) {
return delegate.stream(prompt);
}
private void captureTokenUsage(ChatResponse response) {
try {
if (response.getMetadata() != null && response.getMetadata().getUsage() != null) {
var usage = response.getMetadata().getUsage();
Integer total = usage.getTotalTokens();
if (total != null && total > 0) {
TokenUsageHolder.set(total);
}
}
} catch (Exception e) {
// 不中断模型调用
}
}
}
@@ -0,0 +1,22 @@
package com.superbiz.agent.hook;
/**
* Token 用量持有者(基于 ThreadLocal)
* ChatModel 调用后写入实际 token 数,AgentLoggingHook 读取
*/
public class TokenUsageHolder {
private static final ThreadLocal<Integer> TOKEN_COUNT = new ThreadLocal<>();
public static void set(Integer count) {
TOKEN_COUNT.set(count);
}
public static Integer get() {
return TOKEN_COUNT.get();
}
public static void clear() {
TOKEN_COUNT.remove();
}
}
@@ -0,0 +1,24 @@
package com.superbiz.agent.repository;
import com.superbiz.agent.domain.entity.AgentStep;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
/**
* Agent 决策步骤 Repository
*/
@Repository
public interface AgentStepRepository extends JpaRepository<AgentStep, Long> {
/**
* 根据会话ID查询所有步骤(按步骤号排序)
*/
List<AgentStep> findBySessionIdOrderByStepIndex(String sessionId);
/**
* 统计某个会话的步骤数
*/
int countBySessionId(String sessionId);
}
@@ -1,73 +0,0 @@
package com.superbiz.agent.repository;
import com.superbiz.agent.domain.enums.DiagnosisStatus;
import com.superbiz.agent.domain.enums.FaultCategory;
import com.superbiz.agent.domain.entity.DiagnosisRecord;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
/**
* 诊断记录 Repository
*/
@Repository
public interface DiagnosisRecordRepository extends JpaRepository<DiagnosisRecord, Long> {
/**
* 根据诊断ID查询
*/
Optional<DiagnosisRecord> findByDiagnosisId(String diagnosisId);
/**
* 根据业务ID查询
*/
Optional<DiagnosisRecord> findByBusinessId(String businessId);
/**
* 根据链路追踪ID查询
*/
Optional<DiagnosisRecord> findByTraceId(String traceId);
/**
* 根据会话ID查询所有记录
*/
List<DiagnosisRecord> findBySessionId(String sessionId);
/**
* 根据故障类别和错误码查询
*/
List<DiagnosisRecord> findByFaultCategoryAndErrorCode(FaultCategory category, String errorCode);
/**
* 根据故障类别、故障源和错误码查询
*/
List<DiagnosisRecord> findByFaultCategoryAndFaultSourceAndErrorCode(
FaultCategory category, String faultSource, String errorCode);
/**
* 根据状态查询
*/
List<DiagnosisRecord> findByStatus(DiagnosisStatus status);
/**
* 根据时间范围查询(分页)
*/
Page<DiagnosisRecord> findByCreatedAtBetween(
LocalDateTime start, LocalDateTime end, Pageable pageable);
/**
* 根据故障类别和时间范围查询(分页)
*/
Page<DiagnosisRecord> findByFaultCategoryAndCreatedAtBetween(
FaultCategory category, LocalDateTime start, LocalDateTime end, Pageable pageable);
/**
* 查询有用反馈的高置信度记录(用于生成案例)
*/
List<DiagnosisRecord> findByFeedbackAndConfidenceGreaterThanEqual(String feedback, Integer confidence);
}
@@ -0,0 +1,12 @@
package com.superbiz.agent.repository;
import com.superbiz.agent.domain.entity.DiagnosisSession;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface DiagnosisSessionRepository extends JpaRepository<DiagnosisSession, Long> {
Optional<DiagnosisSession> findBySessionId(String sessionId);
}
@@ -0,0 +1,29 @@
package com.superbiz.agent.repository;
import com.superbiz.agent.domain.entity.ToolInvocation;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
/**
* 工具调用明细 Repository
*/
@Repository
public interface ToolInvocationRepository extends JpaRepository<ToolInvocation, Long> {
/**
* 根据会话ID查询所有工具调用
*/
List<ToolInvocation> findBySessionId(String sessionId);
/**
* 根据工具名查询所有调用
*/
List<ToolInvocation> findByToolName(String toolName);
/**
* 根据会话ID和工具名查询
*/
List<ToolInvocation> findBySessionIdAndToolName(String sessionId, String toolName);
}
@@ -9,6 +9,13 @@ import com.superbiz.agent.agent.tool.DateTimeTools;
import com.superbiz.agent.agent.tool.InternalDocsTools; import com.superbiz.agent.agent.tool.InternalDocsTools;
import com.superbiz.agent.agent.tool.QueryLogsTools; import com.superbiz.agent.agent.tool.QueryLogsTools;
import com.superbiz.agent.agent.tool.QueryMetricsTools; import com.superbiz.agent.agent.tool.QueryMetricsTools;
import com.superbiz.agent.domain.entity.AgentStep;
import com.superbiz.agent.domain.entity.AgentStep;
import com.superbiz.agent.domain.entity.DiagnosisSession;
import com.superbiz.agent.hook.AgentLoggingHook;
import com.superbiz.agent.repository.AgentStepRepository;
import com.superbiz.agent.repository.DiagnosisSessionRepository;
import com.superbiz.agent.util.SessionContextHolder;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.messages.AssistantMessage; import org.springframework.ai.chat.messages.AssistantMessage;
@@ -20,6 +27,7 @@ import com.superbiz.agent.tool.LookupKnowledgeTool;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.UUID;
/** /**
* AI Ops 智能运维服务 * AI Ops 智能运维服务
@@ -48,6 +56,12 @@ public class AiOpsService {
@Autowired @Autowired
private AiOpsPromptProperties promptProperties; private AiOpsPromptProperties promptProperties;
@Autowired
private DiagnosisSessionRepository diagnosisSessionRepository;
@Autowired
private AgentStepRepository agentStepRepository;
/** /**
* 执行 AI Ops 告警分析流程 * 执行 AI Ops 告警分析流程
* *
@@ -59,34 +73,65 @@ public class AiOpsService {
public Optional<OverAllState> executeAiOpsAnalysis(ChatModel chatModel, ToolCallback[] toolCallbacks) throws GraphRunnerException { public Optional<OverAllState> executeAiOpsAnalysis(ChatModel chatModel, ToolCallback[] toolCallbacks) throws GraphRunnerException {
logger.info("开始执行 AI Ops 多 Agent 协作流程"); logger.info("开始执行 AI Ops 多 Agent 协作流程");
// 构建 Planner 和 Executor Agent String sessionId = UUID.randomUUID().toString().substring(0, 8);
ReactAgent plannerAgent = buildPlannerAgent(chatModel, toolCallbacks); long startTime = System.currentTimeMillis();
ReactAgent executorAgent = buildExecutorAgent(chatModel, toolCallbacks);
// 构建 Supervisor Agent // 创建诊断会话
SupervisorAgent supervisorAgent = SupervisorAgent.builder() DiagnosisSession session = DiagnosisSession.builder()
.name("ai_ops_supervisor") .sessionId(sessionId)
.description("负责调度 Planner 与 Executor 的多 Agent 控制器") .query("AI Ops 告警分析")
.model(chatModel) .status("RUNNING")
.systemPrompt(promptProperties.getSupervisor()) .agentFlow("AI_OPS")
.subAgents(List.of(plannerAgent, executorAgent))
.build(); .build();
diagnosisSessionRepository.save(session);
String taskPrompt = "你是企业级 SRE,接到了自动化告警排查任务。请结合工具调用,执行**规划→执行→再规划**的闭环,并最终按照固定模板输出《告警分析报告》。禁止编造虚假数据,如连续多次查询失败需诚实反馈无法完成的原因。"; // 设置 ThreadLocal 上下文(LookupKnowledgeTool 通过此获取 sessionId)
SessionContextHolder.setSessionId(sessionId);
logger.info("调用 Supervisor Agent 开始编排..."); try {
// 构建 Planner 和 Executor Agent(每个 Agent 各自带 Hook)
ReactAgent plannerAgent = buildPlannerAgent(chatModel, toolCallbacks);
ReactAgent executorAgent = buildExecutorAgent(chatModel, toolCallbacks);
Optional<OverAllState> stateOptional = supervisorAgent.invoke(taskPrompt); // 构建 Supervisor Agent(不加 Hook)
SupervisorAgent supervisorAgent = SupervisorAgent.builder()
.name("ai_ops_supervisor")
.description("负责调度 Planner 与 Executor 的多 Agent 控制器")
.model(chatModel)
.systemPrompt(promptProperties.getSupervisor())
.subAgents(List.of(plannerAgent, executorAgent))
.build();
// 添加调试代码 String taskPrompt = "你是企业级 SRE,接到了自动化告警排查任务。请结合工具调用,执行**规划→执行→再规划**的闭环,并最终按照固定模板输出《告警分析报告》。禁止编造虚假数据,如连续多次查询失败需诚实反馈无法完成的原因。";
if (stateOptional.isPresent()) {
OverAllState state = stateOptional.get(); logger.info("调用 Supervisor Agent 开始编排...");
logger.debug("Final State Keys: {}", state.data().keySet()); // 打印所有 key
logger.debug("Planner Plan: {}", state.value("planner_plan")); Optional<OverAllState> stateOptional = supervisorAgent.invoke(taskPrompt);
logger.debug("Executor Feedback: {}", state.value("executor_feedback"));
long duration = System.currentTimeMillis() - startTime;
// 更新诊断会话
session.setStatus(stateOptional.isPresent() ? "SUCCESS" : "FAILED");
session.setTotalDurationMs((int) duration);
backfillSessionMetrics(session);
diagnosisSessionRepository.save(session);
// 添加调试代码
if (stateOptional.isPresent()) {
OverAllState state = stateOptional.get();
logger.debug("Final State Keys: {}", state.data().keySet());
logger.debug("Planner Plan: {}", state.value("planner_plan"));
logger.debug("Executor Feedback: {}", state.value("executor_feedback"));
}
return stateOptional;
} catch (Exception e) {
session.setStatus("FAILED");
diagnosisSessionRepository.save(session);
throw e;
} finally {
SessionContextHolder.clear();
} }
return stateOptional;
} }
/** /**
@@ -124,6 +169,7 @@ public class AiOpsService {
.systemPrompt(promptProperties.getPlanner()) .systemPrompt(promptProperties.getPlanner())
.methodTools(buildMethodToolsArray()) .methodTools(buildMethodToolsArray())
.tools(toolCallbacks) .tools(toolCallbacks)
.hooks(new AgentLoggingHook(agentStepRepository, "planner"))
.outputKey("planner_plan") .outputKey("planner_plan")
.build(); .build();
} }
@@ -139,6 +185,7 @@ public class AiOpsService {
.systemPrompt(promptProperties.getExecutor()) .systemPrompt(promptProperties.getExecutor())
.methodTools(buildMethodToolsArray()) .methodTools(buildMethodToolsArray())
.tools(toolCallbacks) .tools(toolCallbacks)
.hooks(new AgentLoggingHook(agentStepRepository, "executor"))
.outputKey("executor_feedback") .outputKey("executor_feedback")
.build(); .build();
} }
@@ -157,4 +204,26 @@ public class AiOpsService {
return new Object[]{dateTimeTools, lookupKnowledgeTool, queryMetricsTools}; return new Object[]{dateTimeTools, lookupKnowledgeTool, queryMetricsTools};
} }
} }
/** 从 agent_step 汇总指标回填 diagnosis_session */
private void backfillSessionMetrics(DiagnosisSession session) {
try {
List<AgentStep> steps = agentStepRepository.findBySessionIdOrderByStepIndex(session.getSessionId());
if (steps.isEmpty()) return;
int totalTokens = 0;
int stepCount = 0;
int toolCallCount = 0;
for (AgentStep s : steps) {
stepCount++;
if (s.getTokenCount() != null) totalTokens += s.getTokenCount();
if (Boolean.TRUE.equals(s.getHasToolCall())) toolCallCount++;
}
session.setTotalTokenCount(totalTokens);
session.setStepCount(stepCount);
session.setToolCallCount(toolCallCount);
} catch (Exception e) {
logger.warn("回填会话指标失败: sessionId={}", session.getSessionId(), e);
}
}
} }
@@ -1,24 +1,40 @@
package com.superbiz.agent.service; package com.superbiz.agent.service;
import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.agent.ReactAgent; import com.alibaba.cloud.ai.graph.agent.ReactAgent;
import com.alibaba.cloud.ai.graph.agent.flow.agent.SupervisorAgent;
import com.alibaba.cloud.ai.graph.exception.GraphRunnerException; import com.alibaba.cloud.ai.graph.exception.GraphRunnerException;
import com.superbiz.agent.agent.tool.DateTimeTools; import com.superbiz.agent.agent.tool.DateTimeTools;
import com.superbiz.agent.agent.tool.InternalDocsTools; import com.superbiz.agent.agent.tool.InternalDocsTools;
import com.superbiz.agent.agent.tool.QueryLogsTools; import com.superbiz.agent.agent.tool.QueryLogsTools;
import com.superbiz.agent.agent.tool.QueryMetricsTools; import com.superbiz.agent.agent.tool.QueryMetricsTools;
import com.superbiz.agent.tool.LookupKnowledgeTool; import com.superbiz.agent.domain.entity.DiagnosisSession;
import com.superbiz.agent.hook.AgentLoggingHook; import com.superbiz.agent.hook.AgentLoggingHook;
import com.superbiz.agent.hook.TokenTrackingChatModel;
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.util.QuestionComplexity;
import com.superbiz.agent.util.SessionContextHolder;
import jakarta.annotation.PostConstruct;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.model.ChatModel; import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.tool.ToolCallback; import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.ToolCallbackProvider; import org.springframework.ai.tool.ToolCallbackProvider;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional;
import java.util.UUID;
/** /**
* 聊天服务 * 聊天服务
@@ -50,6 +66,37 @@ public class ChatService {
@Autowired @Autowired
private LookupKnowledgeTool lookupKnowledgeTool; private LookupKnowledgeTool lookupKnowledgeTool;
@Autowired
private DiagnosisSessionRepository diagnosisSessionRepository;
@Autowired
private AgentStepRepository agentStepRepository;
/** 多 Agent Chat 的 Prompt */
private String chatPlannerPrompt;
private String chatExecutorPrompt;
@PostConstruct
public void init() {
// 加载 Prompt
try {
chatPlannerPrompt = new String(
new ClassPathResource("prompts/chat-planner-prompt.md").getInputStream().readAllBytes(),
StandardCharsets.UTF_8);
chatExecutorPrompt = new String(
new ClassPathResource("prompts/chat-executor-prompt.md").getInputStream().readAllBytes(),
StandardCharsets.UTF_8);
logger.info("Chat 多 Agent Prompts 加载成功");
} catch (IOException e) {
logger.error("加载 Chat Prompt 文件失败", e);
throw new RuntimeException("Failed to load chat prompts", e);
}
// 包装 ChatModel 以捕获 token 用量
chatModel = new TokenTrackingChatModel(chatModel);
logger.info("ChatModel 已包装 TokenTrackingChatModel");
}
/** /**
* 获取注入的 ChatModel * 获取注入的 ChatModel
*/ */
@@ -177,7 +224,7 @@ public class ChatService {
.systemPrompt(systemPrompt) .systemPrompt(systemPrompt)
.methodTools(buildMethodToolsArray()) .methodTools(buildMethodToolsArray())
.tools(getToolCallbacks()) .tools(getToolCallbacks())
.hooks(new AgentLoggingHook()) // 添加日志 Hook .hooks(new AgentLoggingHook(agentStepRepository, "intelligent_assistant"))
.build(); .build();
} }
@@ -191,16 +238,201 @@ public class ChatService {
logger.info("========================================"); logger.info("========================================");
logger.info("📝 用户问题: {}", question); logger.info("📝 用户问题: {}", question);
String sessionId = UUID.randomUUID().toString().substring(0, 8);
long startTime = System.currentTimeMillis(); long startTime = System.currentTimeMillis();
var response = agent.call(question);
long duration = System.currentTimeMillis() - startTime;
String answer = response.getText(); // 创建诊断会话
DiagnosisSession session = DiagnosisSession.builder()
.sessionId(sessionId)
.query(question)
.status("RUNNING")
.agentFlow("CHAT")
.build();
diagnosisSessionRepository.save(session);
logger.info("⏱️ 总耗时: {} ms", duration); // 设置 ThreadLocal 上下文(LookupKnowledgeTool 通过此获取 sessionId)
logger.info("📏 输出长度: {} 字符", answer.length()); SessionContextHolder.setSessionId(sessionId);
logger.info("========================================");
return answer; try {
var response = agent.call(question);
long duration = System.currentTimeMillis() - startTime;
String answer = response.getText();
// 更新诊断会话
session.setStatus("SUCCESS");
session.setTotalDurationMs((int) duration);
backfillSessionMetrics(session);
diagnosisSessionRepository.save(session);
logger.info("⏱️ 总耗时: {} ms", duration);
logger.info("📏 输出长度: {} 字符", answer.length());
logger.info("========================================");
return answer;
} catch (Exception e) {
session.setStatus("FAILED");
diagnosisSessionRepository.save(session);
throw e;
} finally {
SessionContextHolder.clear();
}
}
/**
* 根据问题复杂度自动选择执行策略
* @param chatModel 聊天模型
* @param toolCallbacks 工具回调
* @param question 用户问题
* @param history 历史消息
* @return AI 回复
*/
public String executeChatWithStrategy(ChatModel chatModel, ToolCallback[] toolCallbacks,
String question, List<Map<String, String>> history) throws GraphRunnerException {
if (QuestionComplexity.isComplex(question)) {
logger.info("📊 问题判定为复杂,使用多 Agent(Planner + Executor)执行");
return executeChatComplex(chatModel, toolCallbacks, question, history);
} else {
logger.info("📊 问题判定为简单,使用单 Agent 执行");
String systemPrompt = buildSystemPrompt(history);
ReactAgent agent = createReactAgent(chatModel, systemPrompt);
return executeChat(agent, question);
}
}
/**
* 多 Agent 复杂对话执行(Planner + Executor + Supervisor)
*/
public String 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();
DiagnosisSession session = DiagnosisSession.builder()
.sessionId(sessionId)
.query(question)
.status("RUNNING")
.agentFlow("CHAT")
.build();
diagnosisSessionRepository.save(session);
SessionContextHolder.setSessionId(sessionId);
try {
ReactAgent planner = buildChatPlannerAgent(chatModel, toolCallbacks, history);
ReactAgent executor = buildChatExecutorAgent(chatModel, toolCallbacks, history);
SupervisorAgent supervisor = SupervisorAgent.builder()
.name("chat_supervisor")
.description("负责调度 Planner 与 Executor 的多 Agent 控制器")
.model(chatModel)
.systemPrompt("你是一个智能任务调度器。分析用户问题,调用 Planner 拆解步骤,调用 Executor 执行各步骤。")
.subAgents(List.of(planner, executor))
.build();
Optional<OverAllState> stateOptional = supervisor.invoke(question);
long duration = System.currentTimeMillis() - startTime;
String answer = null;
if (stateOptional.isPresent()) {
// 从 state 中提取 Executor 的最终输出
OverAllState state = stateOptional.get();
Optional<AssistantMessage> executorOutput = state.value("executor_feedback")
.filter(AssistantMessage.class::isInstance)
.map(AssistantMessage.class::cast);
if (executorOutput.isPresent()) {
answer = executorOutput.get().getText();
}
}
if (answer == null || answer.isBlank()) {
answer = "抱歉,多 Agent 分析未能生成有效结论。";
}
session.setStatus("SUCCESS");
session.setTotalDurationMs((int) duration);
backfillSessionMetrics(session);
diagnosisSessionRepository.save(session);
logger.info("⏱️ 多 Agent 总耗时: {} ms", duration);
logger.info("📏 输出长度: {} 字符", answer.length());
return answer;
} catch (Exception e) {
session.setStatus("FAILED");
diagnosisSessionRepository.save(session);
logger.error("多 Agent 执行失败", e);
return "执行失败: " + e.getMessage();
} finally {
SessionContextHolder.clear();
}
}
private ReactAgent buildChatPlannerAgent(ChatModel chatModel, ToolCallback[] toolCallbacks,
List<Map<String, String>> history) {
StringBuilder prompt = new StringBuilder(chatPlannerPrompt);
if (!history.isEmpty()) {
prompt.append("\n\n--- 对话历史 ---\n");
for (Map<String, String> msg : history) {
prompt.append(msg.get("role")).append(": ").append(msg.get("content")).append("\n");
}
prompt.append("--- 对话历史结束 ---\n");
}
return ReactAgent.builder()
.name("chat_planner")
.description("负责拆解问题、规划步骤")
.model(chatModel)
.systemPrompt(prompt.toString())
// Planner 不注入工具,只能规划不能执行
.hooks(new AgentLoggingHook(agentStepRepository, "planner"))
.outputKey("planner_plan")
.build();
}
private ReactAgent buildChatExecutorAgent(ChatModel chatModel, ToolCallback[] toolCallbacks,
List<Map<String, String>> history) {
StringBuilder prompt = new StringBuilder(chatExecutorPrompt);
if (!history.isEmpty()) {
prompt.append("\n\n--- 对话历史 ---\n");
for (Map<String, String> msg : history) {
prompt.append(msg.get("role")).append(": ").append(msg.get("content")).append("\n");
}
prompt.append("--- 对话历史结束 ---\n");
}
return ReactAgent.builder()
.name("chat_executor")
.description("负责执行具体步骤并及时反馈")
.model(chatModel)
.systemPrompt(prompt.toString())
.methodTools(buildMethodToolsArray())
.tools(toolCallbacks)
.hooks(new AgentLoggingHook(agentStepRepository, "executor"))
.outputKey("executor_feedback")
.build();
}
/** 从 agent_step 汇总 token、步数等指标回填 diagnosis_session */
private void backfillSessionMetrics(DiagnosisSession session) {
try {
List<com.superbiz.agent.domain.entity.AgentStep> steps =
agentStepRepository.findBySessionIdOrderByStepIndex(session.getSessionId());
if (steps.isEmpty()) return;
int totalTokens = 0;
int stepCount = 0;
int toolCallCount = 0;
for (var s : steps) {
stepCount++;
if (s.getTokenCount() != null) totalTokens += s.getTokenCount();
if (Boolean.TRUE.equals(s.getHasToolCall())) toolCallCount++;
}
session.setTotalTokenCount(totalTokens);
session.setStepCount(stepCount);
session.setToolCallCount(toolCallCount);
} catch (Exception e) {
logger.warn("回填会话指标失败: sessionId={}", session.getSessionId(), e);
}
} }
} }
@@ -1,8 +1,11 @@
package com.superbiz.agent.tool; package com.superbiz.agent.tool;
import com.superbiz.agent.domain.entity.ToolInvocation;
import com.superbiz.agent.dto.*; import com.superbiz.agent.dto.*;
import com.superbiz.agent.repository.ToolInvocationRepository;
import com.superbiz.agent.service.KnowledgeIndexService; import com.superbiz.agent.service.KnowledgeIndexService;
import com.superbiz.agent.service.VectorSearchService; import com.superbiz.agent.service.VectorSearchService;
import com.superbiz.agent.util.SessionContextHolder;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.tool.annotation.Tool; import org.springframework.ai.tool.annotation.Tool;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@@ -25,6 +28,9 @@ public class LookupKnowledgeTool {
@Autowired @Autowired
private VectorSearchService vectorSearchService; private VectorSearchService vectorSearchService;
@Autowired
private ToolInvocationRepository toolInvocationRepository;
/** /**
* 查询知识库文档 * 查询知识库文档
* *
@@ -134,9 +140,120 @@ public class LookupKnowledgeTool {
log.info("========================================"); log.info("========================================");
// 记录 tool_invocation(持久化检索明细)
saveToolInvocation(query, l0Matches, l1Results, highConfidence, startTime, result);
return result; return result;
} }
/**
* 保存工具调用明细到 tool_invocation 表
*/
private void saveToolInvocation(String query, List<KnowledgeEntry> l0Matches,
List<VectorSearchService.SearchResult> l1Results,
boolean highConfidence, long startTime, LookupResult result) {
try {
String sessionId = SessionContextHolder.getSessionId();
if (sessionId == null) return; // 非会话上下文不记录
boolean hasL0 = l0Matches != null && !l0Matches.isEmpty();
boolean hasL1 = l1Results != null && !l1Results.isEmpty();
long duration = System.currentTimeMillis() - startTime;
String layer;
String outputPreview = null;
int outputLength = 0;
int l0Count = 0;
int l1Count = 0;
boolean truncated = false;
if (hasL0 && !highConfidence) {
layer = "L0+L1";
l0Count = l0Matches.size();
l1Count = l1Results.size();
} else if (hasL0) {
layer = "L0";
l0Count = l0Matches.size();
} else if (hasL1) {
layer = "L1";
l1Count = l1Results.size();
} else {
layer = null;
}
// 拼接 output_preview(前500字符)
if (result != null && result.getPrimary() != null && result.getPrimary().getContent() != null) {
String content = result.getPrimary().getContent();
outputLength = content.length();
if (content.length() > 500) {
outputPreview = content.substring(0, 500) + "...";
truncated = true;
} else {
outputPreview = content;
}
} else if (l1Results != null && !l1Results.isEmpty() && l1Results.get(0).getContent() != null) {
String content = l1Results.get(0).getContent();
outputLength = content.length();
if (content.length() > 500) {
outputPreview = content.substring(0, 500) + "...";
truncated = true;
} else {
outputPreview = content;
}
}
// 构建检索明细 JSON
StringBuilder details = new StringBuilder("{");
if (hasL0) {
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("]");
}
if (hasL1) {
if (hasL0) details.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("]");
}
details.append("}");
ToolInvocation inv = ToolInvocation.builder()
.sessionId(sessionId)
.toolName("lookup_knowledge")
.inputParams("{\"query\":\"" + escapeJson(query) + "\"}")
.outputPreview(outputPreview)
.outputLength(outputLength)
.retrievalLayer(layer)
.l0MatchCount(hasL0 ? l0Count : null)
.l1MatchCount(hasL1 ? l1Count : null)
.isTruncated(truncated)
.retrievalDetails(details.toString())
.durationMs((int) duration)
.success(true)
.build();
toolInvocationRepository.save(inv);
log.debug("tool_invocation 已保存: sessionId={}, layer={}, duration={}ms", sessionId, layer, duration);
} catch (Exception e) {
log.error("保存 tool_invocation 失败", e);
}
}
private String escapeJson(String s) {
if (s == null) return "";
return s.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("\n", "\\n")
.replace("\r", "\\r")
.replace("\t", "\\t");
}
/** /**
* 组装查询结果 * 组装查询结果
* *
@@ -0,0 +1,44 @@
package com.superbiz.agent.util;
import java.util.List;
/**
* 问题复杂度判断
* 用于决定使用单 Agent 还是多 Agent(Planner + Executor)处理
*/
public class QuestionComplexity {
/** 复杂问题关键词 — 需要多步分析、排查、根因定位 */
private static final List<String> COMPLEX_KEYWORDS = List.of(
"排查", "分析", "为什么", "根因", "调查", "对比", "影响范围",
"原因", "故障", "告警", "诊断", "链路", "流程", "步骤",
"root cause", "troubleshoot", "investigate"
);
/** 极简问题关键词 — 快速回答,无需多 Agent */
private static final List<String> SIMPLE_KEYWORDS = List.of(
"是什么", "查一下", "什么是", "时间", "天气", "定义",
"查", "找", "what is", "define", "time"
);
/**
* 判断是否为复杂问题
*/
public static boolean isComplex(String question) {
if (question == null || question.isBlank()) return false;
String q = question.toLowerCase();
// 复杂关键词匹配 → 多 Agent
for (String kw : COMPLEX_KEYWORDS) {
if (q.contains(kw)) return true;
}
// 简单关键词匹配 → 单 Agent
for (String kw : SIMPLE_KEYWORDS) {
if (q.contains(kw)) return false;
}
// 默认:长问题(>30 字)视为复杂,短问题视为简单
return question.length() > 30;
}
}
@@ -0,0 +1,39 @@
package com.superbiz.agent.util;
/**
* 会话上下文持有者(基于 ThreadLocal)
* <p>
* 用于在执行链路中传递 sessionId 和 agentName,覆盖 AgentLoggingHook 和
* LookupKnowledgeTool 等无法直接通过 RunnableConfig 获取上下文的组件。
* <p>
* 使用规范:
* 1. 调用方(ChatService/AiOpsService)在 Agent 执行前调用 setSessionId() 和 setAgentName()
* 2. AgentLoggingHook 和工具类通过 getSessionId() / getAgentName() 读取
* 3. 必须在 finally 块中调用 clear(),防止内存泄漏和线程污染
*/
public class SessionContextHolder {
private static final ThreadLocal<String> SESSION_ID = new ThreadLocal<>();
private static final ThreadLocal<String> AGENT_NAME = new ThreadLocal<>();
public static void setSessionId(String sessionId) {
SESSION_ID.set(sessionId);
}
public static String getSessionId() {
return SESSION_ID.get();
}
public static void setAgentName(String agentName) {
AGENT_NAME.set(agentName);
}
public static String getAgentName() {
return AGENT_NAME.get();
}
public static void clear() {
SESSION_ID.remove();
AGENT_NAME.remove();
}
}
@@ -0,0 +1,75 @@
-- V005: 创建会话存储体系(diagnosis_session + agent_step + tool_invocation)
-- 设计文档:openspec/changes/session-storage/design.md
CREATE TABLE diagnosis_session (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
session_id VARCHAR(64) UNIQUE NOT NULL COMMENT '会话唯一 ID',
query TEXT NOT NULL COMMENT '用户原始问题',
status VARCHAR(16) DEFAULT 'PENDING' COMMENT 'PENDING/RUNNING/SUCCESS/FAILED',
agent_flow VARCHAR(32) COMMENT 'CHAT / AI_OPS',
total_duration_ms INT COMMENT '总耗时(毫秒)',
total_token_count INT COMMENT '总 Token 消耗',
step_count INT COMMENT 'Agent 步数',
tool_call_count INT COMMENT '工具调用次数',
self_evaluation JSON COMMENT '自评估信号:{"confidence":0-100,"reasoning":"..."}',
feedback VARCHAR(16) COMMENT '用户反馈:useful/not_useful/null',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_created_at (created_at),
INDEX idx_status (status),
INDEX idx_agent_flow (agent_flow)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='诊断会话表';
CREATE TABLE agent_step (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
session_id VARCHAR(64) NOT NULL COMMENT '关联 diagnosis_session',
step_index INT NOT NULL COMMENT '当前 Agent 的第几步(从0开始)',
agent_name VARCHAR(32) NOT NULL COMMENT 'intelligent_assistant/planner/executor',
model_input JSON COMMENT '模型输入摘要',
model_output JSON COMMENT '模型输出摘要(含工具调用决策)',
thought TEXT COMMENT 'Agent 思考过程',
has_tool_call BOOLEAN DEFAULT FALSE COMMENT '本轮是否调用了工具',
duration_ms INT COMMENT '本轮耗时',
token_count INT COMMENT '本轮 Token 消耗',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_session_step (session_id, step_index),
INDEX idx_agent_name (agent_name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Agent 决策步骤表';
CREATE TABLE tool_invocation (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
session_id VARCHAR(64) NOT NULL COMMENT '关联 diagnosis_session',
step_id BIGINT COMMENT '关联 agent_step.id(可为空,不强制外键)',
tool_name VARCHAR(64) NOT NULL COMMENT 'lookup_knowledge/queryPrometheusAlerts/等',
input_params JSON NOT NULL COMMENT '工具入参',
output_preview TEXT COMMENT '输出前500字符',
output_length INT COMMENT '输出总字符数',
retrieval_layer VARCHAR(8) COMMENT 'L0/L1/L0+L1',
l0_match_count INT COMMENT 'L0 匹配数',
l1_match_count INT COMMENT 'L1 匹配数',
is_truncated BOOLEAN DEFAULT FALSE COMMENT '内容是否被截断',
retrieval_details JSON COMMENT '检索明细:{l0_titles:[], l1_scores:[]}',
duration_ms INT COMMENT '工具执行耗时',
success BOOLEAN DEFAULT TRUE COMMENT '是否成功',
error_message TEXT COMMENT '失败原因',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_session_id (session_id),
INDEX idx_tool_name (tool_name),
INDEX idx_retrieval_layer (retrieval_layer)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='工具调用明细表';
@@ -0,0 +1,6 @@
-- V006: 将 agent_step 的 model_input / model_output 从 JSON 改为 TEXT
-- 原因:buildModelInputSummary() 输出的是纯文本摘要,不是合法 JSON
ALTER TABLE agent_step
MODIFY COLUMN model_input TEXT COMMENT '模型输入摘要',
MODIFY COLUMN model_output TEXT COMMENT '模型输出摘要(含工具调用决策)';
@@ -0,0 +1,2 @@
-- V007: 删除旧的 diagnosis_record 表(已被 diagnosis_session + agent_step + tool_invocation 替代)
DROP TABLE IF EXISTS diagnosis_record;
@@ -0,0 +1,12 @@
你是任务执行器。执行 Planner 分配给你的具体步骤,并及时反馈结果。
## 职责
- 按步骤执行具体的查询任务
- 使用知识库查询、日志查询等工具获取信息
- 将执行结果汇总,给出完整的最终答案
## 规则
- 按顺序执行,不可跳过步骤
- 所有需要外部信息的地方,都必须调用对应的工具
- 不要凭记忆回答,必须基于工具返回的真实数据
- 执行完成后,综合所有结果给出完整的答案
@@ -0,0 +1,20 @@
你是智能任务规划器。分析用户的问题,拆解为具体的执行步骤。
## 职责
- 分析用户问题,拆解为可执行的步骤列表
- **你不能调用任何工具**,你的职责是制定计划,不是执行
- 输出 JSON 格式的计划,不输出其他内容
## 输出格式
```json
{
"plan": ["步骤1描述", "步骤2描述", "步骤3描述"],
"reasoning": "规划思路说明"
}
```
## 规则
- 每个步骤应该是一个可以独立执行的任务
- 步骤要具体可操作,不要模糊
- 如果问题需要查知识库,明确在步骤中说明要查什么
@@ -0,0 +1,61 @@
package com.superbiz.agent.repository;
import com.superbiz.agent.domain.entity.AgentStep;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.test.context.TestPropertySource;
import java.util.List;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
/**
* AgentStepRepository 单元测试
*/
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@TestPropertySource(properties = {
"spring.flyway.enabled=true",
"spring.jpa.hibernate.ddl-auto=validate",
"spring.jpa.show-sql=true"
})
class AgentStepRepositoryTest {
@Autowired
private AgentStepRepository repository;
@Test
void testSaveAndFindBySessionId() {
String sessionId = UUID.randomUUID().toString().substring(0, 8);
AgentStep step0 = AgentStep.builder()
.sessionId(sessionId)
.stepIndex(0)
.agentName("planner")
.hasToolCall(true)
.durationMs(500)
.build();
repository.save(step0);
AgentStep step1 = AgentStep.builder()
.sessionId(sessionId)
.stepIndex(1)
.agentName("executor")
.hasToolCall(false)
.durationMs(300)
.build();
repository.save(step1);
List<AgentStep> steps = repository.findBySessionIdOrderByStepIndex(sessionId);
assertEquals(2, steps.size());
assertEquals("planner", steps.get(0).getAgentName());
assertEquals("executor", steps.get(1).getAgentName());
assertEquals(500, steps.get(0).getDurationMs());
int count = repository.countBySessionId(sessionId);
assertEquals(2, count);
}
}
@@ -1,173 +0,0 @@
package com.superbiz.agent.repository;
import com.superbiz.agent.domain.enums.DiagnosisStatus;
import com.superbiz.agent.domain.enums.FaultCategory;
import com.superbiz.agent.domain.entity.DiagnosisRecord;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.test.context.TestPropertySource;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
/**
* DiagnosisRecordRepository 单元测试
*/
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@TestPropertySource(properties = {
"spring.flyway.enabled=true",
"spring.jpa.hibernate.ddl-auto=validate",
"spring.jpa.show-sql=true"
})
class DiagnosisRecordRepositoryTest {
@Autowired
private DiagnosisRecordRepository repository;
@Test
void testSaveAndFindById() {
// 创建测试数据
DiagnosisRecord record = DiagnosisRecord.builder()
.diagnosisId(UUID.randomUUID().toString())
.sessionId("session-001")
.businessId("order-12345")
.traceId("trace-abc123")
.faultCategory(FaultCategory.API)
.faultSource("广东")
.faultTarget("http://api.example.com/query")
.errorCode("40003")
.errorMessage("接口超时")
.status(DiagnosisStatus.SUCCESS)
.confidence(85)
.duration(1500)
.build();
// 保存
DiagnosisRecord saved = repository.save(record);
assertNotNull(saved.getId());
assertNotNull(saved.getCreatedAt());
System.out.println("✓ 保存成功,ID: " + saved.getId());
// 查询
Optional<DiagnosisRecord> found = repository.findById(saved.getId());
assertTrue(found.isPresent());
assertEquals("order-12345", found.get().getBusinessId());
System.out.println("✓ 根据 ID 查询成功");
}
@Test
void testFindByDiagnosisId() {
String diagnosisId = UUID.randomUUID().toString();
DiagnosisRecord record = DiagnosisRecord.builder()
.diagnosisId(diagnosisId)
.businessId("order-test-001")
.faultCategory(FaultCategory.API)
.status(DiagnosisStatus.PENDING)
.build();
repository.save(record);
Optional<DiagnosisRecord> found = repository.findByDiagnosisId(diagnosisId);
assertTrue(found.isPresent());
assertEquals(diagnosisId, found.get().getDiagnosisId());
System.out.println("✓ 根据 diagnosisId 查询成功");
}
@Test
void testFindByFaultCategoryAndErrorCode() {
// 创建测试数据
DiagnosisRecord record1 = DiagnosisRecord.builder()
.diagnosisId(UUID.randomUUID().toString())
.faultCategory(FaultCategory.API)
.errorCode("40003")
.status(DiagnosisStatus.SUCCESS)
.build();
DiagnosisRecord record2 = DiagnosisRecord.builder()
.diagnosisId(UUID.randomUUID().toString())
.faultCategory(FaultCategory.API)
.errorCode("40003")
.status(DiagnosisStatus.FAILED)
.build();
repository.save(record1);
repository.save(record2);
// 查询
List<DiagnosisRecord> results = repository.findByFaultCategoryAndErrorCode(
FaultCategory.API, "40003");
assertFalse(results.isEmpty());
assertTrue(results.size() >= 2);
System.out.println("✓ 根据故障类别和错误码查询成功,找到 " + results.size() + " 条记录");
}
@Test
void testFindByStatus() {
DiagnosisRecord record = DiagnosisRecord.builder()
.diagnosisId(UUID.randomUUID().toString())
.status(DiagnosisStatus.RUNNING)
.faultCategory(FaultCategory.API)
.build();
repository.save(record);
List<DiagnosisRecord> results = repository.findByStatus(DiagnosisStatus.RUNNING);
assertFalse(results.isEmpty());
System.out.println("✓ 根据状态查询成功,找到 " + results.size() + " 条 RUNNING 记录");
}
@Test
void testUpdateRecord() {
// 创建并保存
DiagnosisRecord record = DiagnosisRecord.builder()
.diagnosisId(UUID.randomUUID().toString())
.status(DiagnosisStatus.PENDING)
.confidence(0)
.build();
DiagnosisRecord saved = repository.save(record);
Long id = saved.getId();
// 更新
saved.setStatus(DiagnosisStatus.SUCCESS);
saved.setConfidence(90);
saved.setRootCause("接口超时导致");
saved.setSolution("增加重试机制");
repository.save(saved);
// 验证更新
Optional<DiagnosisRecord> updated = repository.findById(id);
assertTrue(updated.isPresent());
assertEquals(DiagnosisStatus.SUCCESS, updated.get().getStatus());
assertEquals(90, updated.get().getConfidence());
assertNotNull(updated.get().getUpdatedAt());
System.out.println("✓ 更新记录成功");
}
@Test
void testDeleteRecord() {
DiagnosisRecord record = DiagnosisRecord.builder()
.diagnosisId(UUID.randomUUID().toString())
.status(DiagnosisStatus.PENDING)
.build();
DiagnosisRecord saved = repository.save(record);
Long id = saved.getId();
// 删除
repository.deleteById(id);
// 验证删除
Optional<DiagnosisRecord> deleted = repository.findById(id);
assertFalse(deleted.isPresent());
System.out.println("✓ 删除记录成功");
}
}
@@ -0,0 +1,69 @@
package com.superbiz.agent.repository;
import com.superbiz.agent.domain.entity.DiagnosisSession;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.test.context.TestPropertySource;
import java.util.Optional;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
/**
* DiagnosisSessionRepository 单元测试
*/
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@TestPropertySource(properties = {
"spring.flyway.enabled=true",
"spring.jpa.hibernate.ddl-auto=validate",
"spring.jpa.show-sql=true"
})
class DiagnosisSessionRepositoryTest {
@Autowired
private DiagnosisSessionRepository repository;
@Test
void testSaveAndFindBySessionId() {
String sessionId = UUID.randomUUID().toString().substring(0, 8);
DiagnosisSession session = DiagnosisSession.builder()
.sessionId(sessionId)
.query("测试查询")
.status("RUNNING")
.agentFlow("CHAT")
.build();
DiagnosisSession saved = repository.save(session);
assertNotNull(saved.getId());
assertEquals(sessionId, saved.getSessionId());
Optional<DiagnosisSession> found = repository.findBySessionId(sessionId);
assertTrue(found.isPresent());
assertEquals("测试查询", found.get().getQuery());
assertEquals("CHAT", found.get().getAgentFlow());
}
@Test
void testUpdateStatus() {
String sessionId = UUID.randomUUID().toString().substring(0, 8);
DiagnosisSession session = DiagnosisSession.builder()
.sessionId(sessionId)
.query("更新测试")
.status("RUNNING")
.agentFlow("AI_OPS")
.build();
DiagnosisSession saved = repository.save(session);
saved.setStatus("SUCCESS");
saved.setTotalDurationMs(1500);
repository.save(saved);
DiagnosisSession updated = repository.findBySessionId(sessionId).orElseThrow();
assertEquals("SUCCESS", updated.getStatus());
assertEquals(1500, updated.getTotalDurationMs());
}
}
@@ -0,0 +1,64 @@
package com.superbiz.agent.repository;
import com.superbiz.agent.domain.entity.ToolInvocation;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.test.context.TestPropertySource;
import java.util.List;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
/**
* ToolInvocationRepository 单元测试
*/
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@TestPropertySource(properties = {
"spring.flyway.enabled=true",
"spring.jpa.hibernate.ddl-auto=validate",
"spring.jpa.show-sql=true"
})
class ToolInvocationRepositoryTest {
@Autowired
private ToolInvocationRepository repository;
@Test
void testSaveAndFindBySessionId() {
String sessionId = UUID.randomUUID().toString().substring(0, 8);
ToolInvocation inv1 = ToolInvocation.builder()
.sessionId(sessionId)
.toolName("lookup_knowledge")
.inputParams("{\"query\":\"ERR_TIMEOUT\"}")
.retrievalLayer("L0")
.l0MatchCount(1)
.durationMs(50)
.success(true)
.build();
repository.save(inv1);
ToolInvocation inv2 = ToolInvocation.builder()
.sessionId(sessionId)
.toolName("queryPrometheusAlerts")
.inputParams("{\"metric\":\"cpu_usage\"}")
.durationMs(200)
.success(true)
.build();
repository.save(inv2);
List<ToolInvocation> bySession = repository.findBySessionId(sessionId);
assertEquals(2, bySession.size());
List<ToolInvocation> byTool = repository.findByToolName("lookup_knowledge");
assertFalse(byTool.isEmpty());
List<ToolInvocation> byBoth = repository.findBySessionIdAndToolName(sessionId, "lookup_knowledge");
assertEquals(1, byBoth.size());
assertEquals("L0", byBoth.get(0).getRetrievalLayer());
}
}