From 0d9cce75f9b2d2a81913afc97f36f363f404d3a0 Mon Sep 17 00:00:00 2001 From: zhuyongxin Date: Fri, 26 Jun 2026 16:22:05 +0800 Subject: [PATCH] =?UTF-8?q?feat(session):=20=E4=BC=9A=E8=AF=9D=E5=AD=98?= =?UTF-8?q?=E5=82=A8=E4=BD=93=E7=B3=BB=E5=AE=9E=E7=8E=B0=20&=20Chat?= =?UTF-8?q?=E5=A4=9AAgent=E8=B7=AF=E7=94=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增诊断会话(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 --- openspec/changes/session-storage/.committed | 0 openspec/changes/session-storage/decisions.md | 88 ++++++ openspec/changes/session-storage/design.md | 93 +++++++ openspec/changes/session-storage/proposal.md | 70 +++++ .../session-storage/specs/functional-spec.md | 153 +++++++++++ openspec/changes/session-storage/tasks.md | 151 +++++++++++ .../agent/controller/ChatController.java | 14 +- .../agent/domain/entity/AgentStep.java | 64 +++++ .../agent/domain/entity/DiagnosisRecord.java | 128 --------- .../agent/domain/entity/DiagnosisSession.java | 80 ++++++ .../agent/domain/entity/ToolInvocation.java | 84 ++++++ .../agent/domain/enums/DiagnosisStatus.java | 21 -- .../superbiz/agent/hook/AgentLoggingHook.java | 217 ++++++++++++--- .../agent/hook/TokenTrackingChatModel.java | 45 ++++ .../superbiz/agent/hook/TokenUsageHolder.java | 22 ++ .../agent/repository/AgentStepRepository.java | 24 ++ .../repository/DiagnosisRecordRepository.java | 73 ----- .../DiagnosisSessionRepository.java | 12 + .../repository/ToolInvocationRepository.java | 29 ++ .../superbiz/agent/service/AiOpsService.java | 111 ++++++-- .../superbiz/agent/service/ChatService.java | 250 +++++++++++++++++- .../agent/tool/LookupKnowledgeTool.java | 117 ++++++++ .../agent/util/QuestionComplexity.java | 44 +++ .../agent/util/SessionContextHolder.java | 39 +++ .../V005__create_session_storage.sql | 75 ++++++ .../V006__fix_agent_step_json_to_text.sql | 6 + .../migration/V007__drop_diagnosis_record.sql | 2 + .../resources/prompts/chat-executor-prompt.md | 12 + .../resources/prompts/chat-planner-prompt.md | 20 ++ .../repository/AgentStepRepositoryTest.java | 61 +++++ .../DiagnosisRecordRepositoryTest.java | 173 ------------ .../DiagnosisSessionRepositoryTest.java | 69 +++++ .../ToolInvocationRepositoryTest.java | 64 +++++ 33 files changed, 1941 insertions(+), 470 deletions(-) create mode 100644 openspec/changes/session-storage/.committed create mode 100644 openspec/changes/session-storage/decisions.md create mode 100644 openspec/changes/session-storage/design.md create mode 100644 openspec/changes/session-storage/proposal.md create mode 100644 openspec/changes/session-storage/specs/functional-spec.md create mode 100644 openspec/changes/session-storage/tasks.md create mode 100644 src/main/java/com/superbiz/agent/domain/entity/AgentStep.java delete mode 100644 src/main/java/com/superbiz/agent/domain/entity/DiagnosisRecord.java create mode 100644 src/main/java/com/superbiz/agent/domain/entity/DiagnosisSession.java create mode 100644 src/main/java/com/superbiz/agent/domain/entity/ToolInvocation.java delete mode 100644 src/main/java/com/superbiz/agent/domain/enums/DiagnosisStatus.java create mode 100644 src/main/java/com/superbiz/agent/hook/TokenTrackingChatModel.java create mode 100644 src/main/java/com/superbiz/agent/hook/TokenUsageHolder.java create mode 100644 src/main/java/com/superbiz/agent/repository/AgentStepRepository.java delete mode 100644 src/main/java/com/superbiz/agent/repository/DiagnosisRecordRepository.java create mode 100644 src/main/java/com/superbiz/agent/repository/DiagnosisSessionRepository.java create mode 100644 src/main/java/com/superbiz/agent/repository/ToolInvocationRepository.java create mode 100644 src/main/java/com/superbiz/agent/util/QuestionComplexity.java create mode 100644 src/main/java/com/superbiz/agent/util/SessionContextHolder.java create mode 100644 src/main/resources/db/migration/V005__create_session_storage.sql create mode 100644 src/main/resources/db/migration/V006__fix_agent_step_json_to_text.sql create mode 100644 src/main/resources/db/migration/V007__drop_diagnosis_record.sql create mode 100644 src/main/resources/prompts/chat-executor-prompt.md create mode 100644 src/main/resources/prompts/chat-planner-prompt.md create mode 100644 src/test/java/com/superbiz/agent/repository/AgentStepRepositoryTest.java delete mode 100644 src/test/java/com/superbiz/agent/repository/DiagnosisRecordRepositoryTest.java create mode 100644 src/test/java/com/superbiz/agent/repository/DiagnosisSessionRepositoryTest.java create mode 100644 src/test/java/com/superbiz/agent/repository/ToolInvocationRepositoryTest.java diff --git a/openspec/changes/session-storage/.committed b/openspec/changes/session-storage/.committed new file mode 100644 index 0000000..e69de29 diff --git a/openspec/changes/session-storage/decisions.md b/openspec/changes/session-storage/decisions.md new file mode 100644 index 0000000..c4a152f --- /dev/null +++ b/openspec/changes/session-storage/decisions.md @@ -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 方案 diff --git a/openspec/changes/session-storage/design.md b/openspec/changes/session-storage/design.md new file mode 100644 index 0000000..960b82e --- /dev/null +++ b/openspec/changes/session-storage/design.md @@ -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 计数器 | diff --git a/openspec/changes/session-storage/proposal.md b/openspec/changes/session-storage/proposal.md new file mode 100644 index 0000000..1b35b8e --- /dev/null +++ b/openspec/changes/session-storage/proposal.md @@ -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 字符) diff --git a/openspec/changes/session-storage/specs/functional-spec.md b/openspec/changes/session-storage/specs/functional-spec.md new file mode 100644 index 0000000..70b1fad --- /dev/null +++ b/openspec/changes/session-storage/specs/functional-spec.md @@ -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 diff --git a/openspec/changes/session-storage/tasks.md b/openspec/changes/session-storage/tasks.md new file mode 100644 index 0000000..a7351df --- /dev/null +++ b/openspec/changes/session-storage/tasks.md @@ -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 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 测试通过 diff --git a/src/main/java/com/superbiz/agent/controller/ChatController.java b/src/main/java/com/superbiz/agent/controller/ChatController.java index 4af6704..aebedbd 100644 --- a/src/main/java/com/superbiz/agent/controller/ChatController.java +++ b/src/main/java/com/superbiz/agent/controller/ChatController.java @@ -83,16 +83,12 @@ public class ChatController { // 记录可用工具 chatService.logAvailableTools(); + ToolCallback[] toolCallbacks = tools != null ? tools.getToolCallbacks() : new ToolCallback[0]; + + // 根据问题复杂度自动选择单 Agent 或多 Agent logger.info("开始 ReactAgent 对话(支持自动工具调用)"); - - // 构建系统提示词(包含历史消息) - String systemPrompt = chatService.buildSystemPrompt(history); - - // 创建 ReactAgent - ReactAgent agent = chatService.createReactAgent(chatModel, systemPrompt); - - // 执行对话 - String fullAnswer = chatService.executeChat(agent, request.getQuestion()); + String fullAnswer = chatService.executeChatWithStrategy(chatModel, toolCallbacks, + request.getQuestion(), history); // 更新会话历史 session.addMessage(request.getQuestion(), fullAnswer); diff --git a/src/main/java/com/superbiz/agent/domain/entity/AgentStep.java b/src/main/java/com/superbiz/agent/domain/entity/AgentStep.java new file mode 100644 index 0000000..27b15fd --- /dev/null +++ b/src/main/java/com/superbiz/agent/domain/entity/AgentStep.java @@ -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(); + } +} diff --git a/src/main/java/com/superbiz/agent/domain/entity/DiagnosisRecord.java b/src/main/java/com/superbiz/agent/domain/entity/DiagnosisRecord.java deleted file mode 100644 index 2a89986..0000000 --- a/src/main/java/com/superbiz/agent/domain/entity/DiagnosisRecord.java +++ /dev/null @@ -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> 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(); - } -} diff --git a/src/main/java/com/superbiz/agent/domain/entity/DiagnosisSession.java b/src/main/java/com/superbiz/agent/domain/entity/DiagnosisSession.java new file mode 100644 index 0000000..bd49d03 --- /dev/null +++ b/src/main/java/com/superbiz/agent/domain/entity/DiagnosisSession.java @@ -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(); + } +} diff --git a/src/main/java/com/superbiz/agent/domain/entity/ToolInvocation.java b/src/main/java/com/superbiz/agent/domain/entity/ToolInvocation.java new file mode 100644 index 0000000..08c7598 --- /dev/null +++ b/src/main/java/com/superbiz/agent/domain/entity/ToolInvocation.java @@ -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(); + } +} diff --git a/src/main/java/com/superbiz/agent/domain/enums/DiagnosisStatus.java b/src/main/java/com/superbiz/agent/domain/enums/DiagnosisStatus.java deleted file mode 100644 index adcaf11..0000000 --- a/src/main/java/com/superbiz/agent/domain/enums/DiagnosisStatus.java +++ /dev/null @@ -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; - } -} diff --git a/src/main/java/com/superbiz/agent/hook/AgentLoggingHook.java b/src/main/java/com/superbiz/agent/hook/AgentLoggingHook.java index 00559e2..5f79d99 100644 --- a/src/main/java/com/superbiz/agent/hook/AgentLoggingHook.java +++ b/src/main/java/com/superbiz/agent/hook/AgentLoggingHook.java @@ -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.HookPositions; 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 org.springframework.ai.chat.messages.Message; import org.springframework.ai.chat.messages.AssistantMessage; import org.springframework.ai.chat.messages.UserMessage; import org.springframework.ai.chat.messages.ToolResponseMessage; - import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; /** * Agent 日志 Hook - * 用于记录 Agent 的思考过程、消息流转 + * 记录 Agent 的思考过程、消息流转 + 持久化 agent_step 到 DB */ @Slf4j @HookPositions({HookPosition.BEFORE_MODEL, HookPosition.AFTER_MODEL}) public class AgentLoggingHook extends MessagesModelHook { - private int modelCallCount = 0; + private final AgentStepRepository agentStepRepository; + private final String agentName; + + /** 每个 session 的步数计数器:sessionId → stepIndex */ + private final ConcurrentHashMap stepCounters = new ConcurrentHashMap<>(); + + /** beforeModel → afterModel 中间状态:sessionId_stepIndex → {stepId, startTime} */ + private final ConcurrentHashMap> pendingSteps = new ConcurrentHashMap<>(); + + public AgentLoggingHook(AgentStepRepository agentStepRepository, String agentName) { + this.agentStepRepository = agentStepRepository; + this.agentName = agentName; + } @Override public String getName() { @@ -30,9 +46,12 @@ public class AgentLoggingHook extends MessagesModelHook { @Override public AgentCommand beforeModel(List previousMessages, RunnableConfig config) { - modelCallCount++; + String sessionId = SessionContextHolder.getSessionId(); + + int stepIndex = stepCounters.merge(sessionId, 0, (old, one) -> old + 1); + log.info("========================================"); - log.info("*** [Agent 思考] 第 {} 轮思考开始", modelCallCount); + log.info("*** [Agent 思考] 第 {} 轮思考开始", stepIndex + 1); log.info("*** [Agent 思考] 当前消息数量: {}", previousMessages.size()); // 打印最后几条消息 @@ -40,28 +59,51 @@ public class AgentLoggingHook extends MessagesModelHook { if (lastN > 0) { log.info("*** [Agent 思考] 最近 {} 条消息:", lastN); List recentMessages = previousMessages.subList(previousMessages.size() - lastN, previousMessages.size()); - for (int i = 0; i < recentMessages.size(); i++) { Message msg = recentMessages.get(i); String role = getMessageRole(msg); - log.info(" [{}] 角色: {}, 类型: {}", i + 1, role, msg.getClass().getSimpleName()); - // Message 接口可能没有直接的 getContent() 方法,跳过内容打印 - // 具体内容会在工具调用日志中体现 } } log.info("*** [Agent 思考] 准备调用模型..."); 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); } @Override public AgentCommand afterModel(List previousMessages, RunnableConfig config) { + String sessionId = SessionContextHolder.getSessionId(); + log.info("========================================"); - log.info("*** [Agent 思考] 第 {} 轮思考完成", modelCallCount); + log.info("*** [Agent 思考] 第 {} 轮思考完成", stepCounters.getOrDefault(sessionId, 0)); // 查找最后一条 AssistantMessage(模型的回复) AssistantMessage lastAssistant = null; @@ -72,7 +114,15 @@ public class AgentLoggingHook extends MessagesModelHook { } } + boolean hasToolCall = false; + 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); if (textContent != null && !textContent.isEmpty()) { @@ -84,6 +134,7 @@ public class AgentLoggingHook extends MessagesModelHook { // 检查是否有工具调用 if (lastAssistant.getToolCalls() != null && !lastAssistant.getToolCalls().isEmpty()) { + hasToolCall = true; log.info("*** [Agent 思考] 模型决定调用 {} 个工具:", lastAssistant.getToolCalls().size()); lastAssistant.getToolCalls().forEach(toolCall -> { @@ -100,84 +151,178 @@ public class AgentLoggingHook extends MessagesModelHook { log.info("========================================"); - // 不修改消息,直接返回 + // 更新 agent_step(afterModel:补全 model_output、耗时等) + if (sessionId != null) { + int stepIndex = stepCounters.getOrDefault(sessionId, 0); + String stepKey = sessionId + "_" + stepIndex; + Map 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); } + /** + * 构建模型输入摘要(前 N 条消息的 role + 截断内容) + */ + private String buildModelInputSummary(List 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 的文本内容 */ private String extractTextContent(AssistantMessage message) { + if (message == null) return null; try { - // 方法 1: 尝试通过反射获取 text 字段 + // 方法 1: 反射获取 text 字段 try { java.lang.reflect.Field textField = message.getClass().getDeclaredField("text"); textField.setAccessible(true); Object value = textField.get(message); if (value != null) { - String text = value.toString(); log.debug("通过 text 字段提取成功"); - return text; + return value.toString(); } } catch (NoSuchFieldException e) { - // text 字段不存在,尝试下一种方法 + // 尝试下一种方法 } - // 方法 2: 尝试 content 字段 + // 方法 2: 反射获取 content 字段 try { java.lang.reflect.Field contentField = message.getClass().getDeclaredField("content"); contentField.setAccessible(true); Object value = contentField.get(message); if (value != null) { - String text = value.toString(); log.debug("通过 content 字段提取成功"); - return text; + return value.toString(); } } catch (NoSuchFieldException e) { - // content 字段不存在,尝试下一种方法 + // 尝试下一种方法 } - // 方法 3: 尝试调用 getText() 方法 + // 方法 3: 调用 getText() 方法 try { java.lang.reflect.Method getTextMethod = message.getClass().getMethod("getText"); Object value = getTextMethod.invoke(message); if (value != null) { - String text = value.toString(); log.debug("通过 getText() 方法提取成功"); - return text; + return value.toString(); } } catch (NoSuchMethodException e) { - // getText() 方法不存在,尝试下一种方法 + // 尝试下一种方法 } - // 方法 4: 尝试调用 getContent() 方法 + // 方法 4: 调用 getContent() 方法 try { java.lang.reflect.Method getContentMethod = message.getClass().getMethod("getContent"); Object value = getContentMethod.invoke(message); if (value != null) { - String text = value.toString(); log.debug("通过 getContent() 方法提取成功"); - return text; + return value.toString(); } } catch (NoSuchMethodException e) { - // getContent() 方法不存在 + // 方法不存在 } - // 方法 5: 打印所有字段和方法,帮助调试 + // 方法 5: 打印类结构信息 log.warn("无法提取 AssistantMessage 文本内容,打印类信息:"); log.warn("类名: {}", message.getClass().getName()); log.warn("字段列表:"); for (java.lang.reflect.Field field : message.getClass().getDeclaredFields()) { 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(); if (toString != null && !toString.startsWith("AssistantMessage@")) { log.debug("通过 toString() 提取"); diff --git a/src/main/java/com/superbiz/agent/hook/TokenTrackingChatModel.java b/src/main/java/com/superbiz/agent/hook/TokenTrackingChatModel.java new file mode 100644 index 0000000..92af340 --- /dev/null +++ b/src/main/java/com/superbiz/agent/hook/TokenTrackingChatModel.java @@ -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 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) { + // 不中断模型调用 + } + } +} diff --git a/src/main/java/com/superbiz/agent/hook/TokenUsageHolder.java b/src/main/java/com/superbiz/agent/hook/TokenUsageHolder.java new file mode 100644 index 0000000..c35a772 --- /dev/null +++ b/src/main/java/com/superbiz/agent/hook/TokenUsageHolder.java @@ -0,0 +1,22 @@ +package com.superbiz.agent.hook; + +/** + * Token 用量持有者(基于 ThreadLocal) + * ChatModel 调用后写入实际 token 数,AgentLoggingHook 读取 + */ +public class TokenUsageHolder { + + private static final ThreadLocal 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(); + } +} diff --git a/src/main/java/com/superbiz/agent/repository/AgentStepRepository.java b/src/main/java/com/superbiz/agent/repository/AgentStepRepository.java new file mode 100644 index 0000000..40b9d72 --- /dev/null +++ b/src/main/java/com/superbiz/agent/repository/AgentStepRepository.java @@ -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 { + + /** + * 根据会话ID查询所有步骤(按步骤号排序) + */ + List findBySessionIdOrderByStepIndex(String sessionId); + + /** + * 统计某个会话的步骤数 + */ + int countBySessionId(String sessionId); +} diff --git a/src/main/java/com/superbiz/agent/repository/DiagnosisRecordRepository.java b/src/main/java/com/superbiz/agent/repository/DiagnosisRecordRepository.java deleted file mode 100644 index f6f6b53..0000000 --- a/src/main/java/com/superbiz/agent/repository/DiagnosisRecordRepository.java +++ /dev/null @@ -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 { - - /** - * 根据诊断ID查询 - */ - Optional findByDiagnosisId(String diagnosisId); - - /** - * 根据业务ID查询 - */ - Optional findByBusinessId(String businessId); - - /** - * 根据链路追踪ID查询 - */ - Optional findByTraceId(String traceId); - - /** - * 根据会话ID查询所有记录 - */ - List findBySessionId(String sessionId); - - /** - * 根据故障类别和错误码查询 - */ - List findByFaultCategoryAndErrorCode(FaultCategory category, String errorCode); - - /** - * 根据故障类别、故障源和错误码查询 - */ - List findByFaultCategoryAndFaultSourceAndErrorCode( - FaultCategory category, String faultSource, String errorCode); - - /** - * 根据状态查询 - */ - List findByStatus(DiagnosisStatus status); - - /** - * 根据时间范围查询(分页) - */ - Page findByCreatedAtBetween( - LocalDateTime start, LocalDateTime end, Pageable pageable); - - /** - * 根据故障类别和时间范围查询(分页) - */ - Page findByFaultCategoryAndCreatedAtBetween( - FaultCategory category, LocalDateTime start, LocalDateTime end, Pageable pageable); - - /** - * 查询有用反馈的高置信度记录(用于生成案例) - */ - List findByFeedbackAndConfidenceGreaterThanEqual(String feedback, Integer confidence); -} diff --git a/src/main/java/com/superbiz/agent/repository/DiagnosisSessionRepository.java b/src/main/java/com/superbiz/agent/repository/DiagnosisSessionRepository.java new file mode 100644 index 0000000..e8f2235 --- /dev/null +++ b/src/main/java/com/superbiz/agent/repository/DiagnosisSessionRepository.java @@ -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 { + Optional findBySessionId(String sessionId); +} diff --git a/src/main/java/com/superbiz/agent/repository/ToolInvocationRepository.java b/src/main/java/com/superbiz/agent/repository/ToolInvocationRepository.java new file mode 100644 index 0000000..ec896c5 --- /dev/null +++ b/src/main/java/com/superbiz/agent/repository/ToolInvocationRepository.java @@ -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 { + + /** + * 根据会话ID查询所有工具调用 + */ + List findBySessionId(String sessionId); + + /** + * 根据工具名查询所有调用 + */ + List findByToolName(String toolName); + + /** + * 根据会话ID和工具名查询 + */ + List findBySessionIdAndToolName(String sessionId, String toolName); +} diff --git a/src/main/java/com/superbiz/agent/service/AiOpsService.java b/src/main/java/com/superbiz/agent/service/AiOpsService.java index 8f4934a..523adba 100644 --- a/src/main/java/com/superbiz/agent/service/AiOpsService.java +++ b/src/main/java/com/superbiz/agent/service/AiOpsService.java @@ -9,6 +9,13 @@ import com.superbiz.agent.agent.tool.DateTimeTools; import com.superbiz.agent.agent.tool.InternalDocsTools; import com.superbiz.agent.agent.tool.QueryLogsTools; 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.LoggerFactory; import org.springframework.ai.chat.messages.AssistantMessage; @@ -20,6 +27,7 @@ import com.superbiz.agent.tool.LookupKnowledgeTool; import java.util.List; import java.util.Optional; +import java.util.UUID; /** * AI Ops 智能运维服务 @@ -48,6 +56,12 @@ public class AiOpsService { @Autowired private AiOpsPromptProperties promptProperties; + @Autowired + private DiagnosisSessionRepository diagnosisSessionRepository; + + @Autowired + private AgentStepRepository agentStepRepository; + /** * 执行 AI Ops 告警分析流程 * @@ -59,34 +73,65 @@ public class AiOpsService { public Optional executeAiOpsAnalysis(ChatModel chatModel, ToolCallback[] toolCallbacks) throws GraphRunnerException { logger.info("开始执行 AI Ops 多 Agent 协作流程"); - // 构建 Planner 和 Executor Agent - ReactAgent plannerAgent = buildPlannerAgent(chatModel, toolCallbacks); - ReactAgent executorAgent = buildExecutorAgent(chatModel, toolCallbacks); + String sessionId = UUID.randomUUID().toString().substring(0, 8); + long startTime = System.currentTimeMillis(); - // 构建 Supervisor Agent - SupervisorAgent supervisorAgent = SupervisorAgent.builder() - .name("ai_ops_supervisor") - .description("负责调度 Planner 与 Executor 的多 Agent 控制器") - .model(chatModel) - .systemPrompt(promptProperties.getSupervisor()) - .subAgents(List.of(plannerAgent, executorAgent)) + // 创建诊断会话 + DiagnosisSession session = DiagnosisSession.builder() + .sessionId(sessionId) + .query("AI Ops 告警分析") + .status("RUNNING") + .agentFlow("AI_OPS") .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 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(); - // 添加调试代码 - if (stateOptional.isPresent()) { - OverAllState state = stateOptional.get(); - logger.debug("Final State Keys: {}", state.data().keySet()); // 打印所有 key - logger.debug("Planner Plan: {}", state.value("planner_plan")); - logger.debug("Executor Feedback: {}", state.value("executor_feedback")); + String taskPrompt = "你是企业级 SRE,接到了自动化告警排查任务。请结合工具调用,执行**规划→执行→再规划**的闭环,并最终按照固定模板输出《告警分析报告》。禁止编造虚假数据,如连续多次查询失败需诚实反馈无法完成的原因。"; + + logger.info("调用 Supervisor Agent 开始编排..."); + + Optional stateOptional = supervisorAgent.invoke(taskPrompt); + + 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()) .methodTools(buildMethodToolsArray()) .tools(toolCallbacks) + .hooks(new AgentLoggingHook(agentStepRepository, "planner")) .outputKey("planner_plan") .build(); } @@ -139,6 +185,7 @@ public class AiOpsService { .systemPrompt(promptProperties.getExecutor()) .methodTools(buildMethodToolsArray()) .tools(toolCallbacks) + .hooks(new AgentLoggingHook(agentStepRepository, "executor")) .outputKey("executor_feedback") .build(); } @@ -157,4 +204,26 @@ public class AiOpsService { return new Object[]{dateTimeTools, lookupKnowledgeTool, queryMetricsTools}; } } + + /** 从 agent_step 汇总指标回填 diagnosis_session */ + private void backfillSessionMetrics(DiagnosisSession session) { + try { + List 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); + } + } } diff --git a/src/main/java/com/superbiz/agent/service/ChatService.java b/src/main/java/com/superbiz/agent/service/ChatService.java index cb04185..8ba483d 100644 --- a/src/main/java/com/superbiz/agent/service/ChatService.java +++ b/src/main/java/com/superbiz/agent/service/ChatService.java @@ -1,24 +1,40 @@ 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.flow.agent.SupervisorAgent; import com.alibaba.cloud.ai.graph.exception.GraphRunnerException; import com.superbiz.agent.agent.tool.DateTimeTools; import com.superbiz.agent.agent.tool.InternalDocsTools; import com.superbiz.agent.agent.tool.QueryLogsTools; 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.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.LoggerFactory; +import org.springframework.ai.chat.messages.AssistantMessage; import org.springframework.ai.chat.model.ChatModel; import org.springframework.ai.tool.ToolCallback; import org.springframework.ai.tool.ToolCallbackProvider; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.io.ClassPathResource; import org.springframework.stereotype.Service; +import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Map; +import java.util.Optional; +import java.util.UUID; /** * 聊天服务 @@ -50,6 +66,37 @@ public class ChatService { @Autowired 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 */ @@ -177,7 +224,7 @@ public class ChatService { .systemPrompt(systemPrompt) .methodTools(buildMethodToolsArray()) .tools(getToolCallbacks()) - .hooks(new AgentLoggingHook()) // 添加日志 Hook + .hooks(new AgentLoggingHook(agentStepRepository, "intelligent_assistant")) .build(); } @@ -191,16 +238,201 @@ public class ChatService { logger.info("========================================"); logger.info("📝 用户问题: {}", question); + String sessionId = UUID.randomUUID().toString().substring(0, 8); 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); - logger.info("📏 输出长度: {} 字符", answer.length()); - logger.info("========================================"); + // 设置 ThreadLocal 上下文(LookupKnowledgeTool 通过此获取 sessionId) + SessionContextHolder.setSessionId(sessionId); - 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> 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> 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 stateOptional = supervisor.invoke(question); + long duration = System.currentTimeMillis() - startTime; + + String answer = null; + if (stateOptional.isPresent()) { + // 从 state 中提取 Executor 的最终输出 + OverAllState state = stateOptional.get(); + Optional 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> history) { + StringBuilder prompt = new StringBuilder(chatPlannerPrompt); + if (!history.isEmpty()) { + prompt.append("\n\n--- 对话历史 ---\n"); + for (Map 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> history) { + StringBuilder prompt = new StringBuilder(chatExecutorPrompt); + if (!history.isEmpty()) { + prompt.append("\n\n--- 对话历史 ---\n"); + for (Map 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 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); + } } } diff --git a/src/main/java/com/superbiz/agent/tool/LookupKnowledgeTool.java b/src/main/java/com/superbiz/agent/tool/LookupKnowledgeTool.java index b54a035..17e4440 100644 --- a/src/main/java/com/superbiz/agent/tool/LookupKnowledgeTool.java +++ b/src/main/java/com/superbiz/agent/tool/LookupKnowledgeTool.java @@ -1,8 +1,11 @@ package com.superbiz.agent.tool; +import com.superbiz.agent.domain.entity.ToolInvocation; import com.superbiz.agent.dto.*; +import com.superbiz.agent.repository.ToolInvocationRepository; import com.superbiz.agent.service.KnowledgeIndexService; import com.superbiz.agent.service.VectorSearchService; +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; @@ -25,6 +28,9 @@ public class LookupKnowledgeTool { @Autowired private VectorSearchService vectorSearchService; + @Autowired + private ToolInvocationRepository toolInvocationRepository; + /** * 查询知识库文档 * @@ -134,9 +140,120 @@ public class LookupKnowledgeTool { log.info("========================================"); + // 记录 tool_invocation(持久化检索明细) + saveToolInvocation(query, l0Matches, l1Results, highConfidence, startTime, result); + return result; } + /** + * 保存工具调用明细到 tool_invocation 表 + */ + private void saveToolInvocation(String query, List l0Matches, + List 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"); + } + /** * 组装查询结果 * diff --git a/src/main/java/com/superbiz/agent/util/QuestionComplexity.java b/src/main/java/com/superbiz/agent/util/QuestionComplexity.java new file mode 100644 index 0000000..f9183e3 --- /dev/null +++ b/src/main/java/com/superbiz/agent/util/QuestionComplexity.java @@ -0,0 +1,44 @@ +package com.superbiz.agent.util; + +import java.util.List; + +/** + * 问题复杂度判断 + * 用于决定使用单 Agent 还是多 Agent(Planner + Executor)处理 + */ +public class QuestionComplexity { + + /** 复杂问题关键词 — 需要多步分析、排查、根因定位 */ + private static final List COMPLEX_KEYWORDS = List.of( + "排查", "分析", "为什么", "根因", "调查", "对比", "影响范围", + "原因", "故障", "告警", "诊断", "链路", "流程", "步骤", + "root cause", "troubleshoot", "investigate" + ); + + /** 极简问题关键词 — 快速回答,无需多 Agent */ + private static final List 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; + } +} diff --git a/src/main/java/com/superbiz/agent/util/SessionContextHolder.java b/src/main/java/com/superbiz/agent/util/SessionContextHolder.java new file mode 100644 index 0000000..58bfd37 --- /dev/null +++ b/src/main/java/com/superbiz/agent/util/SessionContextHolder.java @@ -0,0 +1,39 @@ +package com.superbiz.agent.util; + +/** + * 会话上下文持有者(基于 ThreadLocal) + *

+ * 用于在执行链路中传递 sessionId 和 agentName,覆盖 AgentLoggingHook 和 + * LookupKnowledgeTool 等无法直接通过 RunnableConfig 获取上下文的组件。 + *

+ * 使用规范: + * 1. 调用方(ChatService/AiOpsService)在 Agent 执行前调用 setSessionId() 和 setAgentName() + * 2. AgentLoggingHook 和工具类通过 getSessionId() / getAgentName() 读取 + * 3. 必须在 finally 块中调用 clear(),防止内存泄漏和线程污染 + */ +public class SessionContextHolder { + + private static final ThreadLocal SESSION_ID = new ThreadLocal<>(); + private static final ThreadLocal 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(); + } +} diff --git a/src/main/resources/db/migration/V005__create_session_storage.sql b/src/main/resources/db/migration/V005__create_session_storage.sql new file mode 100644 index 0000000..a64cf1b --- /dev/null +++ b/src/main/resources/db/migration/V005__create_session_storage.sql @@ -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='工具调用明细表'; diff --git a/src/main/resources/db/migration/V006__fix_agent_step_json_to_text.sql b/src/main/resources/db/migration/V006__fix_agent_step_json_to_text.sql new file mode 100644 index 0000000..415250b --- /dev/null +++ b/src/main/resources/db/migration/V006__fix_agent_step_json_to_text.sql @@ -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 '模型输出摘要(含工具调用决策)'; diff --git a/src/main/resources/db/migration/V007__drop_diagnosis_record.sql b/src/main/resources/db/migration/V007__drop_diagnosis_record.sql new file mode 100644 index 0000000..97d7a89 --- /dev/null +++ b/src/main/resources/db/migration/V007__drop_diagnosis_record.sql @@ -0,0 +1,2 @@ +-- V007: 删除旧的 diagnosis_record 表(已被 diagnosis_session + agent_step + tool_invocation 替代) +DROP TABLE IF EXISTS diagnosis_record; diff --git a/src/main/resources/prompts/chat-executor-prompt.md b/src/main/resources/prompts/chat-executor-prompt.md new file mode 100644 index 0000000..b3c791a --- /dev/null +++ b/src/main/resources/prompts/chat-executor-prompt.md @@ -0,0 +1,12 @@ +你是任务执行器。执行 Planner 分配给你的具体步骤,并及时反馈结果。 + +## 职责 +- 按步骤执行具体的查询任务 +- 使用知识库查询、日志查询等工具获取信息 +- 将执行结果汇总,给出完整的最终答案 + +## 规则 +- 按顺序执行,不可跳过步骤 +- 所有需要外部信息的地方,都必须调用对应的工具 +- 不要凭记忆回答,必须基于工具返回的真实数据 +- 执行完成后,综合所有结果给出完整的答案 diff --git a/src/main/resources/prompts/chat-planner-prompt.md b/src/main/resources/prompts/chat-planner-prompt.md new file mode 100644 index 0000000..66752f2 --- /dev/null +++ b/src/main/resources/prompts/chat-planner-prompt.md @@ -0,0 +1,20 @@ +你是智能任务规划器。分析用户的问题,拆解为具体的执行步骤。 + +## 职责 +- 分析用户问题,拆解为可执行的步骤列表 +- **你不能调用任何工具**,你的职责是制定计划,不是执行 +- 输出 JSON 格式的计划,不输出其他内容 + +## 输出格式 + +```json +{ + "plan": ["步骤1描述", "步骤2描述", "步骤3描述"], + "reasoning": "规划思路说明" +} +``` + +## 规则 +- 每个步骤应该是一个可以独立执行的任务 +- 步骤要具体可操作,不要模糊 +- 如果问题需要查知识库,明确在步骤中说明要查什么 diff --git a/src/test/java/com/superbiz/agent/repository/AgentStepRepositoryTest.java b/src/test/java/com/superbiz/agent/repository/AgentStepRepositoryTest.java new file mode 100644 index 0000000..5af9584 --- /dev/null +++ b/src/test/java/com/superbiz/agent/repository/AgentStepRepositoryTest.java @@ -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 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); + } +} diff --git a/src/test/java/com/superbiz/agent/repository/DiagnosisRecordRepositoryTest.java b/src/test/java/com/superbiz/agent/repository/DiagnosisRecordRepositoryTest.java deleted file mode 100644 index 1ffac11..0000000 --- a/src/test/java/com/superbiz/agent/repository/DiagnosisRecordRepositoryTest.java +++ /dev/null @@ -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 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 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 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 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 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 deleted = repository.findById(id); - assertFalse(deleted.isPresent()); - System.out.println("✓ 删除记录成功"); - } -} diff --git a/src/test/java/com/superbiz/agent/repository/DiagnosisSessionRepositoryTest.java b/src/test/java/com/superbiz/agent/repository/DiagnosisSessionRepositoryTest.java new file mode 100644 index 0000000..199876d --- /dev/null +++ b/src/test/java/com/superbiz/agent/repository/DiagnosisSessionRepositoryTest.java @@ -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 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()); + } +} diff --git a/src/test/java/com/superbiz/agent/repository/ToolInvocationRepositoryTest.java b/src/test/java/com/superbiz/agent/repository/ToolInvocationRepositoryTest.java new file mode 100644 index 0000000..137e2b9 --- /dev/null +++ b/src/test/java/com/superbiz/agent/repository/ToolInvocationRepositoryTest.java @@ -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 bySession = repository.findBySessionId(sessionId); + assertEquals(2, bySession.size()); + + List byTool = repository.findByToolName("lookup_knowledge"); + assertFalse(byTool.isEmpty()); + + List byBoth = repository.findBySessionIdAndToolName(sessionId, "lookup_knowledge"); + assertEquals(1, byBoth.size()); + assertEquals("L0", byBoth.get(0).getRetrievalLayer()); + } +}