Merge branch 'emdash/afraid-geese-carry-h5718' into refactor/mvp1.0
# Conflicts: # devflow/index.md
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
# Frontend Design — Complete Guidance
|
||||
|
||||
This document provides a comprehensive framework for creating visually distinctive, non-templated UI designs. Here's the full breakdown:
|
||||
|
||||
## Foundational Approach
|
||||
|
||||
Act as the design lead for a studio known for unique client identities — the client has already turned down template-like proposals. Every choice about palette, typography, and layout must be specific to the brief, including "one real aesthetic risk you can justify."
|
||||
|
||||
## Grounding in Subject Matter
|
||||
|
||||
If the brief is vague about the product or subject, pin it down yourself: name the subject, its audience, and the page's single job. Draw inspiration from "the subject's own world, its materials, instruments, artifacts, and vernacular." Use any known context about the human's preferences or past designs as hints.
|
||||
|
||||
## Design Principles
|
||||
|
||||
- **Hero as thesis**: Open with "the most characteristic thing in the subject's world" — avoid default choices like a big number with a small label and gradient accent unless truly optimal.
|
||||
- **Typography**: Pair display and body faces deliberately, not from your usual repertoire. Set a clear type scale with intentional weights, widths, and spacing. "Make the type treatment itself a memorable part of the design."
|
||||
- **Structure as information**: Numbering, eyebrows, dividers must encode something true about the content. Question whether numbered markers (01/02/03) actually make sense before using them — only appropriate for real sequences.
|
||||
- **Motion**: Consider where animation serves the subject. "An orchestrated moment usually lands harder than scattered effects." Sometimes less is better to avoid an AI-generated feel.
|
||||
- **Complexity**: Match execution to the vision — maximalist needs elaborate execution, minimal needs precision.
|
||||
- **Content**: Come up with copy if the brief lacks it. Poor copy makes a design feel as templated as poor layout.
|
||||
|
||||
## AI-Generated Design Traps
|
||||
|
||||
Three common AI-default looks to watch for: (1) warm cream background (~#F4F1EA) with serif display and terracotta accent; (2) near-black with bright acid-green or vermilion; (3) broadsheet layout with hairline rules, zero border-radius, and dense columns. "All three are legitimate for some briefs, but they are defaults rather than choices." Where the brief leaves an axis free, don't spend that freedom on a default.
|
||||
|
||||
## Two-Pass Process
|
||||
|
||||
**Pass 1 — Plan**: Create a compact token system:
|
||||
|
||||
1. **Color**: 4–6 named hex values
|
||||
2. **Type**: Characterful display face (used with restraint), complementary body face, utility face for captions/data
|
||||
3. **Layout**: One-sentence prose descriptions + ASCII wireframes
|
||||
4. **Signature**: The single unique element the page will be remembered by
|
||||
|
||||
Review the plan against the brief. If any part reads like what you'd produce for any similar page, revise it. Only then write code.
|
||||
|
||||
**Pass 2 — Build**: Follow the revised plan exactly. Watch for CSS selector specificity conflicts (e.g., `.section` and `.cta` fighting over padding/margins). Do most planning internally, only sharing ideas when confident.
|
||||
|
||||
## Restraint & Self-Critique
|
||||
|
||||
"Spend your boldness in one place" — let the signature element be the one memorable thing; keep everything else quiet. "Not taking a risk can be a risk itself!" Build responsively down to mobile, with visible keyboard focus and reduced motion respected. Critique as you build. Follow Chanel's advice: before finishing, remove one accessory. Jot notes about what you've tried to avoid repeating yourself.
|
||||
|
||||
## Writing in Design
|
||||
|
||||
Words exist to make the design understandable and usable — they're "design material, not decoration." Write from the end user's perspective, naming things by what people control and recognize, never by how the system is built.
|
||||
|
||||
- Use active voice as default
|
||||
- A control should say exactly what happens: "Save changes," not "Submit"
|
||||
- Maintain consistent vocabulary throughout flows (button says "Publish," toast says "Published")
|
||||
- Treat errors as guidance, not mood — explain what went wrong and how to fix it
|
||||
- Empty screens are invitations to act
|
||||
- Keep the register conversational: "plain verbs, sentence case, no filler"
|
||||
- Let each element do exactly one job — "a label labels, an example demonstrates"
|
||||
|
||||
## License
|
||||
|
||||
Apache License 2.0 — see LICENSE.txt
|
||||
@@ -0,0 +1,233 @@
|
||||
# AI Ops Prompt 配置化 & LookupKnowledgeTool 集成
|
||||
|
||||
**日期**: 2026-06-24
|
||||
**类型**: 功能增强 + 架构优化
|
||||
**影响范围**: AI Ops 服务
|
||||
|
||||
---
|
||||
|
||||
## 一、变更背景
|
||||
|
||||
### 1.1 问题
|
||||
|
||||
- **硬编码 Prompt**:Planner、Executor、Supervisor 的系统提示词硬编码在 `AiOpsService.java` 中,难以维护和版本控制
|
||||
- **缺少知识库精确检索**:现有 `InternalDocsTools` 只支持 L1 语义检索(200-500ms),对于错误码、配置项等精确关键词查询效率较低
|
||||
|
||||
### 1.2 解决方案
|
||||
|
||||
1. **Prompt 配置化**:将所有 Agent 的 Prompt 抽取到 `prompts/ai-ops-prompts.yml` 配置文件
|
||||
2. **集成 L0+L1 混合检索**:引入 `LookupKnowledgeTool`,支持精确关键词匹配(< 10ms)+ 语义检索补充
|
||||
|
||||
---
|
||||
|
||||
## 二、架构变更
|
||||
|
||||
### 2.1 Prompt 配置化架构
|
||||
|
||||
```
|
||||
AiOpsService
|
||||
↓ 注入
|
||||
AiOpsPromptProperties (配置类)
|
||||
↓ @PostConstruct 加载
|
||||
ClassPathResource 读取 Markdown 文件
|
||||
↓ 读取
|
||||
prompts/
|
||||
├── planner-prompt.md
|
||||
├── executor-prompt.md
|
||||
└── supervisor-prompt.md
|
||||
```
|
||||
|
||||
**优点**:
|
||||
- 易于维护:Prompt 修改不需要重新编译
|
||||
- 格式友好:Markdown 格式支持代码块、表格,无 YAML 转义问题
|
||||
- 版本控制:配置文件独立管理
|
||||
- 易于扩展:后续可按环境区分(dev/prod)
|
||||
|
||||
### 2.2 工具层增强
|
||||
|
||||
```
|
||||
原有工具:
|
||||
- queryInternalDocs (纯 L1 语义检索,200-500ms)
|
||||
|
||||
新增工具:
|
||||
- lookup_knowledge (L0 精确匹配 + L1 补充,< 10ms 高置信度)
|
||||
```
|
||||
|
||||
**使用策略**:
|
||||
- 精确关键词(错误码、配置项)→ `lookup_knowledge`,未找到时降级到 `queryInternalDocs`
|
||||
- 模糊概念、故障流程 → 直接使用 `queryInternalDocs`
|
||||
|
||||
---
|
||||
|
||||
## 三、核心改动
|
||||
|
||||
### 3.1 新增文件
|
||||
|
||||
#### `AiOpsPromptProperties.java`
|
||||
```java
|
||||
@Configuration
|
||||
public class AiOpsPromptProperties {
|
||||
private String planner;
|
||||
private String executor;
|
||||
private String supervisor;
|
||||
|
||||
@PostConstruct
|
||||
public void loadPrompts() {
|
||||
planner = loadPromptFromFile("prompts/planner-prompt.md");
|
||||
executor = loadPromptFromFile("prompts/executor-prompt.md");
|
||||
supervisor = loadPromptFromFile("prompts/supervisor-prompt.md");
|
||||
}
|
||||
|
||||
private String loadPromptFromFile(String path) throws IOException {
|
||||
ClassPathResource resource = new ClassPathResource(path);
|
||||
return new String(resource.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### `prompts/*.md`
|
||||
三个独立的 Markdown 文件,包含 Agent 的完整系统提示词:
|
||||
- `planner-prompt.md` - Planner Agent 系统提示词
|
||||
- `executor-prompt.md` - Executor Agent 系统提示词(含工具选择指南)
|
||||
- `supervisor-prompt.md` - Supervisor Agent 系统提示词
|
||||
|
||||
### 3.2 修改文件
|
||||
|
||||
#### `AiOpsService.java`
|
||||
|
||||
**注入新组件**:
|
||||
```java
|
||||
@Autowired
|
||||
private LookupKnowledgeTool lookupKnowledgeTool;
|
||||
|
||||
@Autowired
|
||||
private AiOpsPromptProperties promptProperties;
|
||||
```
|
||||
|
||||
**使用配置化 Prompt**:
|
||||
```java
|
||||
// 原来
|
||||
.systemPrompt(buildPlannerPrompt())
|
||||
|
||||
// 改为
|
||||
.systemPrompt(promptProperties.getPlanner())
|
||||
```
|
||||
|
||||
**添加工具到工具数组**:
|
||||
```java
|
||||
return new Object[]{
|
||||
dateTimeTools,
|
||||
internalDocsTools,
|
||||
queryMetricsTools,
|
||||
lookupKnowledgeTool // 新增
|
||||
};
|
||||
```
|
||||
|
||||
**删除方法**:
|
||||
- `buildPlannerPrompt()`
|
||||
- `buildExecutorPrompt()`
|
||||
- `buildSupervisorSystemPrompt()`
|
||||
|
||||
---
|
||||
|
||||
## 四、Executor Prompt 变更详情
|
||||
|
||||
### 4.1 新增工具选择指南
|
||||
|
||||
```yaml
|
||||
- 根据查询内容选择合适的工具:
|
||||
* 精确关键词(错误码、配置项名称)→ 优先使用 lookup_knowledge,未找到时降级到 queryInternalDocs
|
||||
* 模糊概念、故障流程 → 直接使用 queryInternalDocs
|
||||
* 告警数据 → queryPrometheusAlerts
|
||||
* 日志数据 → queryLogs
|
||||
```
|
||||
|
||||
### 4.2 降级策略
|
||||
|
||||
关键改进:明确了 `lookup_knowledge` 未找到时的降级策略。
|
||||
|
||||
**流程**:
|
||||
```
|
||||
1. Planner: "查询 ERR_TIMEOUT 定义"
|
||||
2. Executor: 调用 lookup_knowledge("ERR_TIMEOUT")
|
||||
3a. 如果 found=true, confidence=high → 使用 primary.content
|
||||
3b. 如果 found=false → 自动降级到 queryInternalDocs("ERR_TIMEOUT 超时错误")
|
||||
4. 返回 feedback 给 Planner
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、兼容性说明
|
||||
|
||||
### 5.1 向后兼容
|
||||
|
||||
✅ **完全兼容**:
|
||||
- 现有工具调用逻辑不变
|
||||
- 3-Agent 协同模式不变
|
||||
- Planner/Executor/Supervisor 的职责边界不变
|
||||
|
||||
### 5.2 新增依赖
|
||||
|
||||
- `LookupKnowledgeTool` 依赖 `KnowledgeIndexService` 和 `VectorSearchService`
|
||||
- 需要 `knowledge_base/` 目录存在(已在 `application.yml` 中配置)
|
||||
|
||||
---
|
||||
|
||||
## 六、验证清单
|
||||
|
||||
### 6.1 编译验证
|
||||
|
||||
```bash
|
||||
mvn clean compile -DskipTests
|
||||
```
|
||||
|
||||
✅ **结果**: BUILD SUCCESS
|
||||
|
||||
### 6.2 运行时验证(待完成)
|
||||
|
||||
- [ ] 启动应用,验证 Prompt 配置加载成功
|
||||
- [ ] 触发 AI Ops 流程,验证 `lookup_knowledge` 工具可调用
|
||||
- [ ] 测试精确关键词查询(如 "ERR_TIMEOUT")
|
||||
- [ ] 测试降级策略(查询不存在的关键词)
|
||||
|
||||
---
|
||||
|
||||
## 七、后续工作
|
||||
|
||||
### 7.1 知识库内容准备
|
||||
|
||||
当前 `knowledge_base/` 目录需要补充文档:
|
||||
- 错误码定义(支付网关、订单系统等)
|
||||
- 配置最佳实践(Redis、HikariCP、Flyway 等)
|
||||
- 故障排查流程
|
||||
|
||||
**文档格式示例**:
|
||||
```markdown
|
||||
---
|
||||
title: 支付网关错误码定义
|
||||
keywords: [ERR_TIMEOUT, 超时, 支付网关]
|
||||
summary: 记录了支付网关所有核心错误码的含义及排查方向
|
||||
category: api
|
||||
---
|
||||
|
||||
# 支付网关错误码定义
|
||||
|
||||
## ERR_TIMEOUT
|
||||
...
|
||||
```
|
||||
|
||||
### 7.2 Prompt 优化
|
||||
|
||||
基于实际运行反馈,持续优化 `prompts/ai-ops-prompts.yml` 中的提示词。
|
||||
|
||||
### 7.3 可观测性增强
|
||||
|
||||
- 监控 `lookup_knowledge` 的调用频率和命中率
|
||||
- 记录降级场景(L0 未找到 → L1 补充)
|
||||
|
||||
---
|
||||
|
||||
## 八、参考文档
|
||||
|
||||
- [知识库检索架构说明](../mvp/architecture/knowledge-retrieval-architecture.md)
|
||||
- [AI Ops 核心设计 Essence 报告](../docs/learning/01-AI-Ops-核心设计-Essence报告.md)
|
||||
@@ -0,0 +1,100 @@
|
||||
# Prompt 配置化改进总结
|
||||
|
||||
**日期**: 2026-06-24
|
||||
**改进**: 从 YAML 配置改为 Markdown 文件
|
||||
|
||||
---
|
||||
|
||||
## 改进原因
|
||||
|
||||
YAML 格式存在以下问题:
|
||||
1. **多行字符串缩进敏感**:容易出现格式错误
|
||||
2. **转义字符复杂**:代码块、表格需要转义处理
|
||||
3. **可读性差**:长文本在 YAML 中难以阅读和维护
|
||||
|
||||
Markdown 格式优势:
|
||||
- ✅ 原生支持代码块、表格、列表
|
||||
- ✅ 无需转义,所见即所得
|
||||
- ✅ 版本控制 diff 更清晰
|
||||
- ✅ 编辑器语法高亮支持好
|
||||
|
||||
---
|
||||
|
||||
## 最终方案
|
||||
|
||||
### 文件结构
|
||||
```
|
||||
src/main/resources/prompts/
|
||||
├── planner-prompt.md # Planner Agent 系统提示词
|
||||
├── executor-prompt.md # Executor Agent 系统提示词
|
||||
└── supervisor-prompt.md # Supervisor Agent 系统提示词
|
||||
```
|
||||
|
||||
### 加载方式
|
||||
```java
|
||||
@Configuration
|
||||
public class AiOpsPromptProperties {
|
||||
|
||||
@PostConstruct
|
||||
public void loadPrompts() {
|
||||
planner = loadPromptFromFile("prompts/planner-prompt.md");
|
||||
executor = loadPromptFromFile("prompts/executor-prompt.md");
|
||||
supervisor = loadPromptFromFile("prompts/supervisor-prompt.md");
|
||||
}
|
||||
|
||||
private String loadPromptFromFile(String path) throws IOException {
|
||||
ClassPathResource resource = new ClassPathResource(path);
|
||||
return new String(resource.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 使用方式
|
||||
```java
|
||||
@Autowired
|
||||
private AiOpsPromptProperties promptProperties;
|
||||
|
||||
// 直接使用
|
||||
.systemPrompt(promptProperties.getPlanner())
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 编译验证
|
||||
|
||||
```bash
|
||||
mvn clean compile -DskipTests
|
||||
```
|
||||
|
||||
✅ **结果**: BUILD SUCCESS
|
||||
|
||||
---
|
||||
|
||||
## 完整改动清单
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `AiOpsService.java` | 注入 `LookupKnowledgeTool` + `AiOpsPromptProperties` |
|
||||
| `AiOpsPromptProperties.java` | 从 Markdown 文件加载 Prompt(使用 `@PostConstruct`)|
|
||||
| `prompts/planner-prompt.md` | 新增:Planner 系统提示词 |
|
||||
| `prompts/executor-prompt.md` | 新增:Executor 系统提示词(含工具选择指南)|
|
||||
| `prompts/supervisor-prompt.md` | 新增:Supervisor 系统提示词 |
|
||||
| ~~`YamlPropertySourceFactory.java`~~ | 已删除(不再需要)|
|
||||
| ~~`prompts/ai-ops-prompts.yml`~~ | 已删除(改用 Markdown)|
|
||||
|
||||
---
|
||||
|
||||
## Executor Prompt 关键改进
|
||||
|
||||
新增工具选择指南:
|
||||
```markdown
|
||||
- 根据查询内容选择合适的工具:
|
||||
* 精确关键词(错误码、配置项名称)→ 优先使用 lookup_knowledge,未找到时降级到 queryInternalDocs
|
||||
* 模糊概念、故障流程 → 直接使用 queryInternalDocs
|
||||
* 告警数据 → queryPrometheusAlerts
|
||||
* 日志数据 → queryLogs
|
||||
```
|
||||
|
||||
降级策略:
|
||||
- `lookup_knowledge` 未找到 → 自动降级到 `queryInternalDocs`
|
||||
- 确保查询不会因为知识库缺少内容而失败
|
||||
@@ -0,0 +1,469 @@
|
||||
# 知识库初始化 API 使用文档
|
||||
|
||||
## 概述
|
||||
|
||||
提供了知识库批量初始化接口,用于将 `knowledge_base` 目录下的所有 Markdown 文档导入到数据库和向量索引(L0 + L1)。
|
||||
|
||||
**功能特点**:
|
||||
1. ✅ **批量扫描**:递归扫描 knowledge_base 目录下所有 .md 文件
|
||||
2. ✅ **自动去重**:基于文件路径检查,避免重复导入
|
||||
3. ✅ **数据入库**:保存文档元数据到 MySQL
|
||||
4. ✅ **L0 索引**:自动加入内存精确匹配索引
|
||||
5. ✅ **L1 索引**:文档分块并上传到 Milvus 向量数据库
|
||||
|
||||
---
|
||||
|
||||
## API 接口
|
||||
|
||||
### 1. 初始化知识库
|
||||
|
||||
**端点**:
|
||||
```
|
||||
POST /api/knowledge/init?force=false
|
||||
```
|
||||
|
||||
**参数**:
|
||||
- `force`(可选):是否强制重新导入,跳过去重检查
|
||||
- `false`(默认):跳过已存在的文档
|
||||
- `true`:强制重新导入所有文档
|
||||
|
||||
**请求示例**:
|
||||
```bash
|
||||
# 首次导入(去重模式)
|
||||
curl -X POST http://localhost:9900/api/knowledge/init
|
||||
|
||||
# 强制重新导入
|
||||
curl -X POST http://localhost:9900/api/knowledge/init?force=true
|
||||
```
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "知识库初始化完成",
|
||||
"scanned": 6,
|
||||
"skipped": 0,
|
||||
"inserted": 6,
|
||||
"failed": 0,
|
||||
"details": {
|
||||
"api/payment-errors.md": "导入成功(L0+L1)",
|
||||
"domain/spring-ai-tool-best-practices.md": "导入成功(L0+L1)",
|
||||
"infrastructure/flyway-best-practices.md": "导入成功(L0+L1)",
|
||||
"infrastructure/mysql-connection-pool.md": "导入成功(L0+L1)",
|
||||
"infrastructure/redis-config.md": "导入成功(L0+L1)",
|
||||
"troubleshooting/fault-diagnosis-process.md": "导入成功(L0+L1)"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明**:
|
||||
- `scanned`:扫描到的文件总数
|
||||
- `skipped`:跳过的文件数量(已存在)
|
||||
- `inserted`:成功导入的文件数量
|
||||
- `failed`:失败的文件数量
|
||||
- `details`:每个文件的处理结果详情
|
||||
|
||||
---
|
||||
|
||||
### 2. 查询知识库统计
|
||||
|
||||
**端点**:
|
||||
```
|
||||
GET /api/knowledge/stats
|
||||
```
|
||||
|
||||
**请求示例**:
|
||||
```bash
|
||||
curl http://localhost:9900/api/knowledge/stats
|
||||
```
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"totalDocuments": 6,
|
||||
"totalVectors": 48,
|
||||
"categories": {
|
||||
"api": 1,
|
||||
"domain": 1,
|
||||
"infrastructure": 3,
|
||||
"troubleshooting": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明**:
|
||||
- `totalDocuments`:数据库中的文档总数
|
||||
- `totalVectors`:Milvus 中的向量总数(chunk 数量)
|
||||
- `categories`:按分类统计的文档数量
|
||||
|
||||
---
|
||||
|
||||
## 使用场景
|
||||
|
||||
### 场景 1:项目启动时初始化
|
||||
|
||||
```bash
|
||||
# 1. 启动应用
|
||||
mvn spring-boot:run
|
||||
|
||||
# 2. 等待应用启动完成(约 10 秒)
|
||||
|
||||
# 3. 调用初始化接口
|
||||
curl -X POST http://localhost:9900/api/knowledge/init
|
||||
|
||||
# 4. 查看结果
|
||||
# 日志输出:知识库初始化完成: 扫描=6, 跳过=0, 新增=6, 失败=0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 场景 2:添加新文档后重新初始化
|
||||
|
||||
```bash
|
||||
# 1. 添加新文档到 knowledge_base 目录
|
||||
echo "---
|
||||
title: 新文档
|
||||
keywords: [测试, test]
|
||||
summary: 这是一个测试文档
|
||||
category: test
|
||||
---
|
||||
|
||||
# 新文档内容
|
||||
" > knowledge_base/test/new-doc.md
|
||||
|
||||
# 2. 调用初始化接口(去重模式)
|
||||
curl -X POST http://localhost:9900/api/knowledge/init
|
||||
|
||||
# 3. 查看结果
|
||||
# 只会导入新文档,跳过已存在的 6 个文档
|
||||
# 响应: scanned=7, skipped=6, inserted=1, failed=0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 场景 3:强制重新导入所有文档
|
||||
|
||||
```bash
|
||||
# 适用场景:
|
||||
# - 数据库被清空,需要重新导入
|
||||
# - 文档内容有更新,需要刷新
|
||||
# - 索引损坏,需要重建
|
||||
|
||||
curl -X POST http://localhost:9900/api/knowledge/init?force=true
|
||||
|
||||
# 响应: scanned=6, skipped=0, inserted=6, failed=0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 去重机制
|
||||
|
||||
### 去重依据
|
||||
- **文件路径**:相对于 `knowledge_base` 目录的相对路径
|
||||
- 示例:`api/payment-errors.md`
|
||||
|
||||
### 去重逻辑
|
||||
```
|
||||
if (!force && existingFilePaths.contains(relativePath)) {
|
||||
跳过该文档
|
||||
} else {
|
||||
导入该文档
|
||||
}
|
||||
```
|
||||
|
||||
### 注意事项
|
||||
1. **文件移动会被视为新文档**:
|
||||
```bash
|
||||
# 移动前:api/payment-errors.md
|
||||
# 移动后:errors/payment-errors.md
|
||||
# 结果:会被当作两个不同的文档
|
||||
```
|
||||
|
||||
2. **文件重命名会被视为新文档**:
|
||||
```bash
|
||||
# 重命名前:payment-errors.md
|
||||
# 重命名后:payment-error-codes.md
|
||||
# 结果:会被当作两个不同的文档
|
||||
```
|
||||
|
||||
3. **内容更新不触发重新导入**(非 force 模式):
|
||||
```bash
|
||||
# 修改文件内容后调用 init(非 force)
|
||||
# 结果:跳过该文档,数据库中仍是旧内容
|
||||
# 解决:使用 force=true 强制重新导入
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 数据存储
|
||||
|
||||
### 完整的数据流
|
||||
|
||||
```
|
||||
knowledge_base/*.md
|
||||
↓ 1. 扫描
|
||||
KnowledgeBaseInitService
|
||||
↓ 2. 解析 frontmatter
|
||||
Frontmatter (title, keywords, summary)
|
||||
↓ 3. 保存到数据库
|
||||
MySQL (api_document)
|
||||
↓ 4. 提取正文 & 分块
|
||||
DocumentChunkService
|
||||
↓ 5. 生成向量
|
||||
VectorEmbeddingService
|
||||
↓ 6. 索引到 Milvus
|
||||
Milvus (L1 向量索引)
|
||||
↓ 7. 加入内存索引
|
||||
KnowledgeIndexService (L0)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 数据库表结构(api_document)
|
||||
|
||||
| 字段 | 类型 | 说明 | 示例 |
|
||||
|------|------|------|------|
|
||||
| `id` | BIGINT | 主键 | 1 |
|
||||
| `doc_id` | VARCHAR(64) | 文档唯一标识 | uuid |
|
||||
| `file_name` | VARCHAR(256) | 文件名 | payment-errors.md |
|
||||
| `file_path` | VARCHAR(512) | 相对路径 | api/payment-errors.md |
|
||||
| `api_name` | VARCHAR(128) | 文档标题 | 支付网关错误码定义 |
|
||||
| `status` | VARCHAR(16) | 状态 | INDEXED / FAILED |
|
||||
| `chunk_count` | INT | 分块数量 | 8 |
|
||||
| `error_message` | TEXT | 错误信息 | null |
|
||||
| `metadata` | TEXT | Frontmatter JSON | {"title":"...","keywords":[...]} |
|
||||
| `file_size` | BIGINT | 文件大小(字节) | 2048 |
|
||||
| `indexed_at` | DATETIME | 索引时间 | 2026-06-25 10:00:00 |
|
||||
|
||||
### metadata JSON 结构
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "支付网关错误码定义",
|
||||
"summary": "记录了支付网关所有核心错误码的含义及排查方向",
|
||||
"category": "api",
|
||||
"keywords": ["ERR_TIMEOUT","超时","支付网关"]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Milvus 向量索引
|
||||
|
||||
每个文档会被分块(chunk)并生成向量,存储到 Milvus 集合中:
|
||||
|
||||
**Collection**: `knowledge_base_collection`
|
||||
|
||||
**字段**:
|
||||
- `doc_id`:文档 ID
|
||||
- `chunk_id`:分块 ID
|
||||
- `chunk_text`:分块文本内容
|
||||
- `embedding`:768 维向量
|
||||
- `category`:文档分类
|
||||
- `file_path`:文件路径
|
||||
|
||||
**分块策略**:
|
||||
- Chunk Size:根据 `DocumentChunkConfig` 配置(默认 500 token)
|
||||
- Overlap:重叠区域(默认 50 token)
|
||||
|
||||
---
|
||||
|
||||
## L0 内存索引
|
||||
|
||||
导入过程会自动将文档加入 `KnowledgeIndexService` 的内存索引:
|
||||
|
||||
```java
|
||||
KnowledgeEntry entry = KnowledgeEntry.builder()
|
||||
.filePath(relativePath)
|
||||
.title(title)
|
||||
.keywords(keywords)
|
||||
.summary(summary)
|
||||
.category(category)
|
||||
.build();
|
||||
knowledgeIndexService.addToIndex(entry);
|
||||
```
|
||||
|
||||
**验证 L0 索引**:
|
||||
```bash
|
||||
# 应用启动后查看日志
|
||||
grep "知识库索引加载完成" logs/application.log
|
||||
|
||||
# 输出示例:
|
||||
# [INFO] 知识库索引加载完成,共 6 个文档
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 错误处理
|
||||
|
||||
### 常见错误
|
||||
|
||||
#### 1. 目录不存在
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"message": "初始化失败: 知识库目录不存在: knowledge_base"
|
||||
}
|
||||
```
|
||||
|
||||
**解决**:
|
||||
```bash
|
||||
mkdir -p knowledge_base/api
|
||||
mkdir -p knowledge_base/infrastructure
|
||||
mkdir -p knowledge_base/domain
|
||||
mkdir -p knowledge_base/troubleshooting
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 2. 文档格式无效
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"scanned": 6,
|
||||
"inserted": 5,
|
||||
"failed": 1,
|
||||
"details": {
|
||||
"test/invalid.md": "格式无效: frontmatter 解析失败"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**原因**:
|
||||
- 缺少 frontmatter
|
||||
- YAML 格式错误
|
||||
- 缺少必填字段(title, keywords, summary)
|
||||
|
||||
**解决**:
|
||||
```markdown
|
||||
---
|
||||
title: 文档标题
|
||||
keywords: [关键词1, 关键词2]
|
||||
summary: 文档摘要
|
||||
category: api
|
||||
---
|
||||
|
||||
# 正文内容
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 问题 4: Milvus 连接失败
|
||||
|
||||
**症状**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"scanned": 6,
|
||||
"inserted": 0,
|
||||
"failed": 6,
|
||||
"details": {
|
||||
"api/payment-errors.md": "Milvus 索引失败: Connection refused"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**原因**:
|
||||
- Milvus 服务未启动
|
||||
- 网络连接问题
|
||||
- 配置错误
|
||||
|
||||
**解决**:
|
||||
```bash
|
||||
# 检查 Milvus 是否运行
|
||||
docker ps | grep milvus
|
||||
|
||||
# 检查配置
|
||||
grep milvus application.yml
|
||||
|
||||
# 启动 Milvus
|
||||
docker-compose up -d milvus-standalone
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 问题 5: 文档分块失败
|
||||
|
||||
**症状**:
|
||||
```json
|
||||
{
|
||||
"details": {
|
||||
"test/large-doc.md": "Milvus 索引失败: Document too large"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**原因**:
|
||||
- 文档内容过大
|
||||
- 分块配置不当
|
||||
|
||||
**解决**:
|
||||
- 检查 `DocumentChunkConfig` 配置
|
||||
- 调整 chunk size 和 overlap
|
||||
|
||||
---
|
||||
|
||||
#### 3. 文档缺少标题
|
||||
```json
|
||||
{
|
||||
"details": {
|
||||
"test/no-title.md": "缺少标题"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**解决**:在 frontmatter 中添加 `title` 字段。
|
||||
|
||||
---
|
||||
|
||||
## 最佳实践
|
||||
|
||||
### ✅ 推荐做法
|
||||
|
||||
1. **首次启动后立即初始化**:
|
||||
```bash
|
||||
mvn spring-boot:run
|
||||
sleep 15 # 等待启动完成
|
||||
curl -X POST http://localhost:9900/api/knowledge/init
|
||||
```
|
||||
|
||||
2. **新增文档后增量导入**:
|
||||
```bash
|
||||
# 不使用 force,只导入新文档
|
||||
curl -X POST http://localhost:9900/api/knowledge/init
|
||||
```
|
||||
|
||||
3. **定期检查统计信息**:
|
||||
```bash
|
||||
curl http://localhost:9900/api/knowledge/stats
|
||||
```
|
||||
|
||||
4. **更新文档内容后强制刷新**:
|
||||
```bash
|
||||
curl -X POST http://localhost:9900/api/knowledge/init?force=true
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### ❌ 避免做法
|
||||
|
||||
1. **不检查响应就认为成功**:
|
||||
- 始终检查 `failed` 字段
|
||||
- 查看 `details` 了解具体失败原因
|
||||
|
||||
2. **频繁使用 force=true**:
|
||||
- 会重复插入数据(违反唯一约束)
|
||||
- 建议先清理数据库,再使用 force
|
||||
|
||||
3. **不检查文档格式就导入**:
|
||||
- 先手动验证 frontmatter 格式
|
||||
- 确保必填字段完整
|
||||
|
||||
---
|
||||
|
||||
## 相关文档
|
||||
|
||||
- **知识库使用指南**:`mvp/architecture/knowledge-retrieval-usage.md`
|
||||
- **知识库架构**:`mvp/architecture/knowledge-retrieval-architecture.md`
|
||||
- **Executor Prompt**:`src/main/resources/prompts/executor-prompt.md`
|
||||
+1
-1
@@ -17,8 +17,8 @@ Loaded modules:
|
||||
7FF9B84F0000 GDI32.dll
|
||||
7FF9B6CD0000 gdi32full.dll
|
||||
7FF9B6790000 msvcp_win.dll
|
||||
7FF9B7000000 ucrtbase.dll
|
||||
000210040000 msys-2.0.dll
|
||||
7FF9B7000000 ucrtbase.dll
|
||||
7FF9B7370000 advapi32.dll
|
||||
7FF9B8E40000 msvcrt.dll
|
||||
7FF9B85B0000 sechost.dll
|
||||
|
||||
@@ -8,3 +8,4 @@
|
||||
| 2026-06-23 | phase1-infrastructure | 基础设施/文档管理 | MySQL, Redis, Milvus, Flyway, JPA, 向量检索, 类别过滤 | archived |
|
||||
| 2026-06-24 | lookup-knowledge-integration | 知识库检索 | L0精确匹配, L1语义检索, frontmatter, 混合检索 | archived |
|
||||
| 2026-06-25 | doc-management-ui | 前端开发/文档管理 | 文档管理页面, CRUD, 状态监控, 纯静态页面, API集成 | archived |
|
||||
| 2026-06-26 | session-storage | 会话存储/可观测 | diagnosis_session, agent_step, tool_invocation, token追踪, 多Agent路由 | openspec/changes/session-storage | archived |
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# 验收记录
|
||||
|
||||
## 验证情况
|
||||
|
||||
### 静态验证
|
||||
- [x] 编译通过(`mvn compile`)
|
||||
- [x] 42 个测试全部通过(DocumentChunkService / LookupKnowledgeTool / Repository)
|
||||
- [x] 三张新表通过 Flyway 成功创建
|
||||
|
||||
### 脚本验证
|
||||
- [x] `/api/chat` — 单 Agent 正常响应,agent_step 记录正确
|
||||
- [x] `/api/chat` — 复杂问题路由到多 Agent(Planner + Executor)
|
||||
- [x] `/api/ai_ops` — 多 Agent 流程正常,planner 步骤写入 agent_step
|
||||
- [x] Tool_invocation L0/L1 检索质量明细正确
|
||||
- [x] diagnosis_session 汇总指标(total_token_count / step_count / tool_call_count)正确
|
||||
- [x] TokenTrackingChatModel 捕获实际 token 数(已验证 total=827)
|
||||
- [x] 旧 diagnosis_record 表删除成功
|
||||
|
||||
### 未验证
|
||||
- `/api/chat_stream`(SSE 流式)— 未接入 session 存储,不在本次范围,后续覆盖
|
||||
- `self_evaluation` / `feedback` — 无前端交互入口
|
||||
|
||||
## 剩余风险
|
||||
|
||||
| 风险 | 说明 |
|
||||
|------|------|
|
||||
| Token 累加 | 当前每步独立记录,汇总在 `backfillSessionMetrics`,未在 Hook 层累加 |
|
||||
| Async 优化 | 同步写 DB 在低并发下无问题,后续可引入 @Async |
|
||||
@@ -0,0 +1,21 @@
|
||||
# 会话存储体系
|
||||
|
||||
## 背景
|
||||
当前 `diagnosis_record` 单表字段耦合在"告警分析"领域,无法支撑通用会话存储。缺少 Agent 决策链维度、检索质量明细、Token 消耗等可观测指标。
|
||||
|
||||
## 目标
|
||||
将单表拆分为三表体系,覆盖 ChatService 和 AiOpsService 两个 Agent 的完整决策链记录,支撑可观测和评估。
|
||||
|
||||
## 范围
|
||||
- 新建 3 张表(diagnosis_session / agent_step / tool_invocation)
|
||||
- Flyway 迁移 + JPA Entity + Repository
|
||||
- 改造 AgentLoggingHook 持久化 agent_step
|
||||
- 改造 LookupKnowledgeTool 写入 tool_invocation
|
||||
- ChatService / AiOpsService 支持 diagnosis_session 生命周期
|
||||
- Token 用量追踪(TokenTrackingChatModel)
|
||||
- 意图识别路由(单 Agent / 多 Agent)
|
||||
- 删除旧 diagnosis_record 表
|
||||
|
||||
## 非目标
|
||||
- 不涉及 UI 层面的会话展示
|
||||
- 不涉及历史数据迁移
|
||||
@@ -0,0 +1,22 @@
|
||||
# 会话存储 — 决策记录
|
||||
|
||||
## 关键决策
|
||||
|
||||
| 决策 | 选择 | 理由 |
|
||||
|------|------|------|
|
||||
| AgentLoggingHook 创建方式 | POJO(构造注入),非 @Component | 需为 ChatService/AiOpsService 创建多个实例(不同 agentName) |
|
||||
| AiOpsService 记录粒度 | 只记子 Agent(Planner/Executor),不记 Supervisor | Supervisor 编排日志已有体现,单独记录增加噪音 |
|
||||
| sessionId 传递 | RunnableConfig.metadata(优先)+ ThreadLocal(兜底) | RunnableConfig 线程安全,异步兼容 |
|
||||
| Tool 获取 sessionId | SessionContextHolder(ThreadLocal) | Tool 不在调用链中,无法通过 RunnableConfig 获取 |
|
||||
| Token 追踪 | TokenTrackingChatModel 包装器拦截 ChatModel.call() | 框架 _TOKEN_USAGE_ 仅 stream 路径可用 |
|
||||
| Chat 复杂度路由 | 关键词 + 长度判断 | MVP 简化实现 |
|
||||
| 多 Agent Planner 无工具 | 不注入 methodTools/tools | 防止 Planner 自己执行,强制通过 Executor 执行 |
|
||||
| 旧表处理 | V007 Flyway 迁移删除 diagnosis_record | 被三表替代,不再使用 |
|
||||
|
||||
## 风险
|
||||
|
||||
| 风险 | 等级 | 说明 |
|
||||
|------|:----:|------|
|
||||
| Hook 同步写 DB | 低 | MVP 阶段数据量小,后续可异步化 |
|
||||
| token_count 依赖 ChatResponse.usage | 低 | DeepSeek 已确认返回实际用量 |
|
||||
| stream 路径 session 记录 | 低 | 当前 call 路径正常,stream 需确认 RunnableConfig 传播 |
|
||||
@@ -0,0 +1,23 @@
|
||||
# 证据记录
|
||||
|
||||
## Evidence-Driven 查证
|
||||
|
||||
### E1: AgentLoggingHook 创建方式
|
||||
- **发现**: ChatService 通过 `new AgentLoggingHook()` 创建,非 Spring 管理,无法注入 Repository
|
||||
- **结论**: 需要改造为可注入的 POJO(构造注入)
|
||||
- **影响**: Hook 重构为构造注入 Repository + agentName
|
||||
|
||||
### E2: AiOpsService 未使用 Hook
|
||||
- **发现**: AiOpsService 的 Planner / Executor / Supervisor 均未配置 AgentLoggingHook
|
||||
- **结论**: 需要补齐,每个子 Agent 加 Hook
|
||||
- **影响**: Planner 和 Executor 各加 Hook,Supervisor 不加
|
||||
|
||||
### E3: 项目无异步基础设施
|
||||
- **发现**: 全局搜索 `@Async` / `@EnableAsync` 均无匹配
|
||||
- **结论**: MVP 阶段同步写 DB,后续优化
|
||||
- **影响**: 标记为技术债
|
||||
|
||||
### E4: RunnableConfig 支持 metadata
|
||||
- **发现**: `RunnableConfig` 的 `metadata` 为 `ConcurrentMap`,可在构建时设置
|
||||
- **结论**: sessionId 通过 `config.addMetadata("sessionId", id)` 传递,线程安全
|
||||
- **影响**: 取代 ThreadLocal 方案
|
||||
@@ -1,409 +1,421 @@
|
||||
# 知识库检索架构说明
|
||||
# 知识库检索架构(L0 + L1)
|
||||
|
||||
## 一、架构位置
|
||||
**更新日期**: 2026-06-25
|
||||
|
||||
知识库检索是 Agent 工具层的一部分,为所有 Agent 提供知识查询能力。
|
||||
---
|
||||
|
||||
## 一、概述
|
||||
|
||||
`LookupKnowledgeTool` 实现两阶段混合检索:
|
||||
|
||||
- **L0 精确匹配**:基于内存索引的关键词匹配(< 10ms),索引从数据库加载
|
||||
- **L1 语义检索**:基于 Milvus 向量数据库的相似度搜索(200-500ms)
|
||||
|
||||
---
|
||||
|
||||
## 二、完整流程
|
||||
|
||||
```
|
||||
Agent 层
|
||||
├── Supervisor Agent
|
||||
├── Planner Agent
|
||||
├── SubAgents (ExternalApi, InternalError, Database...)
|
||||
└── Verifier Agent
|
||||
↓ 调用
|
||||
工具层 (Tools)
|
||||
├── searchDoc (文档检索 - L1 向量检索)
|
||||
├── lookup_knowledge (混合检索 - L0+L1) ← 新增
|
||||
├── queryLogs (日志查询)
|
||||
├── queryTrace (链路追踪)
|
||||
└── queryOrder (订单查询)
|
||||
↓ 依赖
|
||||
服务层 (Services)
|
||||
├── VectorSearchService (L1 语义检索 - Milvus)
|
||||
├── KnowledgeIndexService (L0 精确匹配 - 内存) ← 新增
|
||||
├── FrontmatterParser (元数据解析) ← 新增
|
||||
└── DocumentManagementService (文档管理)
|
||||
↓ 持久化
|
||||
数据层
|
||||
├── MySQL (api_document + metadata 字段) ← 增强
|
||||
├── Milvus (向量索引)
|
||||
└── Local Files (knowledge_base/) ← 新增
|
||||
用户查询
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────┐
|
||||
│ L0: 关键词精确匹配 │ (< 10ms)
|
||||
│ • 从内存索引做关键词匹配 │
|
||||
│ • 索引来源: ApiDocument DB│
|
||||
└──────────┬──────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────┴──────┐
|
||||
│ matches=1 │ ← 唯一匹配(高置信度)
|
||||
└──────┬──────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────┐ ┌──────────────────┐
|
||||
│ 跳过 L1 │ │ L0 返回正文摘要 │
|
||||
│ 置信度: high │ │ buildCompactSummary│
|
||||
└──────────────┘ └──────────────────┘
|
||||
|
||||
|
||||
┌──────┴──────┐
|
||||
│ matches=0 │ ← 无匹配
|
||||
└──────┬──────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────┐ ┌──────────────────┐
|
||||
│ 触发 L1 │ │ L0 无结果 │
|
||||
│ L1 语义检索 │ │ 仅有 L1 补充结果 │
|
||||
└──────────────┘ └──────────────────┘
|
||||
|
||||
|
||||
┌──────┴──────┐
|
||||
│ matches>=2 │ ← 多匹配
|
||||
└──────┬──────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────┐
|
||||
│ 触发 L1 │
|
||||
│ L1 语义检索 │
|
||||
└──────┬──────┘
|
||||
│
|
||||
┌─────┴─────┐
|
||||
│ ║ │
|
||||
▼ ▼
|
||||
L1 有结果 L1 无结果
|
||||
│ │
|
||||
▼ ▼
|
||||
元数据摘要 正文摘要
|
||||
(不读文件) (读文件)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、L0+L1 混合检索架构
|
||||
## 三、L0 返回内容策略
|
||||
|
||||
### 2.1 检索流程
|
||||
根据匹配场景决定 L0 返回给 LLM 的上下文内容量。
|
||||
|
||||
### 3.1 唯一匹配(高置信度,matches=1)
|
||||
|
||||
**策略**: `buildCompactSummary()`
|
||||
|
||||
L1 被跳过,LLM 只有 L0 信息来源,需要提供足够的正文内容。
|
||||
|
||||
```
|
||||
Agent 调用 lookup_knowledge(query)
|
||||
↓
|
||||
┌─────────────────────────────────────────┐
|
||||
│ LookupKnowledgeTool │
|
||||
│ (工具入口) │
|
||||
└────────────┬────────────────────────────┘
|
||||
│
|
||||
↓
|
||||
┌────────────────┐
|
||||
│ Step 1: L0 精确匹配 │ < 10ms
|
||||
│ (内存索引) │
|
||||
└────────┬───────────┘
|
||||
│
|
||||
┌───────┴────────┐
|
||||
│ │
|
||||
唯一匹配 多个/零个匹配
|
||||
│ │
|
||||
↓ ↓
|
||||
高置信度 低置信度
|
||||
(不调用L1) (调用L1补充)
|
||||
│ │
|
||||
│ ┌──────────────────┐
|
||||
│ │ Step 2: L1 语义检索 │ 200-500ms
|
||||
│ │ (Milvus) │
|
||||
│ └──────────┬─────────┘
|
||||
│ │
|
||||
└────────┬───────────┘
|
||||
↓
|
||||
┌─────────────────────┐
|
||||
│ Step 3: 组装结果 │
|
||||
│ primary + supplement │
|
||||
└─────────────────────┘
|
||||
↓
|
||||
返回给 Agent
|
||||
文档: 支付网关错误码定义
|
||||
摘要: 记录了支付网关所有核心错误码的含义及排查方向
|
||||
章节:
|
||||
- 超时类错误
|
||||
- 业务类错误
|
||||
- 签名类错误
|
||||
---
|
||||
**含义**:支付网关请求超时
|
||||
**常见原因**:网络延迟、第三方服务响应慢
|
||||
...
|
||||
```
|
||||
|
||||
### 2.2 数据流
|
||||
| 组成部分 | 说明 | 大小 |
|
||||
|---------|------|------|
|
||||
| title + summary | 从内存索引获取 | ~50-100 字符 |
|
||||
| 章节标题列表 | 从文件解析 `##` 标题 | ~50-200 字符 |
|
||||
| 正文片段 | 去 frontmatter/标题行/空行,短文档 800/长文档 500 字符截断 | ~300-800 字符 |
|
||||
| **总计** | | **~400-1000 字符** |
|
||||
|
||||
### 3.2 多匹配 + L1 有结果
|
||||
|
||||
**策略**: `buildMetadataOnlySummary()`
|
||||
|
||||
L1 已有语义内容片段,L0 仅需告知 LLM 命中了哪些文档。**不读文件**,仅用内存索引。
|
||||
|
||||
```
|
||||
文档上传流程:
|
||||
POST /api/documents/upload
|
||||
↓
|
||||
DocumentManagementService.uploadDocument()
|
||||
↓
|
||||
1. 文本提取
|
||||
2. 保存原始文件 → knowledge_base/{category}/{filename}
|
||||
3. 解析 frontmatter (FrontmatterParser)
|
||||
4. 分块 → 向量化 → Milvus 索引 (L1)
|
||||
5. 元数据存 MySQL (metadata 字段 JSON)
|
||||
6. 更新 L0 内存索引 (KnowledgeIndexService)
|
||||
↓
|
||||
完成
|
||||
|
||||
文档查询流程:
|
||||
Agent 调用 lookup_knowledge("ERR_TIMEOUT")
|
||||
↓
|
||||
KnowledgeIndexService.exactMatch()
|
||||
↓
|
||||
遍历内存索引 (keywords 精确匹配)
|
||||
↓
|
||||
找到唯一匹配 → 读取本地文件 (前 2000 字符)
|
||||
↓
|
||||
返回 primary (高置信度)
|
||||
文档: 支付网关错误码定义
|
||||
摘要: 记录了支付网关所有核心错误码的含义及排查方向
|
||||
关键词: ERR_TIMEOUT, 超时, 支付网关
|
||||
来源: api/payment-errors.md
|
||||
```
|
||||
|
||||
| 组成部分 | 说明 | 大小 |
|
||||
|---------|------|------|
|
||||
| title + summary + keywords | 全部从内存索引获取 | ~100-200 字符 |
|
||||
| **总计** | | **~100-200 字符** |
|
||||
|
||||
### 3.3 多匹配 + L1 无结果
|
||||
|
||||
**策略**: `buildCompactSummary()`(同 3.1)
|
||||
|
||||
L1 未返回结果, L0 作为兜底提供正文内容。
|
||||
|
||||
---
|
||||
|
||||
## 三、核心组件说明
|
||||
## 四、决策矩阵
|
||||
|
||||
### 3.1 FrontmatterParser
|
||||
|
||||
**职责**:解析 Markdown 文件头的 YAML frontmatter
|
||||
|
||||
**输入**:
|
||||
```markdown
|
||||
---
|
||||
title: 支付网关错误码定义
|
||||
keywords: [ERR_TIMEOUT, 超时, 支付网关]
|
||||
summary: 记录了支付网关所有核心错误码的含义及排查方向
|
||||
category: api
|
||||
---
|
||||
|
||||
# 正文内容
|
||||
```
|
||||
needFullContent = highConfidence || !hasL1
|
||||
```
|
||||
|
||||
**输出**:
|
||||
| 场景 | matches | L1 结果 | needFullContent | L0 策略 | 是否读文件 | 上下文大小 |
|
||||
|------|:-------:|:--------:|:---------------:|---------|:---------:|:--------:|
|
||||
| 唯一匹配 | 1 | 未执行 | true | `buildCompactSummary` | 是 | ~600 字符 |
|
||||
| 多匹配 + L1 有结果 | 2+ | 有 | false | `buildMetadataOnlySummary` | **否** | ~150 字符 |
|
||||
| 多匹配 + L1 无结果 | 2+ | 无 | true | `buildCompactSummary` | 是 | ~600 字符 |
|
||||
| 无匹配 | 0 | 有 | — | 无 L0,仅 L1 | 否 | 0 |
|
||||
|
||||
---
|
||||
|
||||
## 五、代码结构
|
||||
|
||||
```
|
||||
LookupKnowledgeTool
|
||||
├── lookupKnowledge(query) # 入口:编排 L0 + L1
|
||||
├── buildResult(l0, l1, confidence) # 组装结果,选择摘要策略
|
||||
├── buildCompactSummary(entry) # 元数据 + 章节 + 正文片段(读文件)
|
||||
├── buildMetadataOnlySummary(entry) # 仅元数据(不读文件)
|
||||
├── countMdHeadings(content) # 统计章节数(日志用)
|
||||
└── extractFirstMeaningfulLine(...) # 提取首个有意义文本行(日志用)
|
||||
```
|
||||
|
||||
### 关键逻辑(buildResult)
|
||||
|
||||
```java
|
||||
Frontmatter {
|
||||
title: "支付网关错误码定义",
|
||||
keywords: ["ERR_TIMEOUT", "超时", "支付网关"],
|
||||
summary: "...",
|
||||
category: "api"
|
||||
}
|
||||
boolean needFullContent = highConfidence || !hasL1;
|
||||
String content = needFullContent
|
||||
? buildCompactSummary(first)
|
||||
: buildMetadataOnlySummary(first);
|
||||
```
|
||||
|
||||
### 3.2 KnowledgeIndexService
|
||||
---
|
||||
|
||||
**职责**:维护 L0 内存索引,提供精确关键词匹配
|
||||
## 六、日志输出示例
|
||||
|
||||
**核心方法**:
|
||||
- `@PostConstruct loadIndex()` - 启动时扫描 knowledge_base/
|
||||
- `exactMatch(String query)` - 精确匹配(不区分大小写)
|
||||
- `readDocument(String filePath, int maxChars)` - 读取文档内容
|
||||
- `addToIndex(KnowledgeEntry entry)` - 添加到索引
|
||||
- `removeFromIndex(String filePath)` - 从索引移除
|
||||
### 多匹配场景(matches=2, L1 有结果)
|
||||
|
||||
**数据结构**:
|
||||
```java
|
||||
List<KnowledgeEntry> knowledgeIndex = new CopyOnWriteArrayList<>();
|
||||
|
||||
KnowledgeEntry {
|
||||
filePath: "knowledge_base/api/payment-errors.md",
|
||||
title: "支付网关错误码定义",
|
||||
keywords: ["ERR_TIMEOUT", "超时", "支付网关"],
|
||||
summary: "...",
|
||||
category: "api"
|
||||
}
|
||||
```
|
||||
[L0 精确匹配] 完成: matches=2, time=3ms
|
||||
[置信度判断] highConfidence=false, reason=多个或零个匹配
|
||||
[L1 语义检索] L0非唯一匹配,触发L1语义检索...
|
||||
[L1 语义检索] 完成: matches=1, time=245ms
|
||||
----------------------------------------
|
||||
<<< [工具返回] lookup_knowledge
|
||||
<<< [L0 主结果] 标题: 支付网关错误码定义
|
||||
<<< [L0 主结果] 摘要: 记录了支付网关所有核心错误码的含义及排查方向 ← 仅元数据
|
||||
<<< [L0 主结果] 内容: 126 字符, 0 个章节 ← 约150字符
|
||||
<<< [L1 补充] 相似度: 0.8234
|
||||
<<< [L1 补充] 内容片段: 支付网关请求超时... ← L1 提供具体内容
|
||||
```
|
||||
|
||||
### 3.3 LookupKnowledgeTool
|
||||
### 唯一匹配场景(matches=1, 跳过 L1)
|
||||
|
||||
**职责**:L0+L1 混合检索工具,Agent 可调用
|
||||
|
||||
**工具定义**:
|
||||
```java
|
||||
@Tool(description = "查询知识库文档。优先精确匹配关键词,未命中或多个匹配时自动补充语义相关片段。" +
|
||||
"参数 query: 查询关键词,例如 'ERR_TIMEOUT'、'支付网关超时'")
|
||||
public LookupResult lookupKnowledge(String query)
|
||||
```
|
||||
[L0 精确匹配] 完成: matches=1, time=2ms
|
||||
[置信度判断] highConfidence=true, reason=唯一匹配
|
||||
[L1 语义检索] L0唯一匹配,跳过L1检索
|
||||
----------------------------------------
|
||||
<<< [工具返回] lookup_knowledge
|
||||
<<< [L0 主结果] 标题: 支付网关错误码定义
|
||||
<<< [L0 主结果] 摘要: 记录了支付网关所有核心错误码的含义及排查方向
|
||||
<<< [L0 主结果] 内容: 725 字符, 3 个章节 ← 约700字符
|
||||
```
|
||||
|
||||
**返回格式**:
|
||||
---
|
||||
|
||||
## 七、MVP 效率评估 & 改进方向
|
||||
|
||||
### 7.1 当前效率评估
|
||||
|
||||
| 维度 | 评分 | 说明 |
|
||||
|------|:----:|------|
|
||||
| L0 匹配速度 | ★★★★★ | 内存索引,< 10ms,几乎没有优化空间 |
|
||||
| L1 检索速度 | ★★★★☆ | Milvus 向量检索,200-500ms,取决于数据量 |
|
||||
| L0 匹配准确率 | ★★☆☆☆ | 子串匹配,无排序无评分,匹配即返回 |
|
||||
| L1 检索准确率 | ★★★☆☆ | 语义相似度,但分块缺少上下文信息 |
|
||||
| 召回率(查全) | ★★★☆☆ | L0+L1 两阶段覆盖大多数场景,但缺乏融合重排 |
|
||||
| 上下文利用率 | ★★★★☆ | 根据场景动态控制 L0 内容量,已优化 |
|
||||
| **综合** | **★★★☆☆** | **MVP 可用,但检索质量有提升空间** |
|
||||
|
||||
### 7.2 关键瓶颈
|
||||
|
||||
#### 瓶颈 1:分块丢失上下文(✅ 已修复—见下方 7.5)
|
||||
|
||||
当前每个 Chunk 只记录最近的 `##` 标题:
|
||||
|
||||
```json
|
||||
{
|
||||
"found": true,
|
||||
"primary": {
|
||||
"content": "文档内容(前 2000 字符)",
|
||||
"source": "knowledge_base/api/payment-errors.md",
|
||||
"matchType": "exact_L0",
|
||||
"confidence": "high"
|
||||
},
|
||||
"supplement": {
|
||||
"content": "语义相关片段(L1)",
|
||||
"source": "metadata",
|
||||
"matchType": "semantic_L1"
|
||||
}
|
||||
"content": "**含义**:支付网关请求超时\n**常见原因**:网络延迟",
|
||||
"title": "超时类错误",
|
||||
"chunkIndex": 2
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
LLM 收到这个片段时**不知道**它属于"支付网关错误码定义"这个文档,也不知道具体错误码名称是 ERR_TIMEOUT。如果同时检索了多个文档的片段,LLM 容易混淆。
|
||||
|
||||
## 四、与现有架构的集成
|
||||
#### 瓶颈 2:L0 关键词匹配过于简单
|
||||
|
||||
### 4.1 Agent 使用场景
|
||||
当前 `KnowledgeIndexService.matchesKeywords()` 只做子串包含匹配,没有:
|
||||
- 排序/评分(多个匹配时按什么顺序?)
|
||||
- 权重(标题匹配 > 正文匹配)
|
||||
- 部分匹配("timeout" 匹配 "ERR_TIMEOUT")
|
||||
|
||||
**ExternalApiSubAgent** (接口专家):
|
||||
```
|
||||
诊断步骤:
|
||||
1. 提取错误码(如 "ERR_TIMEOUT")
|
||||
2. 调用 lookup_knowledge("ERR_TIMEOUT")
|
||||
3. 获得完整错误码定义和排查方向
|
||||
4. 结合日志/链路追踪进行分析
|
||||
```
|
||||
#### 瓶颈 3:L0 和 L1 无交叉融合
|
||||
|
||||
**DatabaseSubAgent** (数据库专家):
|
||||
```
|
||||
诊断步骤:
|
||||
1. 识别数据库问题(如 "连接池满")
|
||||
2. 调用 lookup_knowledge("HikariCP")
|
||||
3. 获得连接池配置最佳实践
|
||||
4. 提供优化建议
|
||||
```
|
||||
|
||||
**Planner Agent** (规划者):
|
||||
```
|
||||
规划阶段:
|
||||
1. 分析问题类型
|
||||
2. 调用 lookup_knowledge("故障诊断")
|
||||
3. 获得标准诊断流程
|
||||
4. 制定排查策略
|
||||
```
|
||||
|
||||
### 4.2 与现有工具对比
|
||||
|
||||
| 工具 | 检索方式 | 响应时间 | 适用场景 | 置信度 |
|
||||
|------|---------|---------|---------|--------|
|
||||
| searchDoc | L1 语义检索 | 200-500ms | 模糊查询、语义理解 | 依赖相似度 |
|
||||
| lookup_knowledge | L0+L1 混合 | < 10ms (高置信) | 精确关键词 + 语义补充 | high/low |
|
||||
|
||||
**推荐使用策略**:
|
||||
- 已知精确关键词(错误码、配置项)→ `lookup_knowledge`
|
||||
- 模糊描述、需要语义理解 → `searchDoc`
|
||||
两阶段检索结果只是简单的"1位L0 + 1位L1"拼接,没有:
|
||||
- RRF 或加权融合重排
|
||||
- 重复内容去重
|
||||
- 根据相关性选择 top-K
|
||||
|
||||
---
|
||||
|
||||
## 五、数据库变更
|
||||
### 7.3 改进方向分析
|
||||
|
||||
### 5.1 api_document 表增强
|
||||
#### 方向 A:面包屑导航(Chunk 携带层级上下文)
|
||||
|
||||
**新增字段**:
|
||||
```sql
|
||||
ALTER TABLE api_document
|
||||
ADD COLUMN metadata TEXT COMMENT 'Frontmatter 元数据 (JSON)';
|
||||
**做法**:分块时记录完整的标题层级路径作为 `breadcrumb`。
|
||||
|
||||
当前分块 metadata:
|
||||
```json
|
||||
{ "title": "超时类错误" }
|
||||
```
|
||||
|
||||
**字段说明**:
|
||||
- 类型:TEXT(最大 64KB)
|
||||
- 格式:JSON 字符串
|
||||
- 内容:frontmatter 解析结果
|
||||
|
||||
**示例数据**:
|
||||
改进后:
|
||||
```json
|
||||
{
|
||||
"title": "支付网关错误码定义",
|
||||
"keywords": ["ERR_TIMEOUT", "超时", "支付网关"],
|
||||
"summary": "记录了支付网关所有核心错误码的含义及排查方向",
|
||||
"title": "超时类错误",
|
||||
"breadcrumb": "支付网关错误码定义 > 超时类错误 > ERR_TIMEOUT",
|
||||
"heading_h1": "支付网关错误码定义",
|
||||
"heading_h2": "超时类错误",
|
||||
"heading_h3": "ERR_TIMEOUT"
|
||||
}
|
||||
```
|
||||
|
||||
**收益评估**:
|
||||
|
||||
| 场景 | 无面包屑的问题 | 有面包屑的改善 | 提升幅度 |
|
||||
|------|---------------|---------------|:--------:|
|
||||
| 单文档多分块 | LLM 知道标题但不知道层级关系 | 清楚"文档>章节>条目"归属 | 中等 |
|
||||
| 跨文档混合结果 | 分块看不出源文档 | breadcrumb 第一段就是文档标题 | 大 |
|
||||
| 深层嵌套文档(3+ 级) | 分块内容难以定位 | 完整路径一目了然 | 显著 |
|
||||
| 向量检索相关性 | 只对 chunk content 做 embedding | breadcrumb 可拼入 content 做 embedding 或单独索引 | 中等 |
|
||||
|
||||
**MVP 阶段价值**:当前文档结构较浅(2-3级),breadcrumb 对 LLM 理解帮助中等。但如果后续文档层级加深(像你提到的"排障指南 > 支付网关 > 502错误处理"),价值会显著提升。
|
||||
|
||||
**实现成本**:低。修改 `DocumentChunkService` 的分块逻辑,积累当前标题栈,写入 `DocumentChunk` 和 Milvus metadata。
|
||||
|
||||
#### 方向 B:混合检索 + RRF 重排
|
||||
|
||||
**做法**:L0 关键词和 L1 向量检索并行执行 → 结果用 Reciprocal Rank Fusion 统一排序 → 取 top-K。
|
||||
|
||||
```
|
||||
用户查询 → 并行的:
|
||||
├── L0 关键词匹配 → 得分向量 S₀
|
||||
└── L1 向量检索 → 得分向量 S₁
|
||||
↓
|
||||
RRF 融合重排
|
||||
↓
|
||||
top-K 统一结果
|
||||
```
|
||||
|
||||
RRF 公式:对每个文档 d,`score(d) = Σ 1/(k + rank_r(d))`,其中 k=60(常数)。
|
||||
|
||||
**收益评估**:
|
||||
|
||||
| 场景 | 当前的问题 | 混合 + RRF | 提升幅度 |
|
||||
|------|-----------|-----------|:--------:|
|
||||
| 精确关键词("ERR_TIMEOUT") | L0 匹配但不排序,L1 可能不匹配 | L0 高排名 → RRF 拉到顶部 | 大 |
|
||||
| 语义查询("支付超时如何处理") | L0 可能不匹配,全靠 L1 | L1 兜底不受影响 | 无变化 |
|
||||
| 混合查询("ERR_TIMEOUT 支付网关超时") | L0 匹配一个、L1 匹配一个,无融合 | RRF 统一排序,更合理 | 中等 |
|
||||
| 多文档匹配 | L0 返回无序列表 + L1 独立结果 | 统一排序、去重 | 大 |
|
||||
|
||||
**MVP 阶段价值**:RRF 的实现成本和维护成本较高,而当前 MVP 数据量小(6 个文档),人工检查即可确定哪些匹配是好的。**建议数据量 > 50 个文档时引入**。
|
||||
|
||||
#### 方向 C:Breadcrumb + Embedding 增强
|
||||
|
||||
**做法**:将 breadcrumb 拼入 chunk content 后再做 embedding,让向量包含层级语义。
|
||||
|
||||
```java
|
||||
// 当前
|
||||
embeddingService.generateEmbedding(chunk.getContent())
|
||||
|
||||
// 改进
|
||||
String augmentedContent = chunk.getBreadcrumb() + "\n" + chunk.getContent();
|
||||
embeddingService.generateEmbedding(augmentedContent);
|
||||
```
|
||||
|
||||
这样搜索"ERR_TIMEOUT"时,"支付网关错误码定义 > 超时类错误 > ERR_TIMEOUT" 也会匹配到,而不只是 chunk 正文。
|
||||
|
||||
| 场景 | 当前 | Breadcrumb + Embedding | 提升 |
|
||||
|------|------|------------------------|:----:|
|
||||
| 搜索"支付网关超时" | 匹配到正文含"超时"和"支付网关"的 chunk | breadcrumb 直接含"支付网关",匹配更准 | 中等 |
|
||||
| 搜索"错误码定义" | 可能匹配不到具体错误内容的 chunk | breadcrumb 含"错误码定义",相关性更高 | 大 |
|
||||
|
||||
---
|
||||
|
||||
### 7.4 实施优先级建议
|
||||
|
||||
| 优先级 | 改进项 | 复杂度 | 收益 | 状态 |
|
||||
|:------:|--------|:------:|:----:|:----:|
|
||||
| P0 | **Breadcrumb 上下文**(方向 A) | 低 | 中 | **✅ 已实现 (2026-06-26)** |
|
||||
| P1 | 下个版本 | 低 | 中-大 | 待定 |
|
||||
| P1 | L0 排序(匹配评分 + 排序) | 低 | 中 | 待定 |
|
||||
| P2 | 混合检索 + RRF 重排 | 高 | 大 | 数据量 > 50 文档时引入 |
|
||||
|
||||
### 7.5 Breadcrumb 实现说明
|
||||
|
||||
已于 2026-06-26 实现。改动范围:
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `DocumentChunk.java` | 新增 `breadcrumb` 字段 |
|
||||
| `DocumentChunkService.java` | `splitByHeadings()` 维护标题层级栈,`Section` 新增 `level`/`breadcrumb`,`chunkSection()` 和 `saveChunkAndGetNextStart()` 透传 Breadcrumb |
|
||||
| `VectorIndexService.java` | `buildMetadata()` 和 `buildDocumentMetadata()` 将 breadcrumb 写入 Milvus metadata |
|
||||
|
||||
#### 层级栈算法
|
||||
|
||||
```java
|
||||
// 在 splitByHeadings() 中,每次匹配到标题时:
|
||||
while (!headingStack.isEmpty() && headingStack.size() >= level) {
|
||||
headingStack.remove(headingStack.size() - 1); // 弹出同级或更高级
|
||||
}
|
||||
headingStack.add(title); // 追加当前标题
|
||||
currentBreadcrumb = String.join(" > ", headingStack);
|
||||
```
|
||||
|
||||
示例:处理 `fault-diagnosis-process.md` 的完整面包屑路径──
|
||||
|
||||
```json
|
||||
// 分块 "应急响应流程 > 1. 初步评估"
|
||||
{ "breadcrumb": "故障诊断流程规范 > 应急响应流程 > 1. 初步评估" }
|
||||
|
||||
// 分块 "根因分析方法 > 5-Why 分析法"
|
||||
{ "breadcrumb": "故障诊断流程规范 > 根因分析方法 > 5-Why 分析法" }
|
||||
```
|
||||
|
||||
#### 当前 metadata 结构(Milvus)
|
||||
|
||||
```json
|
||||
{
|
||||
"_source": "knowledge_base/api/payment-errors.md",
|
||||
"_file_name": "payment-errors.md",
|
||||
"category": "api",
|
||||
"version": "1.0",
|
||||
"author": "zhangsan"
|
||||
"chunkIndex": 2,
|
||||
"totalChunks": 5,
|
||||
"title": "超时类错误",
|
||||
"breadcrumb": "支付网关错误码定义 > 超时类错误 > ERR_TIMEOUT"
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 filePath 字段用途变更
|
||||
以 `fault-diagnosis-process.md` 为例:
|
||||
|
||||
**原用途**:存储相对路径或 URL
|
||||
```markdown
|
||||
# 故障诊断流程规范 ← heading_h1
|
||||
|
||||
**新用途**:存储本地文件绝对路径
|
||||
```
|
||||
knowledge_base/api/payment-errors.md
|
||||
knowledge_base/infrastructure/redis-config.md
|
||||
## 应急响应流程 ← heading_h2
|
||||
|
||||
### 1. 初步评估 ← heading_h3(分块1)
|
||||
内容...
|
||||
|
||||
### 2. 快速止血 ← heading_h3(分块2)
|
||||
内容...
|
||||
|
||||
## 根因分析方法 ← heading_h2
|
||||
|
||||
### 5-Why 分析法 ← heading_h3(分块3)
|
||||
内容...
|
||||
```
|
||||
|
||||
**用途**:
|
||||
1. L0 索引读取完整文档
|
||||
2. 支持未来的章节锚点功能
|
||||
|
||||
---
|
||||
|
||||
## 六、配置说明
|
||||
|
||||
### 6.1 application.yml 新增配置
|
||||
|
||||
```yaml
|
||||
knowledge:
|
||||
base-path: knowledge_base/
|
||||
```
|
||||
|
||||
**说明**:
|
||||
- 相对于项目根目录
|
||||
- 启动时递归扫描此目录
|
||||
- 建议按 category 组织子目录
|
||||
|
||||
### 6.2 目录结构规范
|
||||
改造后每个分块的 metadata:
|
||||
|
||||
```
|
||||
knowledge_base/
|
||||
├── api/ # API 相关文档
|
||||
│ └── payment-errors.md
|
||||
├── infrastructure/ # 基础设施配置
|
||||
│ ├── redis-config.md
|
||||
│ ├── mysql-connection-pool.md
|
||||
│ └── flyway-best-practices.md
|
||||
├── domain/ # 领域知识
|
||||
│ └── spring-ai-tool-best-practices.md
|
||||
└── troubleshooting/ # 故障排查
|
||||
└── fault-diagnosis-process.md
|
||||
分块1: breadcrumb = "故障诊断流程规范 > 应急响应流程 > 1. 初步评估"
|
||||
分块2: breadcrumb = "故障诊断流程规范 > 应急响应流程 > 2. 快速止血"
|
||||
分块3: breadcrumb = "故障诊断流程规范 > 根因分析方法 > 5-Why 分析法"
|
||||
```
|
||||
|
||||
---
|
||||
LLM 视角受益:当检索到 "2. 快速止血" 时,LLM 立刻知道它属于"故障诊断流程规范 > 应急响应流程"体系,不需要额外读取其他分块来推断上下文。
|
||||
|
||||
## 七、性能指标
|
||||
|
||||
### 7.1 查询性能
|
||||
|
||||
| 场景 | L0 耗时 | L1 耗时 | 总耗时 |
|
||||
|------|---------|---------|--------|
|
||||
| 唯一匹配(高置信) | < 5ms | 0 (不调用) | < 10ms |
|
||||
| 多个匹配(低置信) | < 5ms | 200-500ms | < 500ms |
|
||||
| 未匹配(仅L1) | < 5ms | 200-500ms | < 500ms |
|
||||
|
||||
### 7.2 索引性能
|
||||
|
||||
| 指标 | 实测值 | 目标值 |
|
||||
|------|--------|--------|
|
||||
| 启动扫描时间 | < 20ms (6 个文档) | < 1s (500 个文档) |
|
||||
| 内存占用 | < 1MB (6 个文档) | < 5MB (500 个文档) |
|
||||
| L0 匹配时间 | < 5ms | < 10ms |
|
||||
|
||||
---
|
||||
|
||||
## 八、可观测性
|
||||
|
||||
### 8.1 日志追踪
|
||||
|
||||
所有查询都带 requestId(8 位 UUID),可追踪完整流程:
|
||||
|
||||
```
|
||||
[a1b2c3d4] 收到知识库查询请求: query=ERR_TIMEOUT
|
||||
[a1b2c3d4] L0精确匹配完成: matches=1, time=2ms
|
||||
[a1b2c3d4] 置信度判断: highConfidence=true, reason=唯一匹配
|
||||
[a1b2c3d4] L0唯一匹配,跳过L1检索
|
||||
[a1b2c3d4] 查询完成: found=true, confidence=high, totalTime=5ms
|
||||
```
|
||||
|
||||
### 8.2 关键指标
|
||||
|
||||
**监控指标**:
|
||||
- L0 查询耗时(P50/P95/P99)
|
||||
- L1 调用频率(低置信度比例)
|
||||
- 查询总耗时(端到端)
|
||||
- 高置信度命中率
|
||||
|
||||
**告警阈值**:
|
||||
- 查询总耗时 > 2s
|
||||
- L0 索引加载失败
|
||||
- 高置信度命中率 < 20%
|
||||
|
||||
---
|
||||
|
||||
## 九、限制与注意事项
|
||||
|
||||
### 9.1 MVP 阶段限制
|
||||
|
||||
1. **L0 索引无持久化**
|
||||
- 应用重启需要重新扫描
|
||||
- 缓解:启动扫描通常 < 1s
|
||||
|
||||
2. **章节锚点未实现**
|
||||
- sectionTitle 参数预留
|
||||
- availableSections 返回 null
|
||||
|
||||
3. **批量导入不支持**
|
||||
- 当前仅支持单文件上传
|
||||
|
||||
### 9.2 最佳实践
|
||||
|
||||
1. **编写高质量 frontmatter**
|
||||
- keywords 精准且全面
|
||||
- 避免关键词重复(导致多匹配)
|
||||
|
||||
2. **知识库目录组织**
|
||||
- 按 category 分类
|
||||
- 文件命名语义化
|
||||
|
||||
3. **监控告警配置**
|
||||
- 慢查询告警
|
||||
- L0 索引加载失败告警
|
||||
|
||||
---
|
||||
|
||||
## 十、后续增强方向(Phase 2)
|
||||
|
||||
1. **章节锚点**
|
||||
- 支持 sectionTitle 参数
|
||||
- 直接定位到文档特定章节
|
||||
|
||||
2. **L0 索引持久化**
|
||||
- 序列化到文件
|
||||
- 避免重启扫描
|
||||
|
||||
3. **批量导入工具**
|
||||
- 支持目录批量导入
|
||||
- 进度监控
|
||||
|
||||
4. **知识库管理 API**
|
||||
- CRUD 接口
|
||||
- 在线编辑
|
||||
|
||||
5. **向量化元数据**
|
||||
- title/summary 也参与 L1 检索
|
||||
- 提升语义检索准确度
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `LookupKnowledgeTool.java` | 检索工具入口 |
|
||||
| `KnowledgeIndexService.java` | L0 内存索引管理 |
|
||||
| `VectorSearchService.java` | L1 向量检索(Milvus) |
|
||||
| `KnowledgeEntry.java` | 索引条目 DTO(含 title, summary, keywords) |
|
||||
| `LookupResult.java` | 查询结果 DTO |
|
||||
| `PrimaryResult.java` | L0 结果 DTO |
|
||||
| `SupplementResult.java` | L1 结果 DTO |
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
# 执行者 System Prompt
|
||||
|
||||
## 角色定位
|
||||
|
||||
你是诊断流程的**执行者**。你的任务非常明确:严格遵循规划者下发的任务清单,按步骤调用工具完成任务,并输出最终结果。
|
||||
|
||||
---
|
||||
|
||||
## 核心行为准则
|
||||
|
||||
### 1. 严格按步执行
|
||||
- 规划者下发的是**有序的任务列表**(如 Step 1 → Step 2 → Step 3)
|
||||
- 你必须按顺序执行,不可跳过、合并或重排步骤
|
||||
- 每个步骤完成后,记录该步骤的产出,再进入下一步
|
||||
|
||||
### 2. 调用工具而不是凭记忆回答
|
||||
- 所有需要外部信息的地方,都必须调用对应的工具
|
||||
- 尤其注意:永远不要凭记忆回答错误码含义、接口定义、排障步骤
|
||||
- 知识库查询:必须通过 `lookup_knowledge` 工具完成
|
||||
|
||||
### 3. 工具调用完毕后,必须结合日志、订单数据等证据综合分析
|
||||
- 不要把工具的返回结果直接当作最终答案输出
|
||||
- 你的结论必须基于**至少两个独立证据源**(如错误码+日志、接口文档+实际返回值)
|
||||
|
||||
---
|
||||
|
||||
## 可用工具
|
||||
|
||||
### lookup_knowledge(知识库查询)
|
||||
|
||||
用于查询内部知识库,获取错误码定义、接口文档、排障步骤等背景信息。
|
||||
|
||||
| 参数 | 说明 |
|
||||
|------|------|
|
||||
| `query_text` | 查询关键词。可以是错误码(ERR_TIMEOUT)、服务名(payment-gateway)、模糊问题(支付为什么失败) |
|
||||
|
||||
**内部机制**:
|
||||
工具内部自动执行「先精确匹配(L0),未命中则语义检索(L1)」的两阶段检索逻辑,你无需关心哪一层。返回结果中包含 `match_type` 字段标记来源类型。
|
||||
|
||||
**返回字段**:
|
||||
- `primary`:主要信息(L0 命中文档内容 或 L1 返回的 Top-1 片段)
|
||||
- `primary.match_type`:`exact_l0`(精确匹配)或 `semantic_l1`(语义搜索)
|
||||
- `primary.source`:信息来源的文件路径
|
||||
|
||||
**使用规则**:
|
||||
- 当你查到了错误码、接口名、服务名时:**必须**调用此工具
|
||||
- 当需要查排障步骤、业务流程、最佳实践时:**必须**调用此工具
|
||||
- 对当前结果没有十足把握时:**建议**调用此工具验证
|
||||
|
||||
---
|
||||
|
||||
## 任务执行规范
|
||||
|
||||
### 1. 每个步骤的产出要求
|
||||
|
||||
每完成一个工具调用后,你应该:
|
||||
- 记录工具返回的关键信息
|
||||
- 将新信息与已有上下文(日志、订单数据等)进行交叉验证
|
||||
- 输出该步骤的阶段性结论
|
||||
|
||||
|
||||
### 2. 最终输出的报告格式
|
||||
|
||||
```yaml
|
||||
## 诊断结论
|
||||
|
||||
**问题根因**:XXX
|
||||
|
||||
**证据链**:
|
||||
1. 订单状态返回错误码 ERR_TIMEOUT
|
||||
2. 知识库 lookup_knowledge("ERR_TIMEOUT") 返回:支付网关响应超时(>5秒)
|
||||
3. 日志确认:14:32:15 请求耗时 5.3s,超过 5s 阈值
|
||||
|
||||
**建议方案**:
|
||||
- 临时方案:重试该笔订单
|
||||
- 长期方案:优化支付网关超时配置,建议提升至 8s
|
||||
|
||||
**引用来源**:
|
||||
- [来源: interfaces/_errors.md]
|
||||
@@ -0,0 +1,296 @@
|
||||
# 会话存储方案设计
|
||||
|
||||
**日期**: 2026-06-26
|
||||
**类型**: 架构设计
|
||||
**状态**: 已实现 (2026-06-26)
|
||||
|
||||
---
|
||||
|
||||
## 一、背景与目标
|
||||
|
||||
### 1.1 现状问题
|
||||
|
||||
当前仅有一张 `diagnosis_record` 表,存在以下问题:
|
||||
|
||||
| 问题 | 说明 |
|
||||
|------|------|
|
||||
| **语义耦合** | `fault_category`、`error_code`、`root_cause`、`solution` 等字段耦合在"告警分析"领域语义,ChatService 通用问答场景用不上 |
|
||||
| **Agent 维度缺失** | 只有一个 `tool_calls` JSON 字段,存不下两个 Agent 的多轮决策链 |
|
||||
| **检索质量不可追溯** | 没有记录 L0/L1 命中层、截断信息、召回内容长度 |
|
||||
| **指标不完整** | 有 `duration` 和 `confidence`,但缺 token 用量、自评信号、采纳率 |
|
||||
|
||||
### 1.2 存储范围
|
||||
|
||||
需要覆盖四个层面的数据:
|
||||
|
||||
```
|
||||
诊断级元数据
|
||||
├── 单次诊断的唯一 ID、查询问题、状态
|
||||
├── 会话级决策链
|
||||
│ ├── agent_step:每个 Agent 的每一步(输入、输出、延迟、Token)
|
||||
│ └── tool_invocation:每次工具调用(参数、结果、耗时)
|
||||
├── 检索质量明细
|
||||
│ └── 每次 lookup_knowledge 的命中层(L0/L1)、内容长度、是否截断
|
||||
└── 自评估信号
|
||||
└── LLM 对结论的置信度自评
|
||||
```
|
||||
|
||||
### 1.3 设计目标
|
||||
|
||||
- **可观测**:Debug 时能回溯完整决策链
|
||||
- **可评估**:能统计 L0/L1 命中率、平均 Token 消耗、工具采纳率等指标
|
||||
- **可演进**:覆盖当前两个 Agent(ChatService / AiOpsService),未来新增 Agent 也能接入
|
||||
|
||||
---
|
||||
|
||||
## 二、存储选型分析
|
||||
|
||||
### 2.1 方案对比
|
||||
|
||||
| 维度 | SQL + JSON 列 | NoSQL 文档库 |
|
||||
|------|:------------:|:-----------:|
|
||||
| 基础设施 | 已有的 MySQL,零新增 | 需新部署 MongoDB 等 |
|
||||
| 层级查询 | `WHERE session_id=? AND agent_name=?` 高效 | 需二级索引 |
|
||||
| 指标聚合 | `AVG(token_count) GROUP BY agent_name` 原生支持 | 聚合管道,学习成本 |
|
||||
| 非结构化内容 | JSON 列(MySQL 8+ 支持良好) | 天然支持 |
|
||||
| MVP 迭代速度 | JPA Entity + Flyway 快速迭代 | 新 ORM 学习成本 |
|
||||
|
||||
### 2.2 结论
|
||||
|
||||
**采用 MySQL + JSON 列**。结构化字段做查询和聚合,JSON 列存非结构化载荷。MVP 阶段数据量可控,等后续 > 百万级或需要更灵活 schema 时再评估 NoSQL。
|
||||
|
||||
---
|
||||
|
||||
## 三、存储模型
|
||||
|
||||
### 3.1 整体关系
|
||||
|
||||
```
|
||||
diagnosis_session (1)
|
||||
│
|
||||
└── agent_step (0:N) —— 单次诊断的每一步 Agent 决策
|
||||
│
|
||||
└── tool_invocation (0:N) —— 每步中的工具调用
|
||||
```
|
||||
|
||||
### 3.2 表设计
|
||||
|
||||
#### 表 1:diagnosis_session(诊断会话)
|
||||
|
||||
```sql
|
||||
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": "...", "evidence_count": 3}',
|
||||
|
||||
-- 用户反馈
|
||||
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='诊断会话表';
|
||||
```
|
||||
|
||||
#### 表 2:agent_step(Agent 决策步骤)
|
||||
|
||||
```sql
|
||||
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 / supervisor',
|
||||
|
||||
-- 模型调用(输入输出摘要,非完整消息体)
|
||||
model_input JSON COMMENT '模型输入摘要 [{role, content_truncated}, ...]',
|
||||
model_output JSON COMMENT '模型输出摘要 {text, tool_calls, ...}',
|
||||
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 决策步骤表';
|
||||
```
|
||||
|
||||
#### 表 3:tool_invocation(工具调用明细)
|
||||
|
||||
```sql
|
||||
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 '输出总字符数',
|
||||
|
||||
-- 检索质量(仅 lookup_knowledge 时有意义)
|
||||
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":[], "has_supplement": true}',
|
||||
|
||||
-- 性能 & 状态
|
||||
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='工具调用明细表';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、数据流设计
|
||||
|
||||
### 4.1 完整链路
|
||||
|
||||
```
|
||||
用户请求
|
||||
│
|
||||
▼
|
||||
1. 创建 diagnosis_session(status=RUNNING)
|
||||
│
|
||||
▼
|
||||
2. Agent Loop(可能多轮)
|
||||
│
|
||||
├── beforeModel()
|
||||
│ └── AgentLoggingHook 记录 model_input + 开始时间 → 写入 agent_step(先创建,duration 待填)
|
||||
│
|
||||
├── afterModel()
|
||||
│ └── AgentLoggingHook 记录 model_output + token_count + 工具调用决策 → 更新 agent_step
|
||||
│
|
||||
├── 工具执行(如 lookup_knowledge)
|
||||
│ └── LookupKnowledgeTool 记录 tool_invocation(L0/L1 明细、耗时、是否截断)
|
||||
│
|
||||
└── 循环直到模型不再调用工具
|
||||
│
|
||||
▼
|
||||
3. 诊断完成 → 更新 diagnosis_session
|
||||
├── status = SUCCESS / FAILED
|
||||
├── 汇总指标:total_duration_ms / total_token_count / step_count / tool_call_count
|
||||
└── self_evaluation(可选,由 LLM 自评)
|
||||
```
|
||||
|
||||
### 4.2 变更点
|
||||
|
||||
| 模块 | 当前行为 | 改造后 |
|
||||
|------|---------|--------|
|
||||
| `AgentLoggingHook` | 只打日志到 stdout | 同时写入 `agent_step` 表 |
|
||||
| `LookupKnowledgeTool` | 只打日志到 stdout | 同时写入 `tool_invocation` 表 |
|
||||
| `ChatService` / `AiOpsService` | 执行前后无持久化 | 创建 + 更新 `diagnosis_session` |
|
||||
|
||||
---
|
||||
|
||||
## 五、可观测能力
|
||||
|
||||
### 5.1 查询场景
|
||||
|
||||
| 需求 | SQL | 说明 |
|
||||
|------|-----|------|
|
||||
| 某次诊断用了哪些工具 | `SELECT * FROM tool_invocation WHERE session_id=?` | 按 session 关联 |
|
||||
| lookup_knowledge 的 L0/L1 命中率 | `SELECT retrieval_layer, COUNT(*) FROM tool_invocation WHERE tool_name='lookup_knowledge' GROUP BY retrieval_layer` | 聚合检索层分布 |
|
||||
| 某个 Agent 的平均思考耗时 | `SELECT AVG(duration_ms) FROM agent_step WHERE agent_name=?` | 按 Agent 分组 |
|
||||
| 某次诊断的完整决策链 | `SELECT * FROM agent_step WHERE session_id=? ORDER BY step_index` | 按步骤号排序 |
|
||||
| 被截断的检索占比 | `SELECT COUNT(*) FROM tool_invocation WHERE is_truncated=true AND tool_name='lookup_knowledge'` | 条件计数 |
|
||||
| 高置信度但用户反馈 negative | `SELECT * FROM diagnosis_session WHERE JSON_EXTRACT(self_evaluation, '$.confidence') > 80 AND feedback='not_useful'` | JSON 条件查询 |
|
||||
|
||||
### 5.2 评估指标
|
||||
|
||||
| 指标 | 计算方式 | 数据来源 |
|
||||
|------|---------|---------|
|
||||
| 平均诊断耗时 | `AVG(total_duration_ms)` | diagnosis_session |
|
||||
| 平均 Token 消耗 | `AVG(total_token_count)` | diagnosis_session |
|
||||
| 工具采纳率 | `tools_accepted / tools_proposed` | self_evaluation |
|
||||
| L0 命中率 | `l0_match_count > 0 的比例` | tool_invocation |
|
||||
| 截断率 | `is_truncated=true 的比例` | tool_invocation |
|
||||
| 用户满意度 | `feedback='useful' 的比例` | diagnosis_session |
|
||||
|
||||
---
|
||||
|
||||
## 六、与现有表的关系
|
||||
|
||||
### 6.1 diagnosis_session vs 现有 diagnosis_record
|
||||
|
||||
- **`diagnosis_record`** 保持不动,继续用于"告警分析"场景的领域字段(root_cause、solution 等)
|
||||
- **`diagnosis_session`** 是通用会话存储,覆盖 ChatService 和 AiOpsService
|
||||
- 两者通过 `session_id` 可关联
|
||||
|
||||
### 6.2 迁移策略
|
||||
|
||||
| 阶段 | 动作 |
|
||||
|:----:|------|
|
||||
| MVP | 新建三张表,新代码写入新表 |
|
||||
| V1.1 | 评估是否将 diagnosis_record 合并回 diagnosis_session(加 fault 相关字段到 JSON) |
|
||||
| V1.2 | 数据量 > 10 万时评估是否需要归档或迁移 |
|
||||
|
||||
---
|
||||
|
||||
## 七、未完成事项
|
||||
|
||||
- [ ] AI Ops Supervisor 的 Agent 执行步骤如何对应 agent_step 表(Supervisor 内嵌的子 Agent 步骤归到同一个 session 还是独立)
|
||||
- [ ] self_evaluation 的 confidence 自评通过什么方式获取(单独的 LLM 调用还是在 prompt 中要求输出)
|
||||
- [ ] feedback 字段和前端的交互方式
|
||||
- [ ] Tool_invocation 的 output_preview 截断策略(当前建议 500 字符)
|
||||
|
||||
---
|
||||
|
||||
## 八、实现变更记录
|
||||
|
||||
### 8.1 与设计文档的差异
|
||||
|
||||
| 设计 | 实现 | 原因 |
|
||||
|------|------|------|
|
||||
| AgentLoggingHook 为 @Component | 改为 POJO(构造注入 Repository + agentName) | 需要为 ChatService / AiOpsService 创建多个 Hook 实例(不同 agentName) |
|
||||
| sessionId 通过 RunnableConfig 的 metadata 携带 | 通过 `RunnableConfig.builder().addMetadata("sessionId", id)` 构建 | 确认框架 API 原生支持,线程安全 |
|
||||
| Token 从 ChatResponse 获取 | 增加了 `TokenTrackingChatModel` 包装器拦截 ChatModel.call() | 框架的 `_TOKEN_USAGE_` 仅在 stream 路径可用,call 路径需自行拦截 |
|
||||
| sessionId 汇总后回填 | `backfillSessionMetrics()` 从 agent_step 表统计 | 避免在 Hook 中维护累加状态 |
|
||||
|
||||
### 8.2 新增文件(超出原设计)
|
||||
|
||||
| 文件 | 用途 |
|
||||
|------|------|
|
||||
| `TokenTrackingChatModel.java` | ChatModel 包装器,拦截 call() 获取实际 token 用量 |
|
||||
| `TokenUsageHolder.java` | ThreadLocal 传递 token 数给 Hook |
|
||||
| `QuestionComplexity.java` | 问题复杂度判断,路由单 Agent / 多 Agent |
|
||||
| `SessionContextHolder.java` | ThreadLocal 传递 sessionId(同步路径兜底) |
|
||||
|
||||
### 8.3 删除文件
|
||||
|
||||
| 文件 | 原因 |
|
||||
|------|------|
|
||||
| `DiagnosisRecord.java` / `DiagnosisRecordRepository.java` / `DiagnosisStatus.java` | 被新三表替代,V007 Flyway 迁移删除 |
|
||||
| `.docs/mvp/` | 内容合并到根目录 `mvp/` |
|
||||
|
||||
@@ -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
|
||||
@@ -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 测试通过
|
||||
@@ -15,7 +15,12 @@ import java.util.List;
|
||||
/**
|
||||
* 内部文档查询工具
|
||||
* 使用 RAG (Retrieval-Augmented Generation) 从内部知识库检索相关文档
|
||||
*
|
||||
* @deprecated 请使用 {@link com.superbiz.agent.tool.LookupKnowledgeTool} 替代。
|
||||
* lookup_knowledge 支持 L0 精确匹配 + L1 语义检索,性能更优且功能更全面。
|
||||
* 计划在下一个版本中移除此工具。
|
||||
*/
|
||||
@Deprecated
|
||||
@Component
|
||||
public class InternalDocsTools {
|
||||
|
||||
@@ -45,7 +50,9 @@ public class InternalDocsTools {
|
||||
*
|
||||
* @param query 搜索查询,描述您要查找的信息
|
||||
* @return JSON 格式的搜索结果,包含相关文档内容、相似度分数和元数据
|
||||
* @deprecated 请使用 {@link com.superbiz.agent.tool.LookupKnowledgeTool#lookupKnowledge(String)} 替代
|
||||
*/
|
||||
@Deprecated
|
||||
@Tool(description = "Use this tool to search internal documentation and knowledge base for relevant information. " +
|
||||
"It performs RAG (Retrieval-Augmented Generation) to find similar documents and extract processing steps. " +
|
||||
"This is useful when you need to understand internal procedures, best practices, or step-by-step guides " +
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.superbiz.agent.config;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* AI Ops Agent Prompt 配置
|
||||
* 从独立的 Markdown 文件加载 Prompt 模板
|
||||
*/
|
||||
@Slf4j
|
||||
@Configuration
|
||||
public class AiOpsPromptProperties {
|
||||
|
||||
private String planner;
|
||||
private String executor;
|
||||
private String supervisor;
|
||||
|
||||
@PostConstruct
|
||||
public void loadPrompts() {
|
||||
try {
|
||||
planner = loadPromptFromFile("prompts/planner-prompt.md");
|
||||
executor = loadPromptFromFile("prompts/executor-prompt.md");
|
||||
supervisor = loadPromptFromFile("prompts/supervisor-prompt.md");
|
||||
|
||||
log.info("AI Ops Prompts 加载成功");
|
||||
log.debug("Planner Prompt 长度: {} 字符", planner.length());
|
||||
log.debug("Executor Prompt 长度: {} 字符", executor.length());
|
||||
log.debug("Supervisor Prompt 长度: {} 字符", supervisor.length());
|
||||
} catch (IOException e) {
|
||||
log.error("加载 Prompt 文件失败", e);
|
||||
throw new RuntimeException("Failed to load AI Ops prompts", e);
|
||||
}
|
||||
}
|
||||
|
||||
private String loadPromptFromFile(String path) throws IOException {
|
||||
ClassPathResource resource = new ClassPathResource(path);
|
||||
return new String(resource.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
public String getPlanner() {
|
||||
return planner;
|
||||
}
|
||||
|
||||
public String getExecutor() {
|
||||
return executor;
|
||||
}
|
||||
|
||||
public String getSupervisor() {
|
||||
return supervisor;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.superbiz.agent.controller;
|
||||
|
||||
import com.superbiz.agent.service.KnowledgeBaseInitService;
|
||||
import lombok.Data;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 知识库管理控制器
|
||||
* 提供知识库初始化、查询等接口
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/knowledge")
|
||||
public class KnowledgeBaseController {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(KnowledgeBaseController.class);
|
||||
|
||||
@Autowired
|
||||
private KnowledgeBaseInitService initService;
|
||||
|
||||
/**
|
||||
* 初始化知识库
|
||||
* 扫描 knowledge_base 目录下的所有文档,去重后批量导入到数据库和 Milvus
|
||||
*
|
||||
* @param force 是否强制重新导入(跳过去重检查)
|
||||
* @return 初始化结果
|
||||
*/
|
||||
@PostMapping("/init")
|
||||
public ResponseEntity<?> initKnowledgeBase(@RequestParam(defaultValue = "false") boolean force) {
|
||||
logger.info("收到知识库初始化请求, force={}", force);
|
||||
|
||||
try {
|
||||
KnowledgeBaseInitService.InitResult result = initService.initializeKnowledgeBase(force);
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", true);
|
||||
response.put("message", "知识库初始化完成");
|
||||
response.put("scanned", result.getScanned());
|
||||
response.put("skipped", result.getSkipped());
|
||||
response.put("inserted", result.getInserted());
|
||||
response.put("failed", result.getFailed());
|
||||
response.put("details", result.getDetails());
|
||||
|
||||
logger.info("知识库初始化成功: 扫描={}, 跳过={}, 新增={}, 失败={}",
|
||||
result.getScanned(), result.getSkipped(), result.getInserted(), result.getFailed());
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("知识库初始化失败", e);
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", false);
|
||||
response.put("message", "初始化失败: " + e.getMessage());
|
||||
|
||||
return ResponseEntity.internalServerError().body(response);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询知识库统计信息
|
||||
*
|
||||
* @return 统计信息
|
||||
*/
|
||||
@GetMapping("/stats")
|
||||
public ResponseEntity<?> getStats() {
|
||||
try {
|
||||
KnowledgeBaseInitService.Stats stats = initService.getStats();
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", true);
|
||||
response.put("totalDocuments", stats.getTotalDocuments());
|
||||
response.put("totalVectors", stats.getTotalVectors());
|
||||
response.put("categories", stats.getCategoryCount());
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("查询统计信息失败", e);
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", false);
|
||||
response.put("message", "查询失败: " + e.getMessage());
|
||||
|
||||
return ResponseEntity.internalServerError().body(response);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,7 @@ public class ApiDocument {
|
||||
// 文档分类
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "fault_category", length = 32, columnDefinition = "VARCHAR(32)")
|
||||
private FaultCategory faultCategory = FaultCategory.EXTERNAL_API;
|
||||
private FaultCategory faultCategory = FaultCategory.GENERAL;
|
||||
|
||||
@Column(name = "fault_source", length = 128)
|
||||
private String faultSource;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,14 @@
|
||||
package com.superbiz.agent.domain.enums;
|
||||
|
||||
/**
|
||||
* 故障类别枚举
|
||||
* 文档分类枚举
|
||||
*/
|
||||
public enum FaultCategory {
|
||||
EXTERNAL_API("外部接口调用失败"),
|
||||
INTERNAL_ERROR("系统内部错误"),
|
||||
DATABASE("数据库问题"),
|
||||
CACHE("缓存问题"),
|
||||
NETWORK("网络问题"),
|
||||
THREAD("线程问题"),
|
||||
MEMORY("内存问题"),
|
||||
CONFIG("配置问题");
|
||||
API("API 接口文档"),
|
||||
INFRASTRUCTURE("基础设施文档"),
|
||||
DOMAIN("领域业务文档"),
|
||||
TROUBLESHOOTING("故障排查文档"),
|
||||
GENERAL("通用文档");
|
||||
|
||||
private final String description;
|
||||
|
||||
@@ -22,4 +19,26 @@ public enum FaultCategory {
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从字符串映射到枚举
|
||||
*/
|
||||
public static FaultCategory fromString(String category) {
|
||||
if (category == null || category.isEmpty()) {
|
||||
return GENERAL;
|
||||
}
|
||||
|
||||
switch (category.toLowerCase()) {
|
||||
case "api":
|
||||
return API;
|
||||
case "infrastructure":
|
||||
return INFRASTRUCTURE;
|
||||
case "domain":
|
||||
return DOMAIN;
|
||||
case "troubleshooting":
|
||||
return TROUBLESHOOTING;
|
||||
default:
|
||||
return GENERAL;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,4 +38,10 @@ public class DocumentChunk {
|
||||
* 分片标题或上下文信息
|
||||
*/
|
||||
private String title;
|
||||
|
||||
/**
|
||||
* 面包屑导航(完整标题层级路径)
|
||||
* 例如: "故障诊断流程规范 > 应急响应流程 > 1. 初步评估"
|
||||
*/
|
||||
private String breadcrumb;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
package com.superbiz.agent.hook;
|
||||
|
||||
import com.alibaba.cloud.ai.graph.agent.hook.messages.MessagesModelHook;
|
||||
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_step 到 DB
|
||||
*/
|
||||
@Slf4j
|
||||
@HookPositions({HookPosition.BEFORE_MODEL, HookPosition.AFTER_MODEL})
|
||||
public class AgentLoggingHook extends MessagesModelHook {
|
||||
|
||||
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
|
||||
public String getName() {
|
||||
return "agent_logging_hook";
|
||||
}
|
||||
|
||||
@Override
|
||||
public AgentCommand beforeModel(List<Message> previousMessages, RunnableConfig config) {
|
||||
// 优先从 config.metadata 取 sessionId(线程安全),兜底 ThreadLocal
|
||||
String sessionId = config.metadata("sessionId")
|
||||
.map(Object::toString)
|
||||
.orElseGet(SessionContextHolder::getSessionId);
|
||||
|
||||
boolean hasSession = (sessionId != null);
|
||||
|
||||
int stepIndex = 0;
|
||||
if (hasSession) {
|
||||
stepIndex = stepCounters.merge(sessionId, 0, (old, one) -> old + 1);
|
||||
}
|
||||
|
||||
log.info("========================================");
|
||||
log.info("*** [Agent 思考] 第 {} 轮思考开始", (hasSession ? stepCounters.get(sessionId) : 0) + 1);
|
||||
log.info("*** [Agent 思考] 当前消息数量: {}", previousMessages.size());
|
||||
|
||||
// 打印最后几条消息
|
||||
int lastN = Math.min(3, previousMessages.size());
|
||||
if (lastN > 0) {
|
||||
log.info("*** [Agent 思考] 最近 {} 条消息:", lastN);
|
||||
List<Message> 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());
|
||||
}
|
||||
}
|
||||
|
||||
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<Message> previousMessages, RunnableConfig config) {
|
||||
String sessionId = SessionContextHolder.getSessionId();
|
||||
boolean hasSession = (sessionId != null);
|
||||
|
||||
log.info("========================================");
|
||||
log.info("*** [Agent 思考] 第 {} 轮思考完成", (hasSession ? stepCounters.getOrDefault(sessionId, 0) : 0));
|
||||
|
||||
// 查找最后一条 AssistantMessage(模型的回复)
|
||||
AssistantMessage lastAssistant = null;
|
||||
for (int i = previousMessages.size() - 1; i >= 0; i--) {
|
||||
if (previousMessages.get(i) instanceof AssistantMessage) {
|
||||
lastAssistant = (AssistantMessage) previousMessages.get(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
boolean hasToolCall = false;
|
||||
|
||||
if (lastAssistant != null) {
|
||||
// 打印模型返回的文本内容
|
||||
String textContent = extractTextContent(lastAssistant);
|
||||
if (textContent != null && !textContent.isEmpty()) {
|
||||
log.info("*** [Agent 思考] 模型返回文本: {}",
|
||||
textContent.length() > 500
|
||||
? textContent.substring(0, 500) + "... (已截断,总长度: " + textContent.length() + ")"
|
||||
: textContent);
|
||||
}
|
||||
|
||||
// 检查是否有工具调用
|
||||
if (lastAssistant.getToolCalls() != null && !lastAssistant.getToolCalls().isEmpty()) {
|
||||
hasToolCall = true;
|
||||
log.info("*** [Agent 思考] 模型决定调用 {} 个工具:",
|
||||
lastAssistant.getToolCalls().size());
|
||||
lastAssistant.getToolCalls().forEach(toolCall -> {
|
||||
log.info(" - 工具: {}, 参数: {}",
|
||||
toolCall.name(),
|
||||
toolCall.arguments());
|
||||
});
|
||||
log.info("*** [Agent 思考] 等待工具执行结果...");
|
||||
} else {
|
||||
log.info("*** [Agent 思考] 模型决定不调用工具");
|
||||
log.info("*** [Agent 思考] 这是最终答案,准备返回给用户");
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建模型输入摘要(前 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 的文本内容
|
||||
*/
|
||||
private String extractTextContent(AssistantMessage message) {
|
||||
if (message == null) return null;
|
||||
try {
|
||||
// 方法 1: 反射获取 text 字段
|
||||
try {
|
||||
java.lang.reflect.Field textField = message.getClass().getDeclaredField("text");
|
||||
textField.setAccessible(true);
|
||||
Object value = textField.get(message);
|
||||
if (value != null) {
|
||||
log.debug("通过 text 字段提取成功");
|
||||
return value.toString();
|
||||
}
|
||||
} catch (NoSuchFieldException e) {
|
||||
// 尝试下一种方法
|
||||
}
|
||||
|
||||
// 方法 2: 反射获取 content 字段
|
||||
try {
|
||||
java.lang.reflect.Field contentField = message.getClass().getDeclaredField("content");
|
||||
contentField.setAccessible(true);
|
||||
Object value = contentField.get(message);
|
||||
if (value != null) {
|
||||
log.debug("通过 content 字段提取成功");
|
||||
return value.toString();
|
||||
}
|
||||
} catch (NoSuchFieldException e) {
|
||||
// 尝试下一种方法
|
||||
}
|
||||
|
||||
// 方法 3: 调用 getText() 方法
|
||||
try {
|
||||
java.lang.reflect.Method getTextMethod = message.getClass().getMethod("getText");
|
||||
Object value = getTextMethod.invoke(message);
|
||||
if (value != null) {
|
||||
log.debug("通过 getText() 方法提取成功");
|
||||
return value.toString();
|
||||
}
|
||||
} catch (NoSuchMethodException e) {
|
||||
// 尝试下一种方法
|
||||
}
|
||||
|
||||
// 方法 4: 调用 getContent() 方法
|
||||
try {
|
||||
java.lang.reflect.Method getContentMethod = message.getClass().getMethod("getContent");
|
||||
Object value = getContentMethod.invoke(message);
|
||||
if (value != null) {
|
||||
log.debug("通过 getContent() 方法提取成功");
|
||||
return value.toString();
|
||||
}
|
||||
} catch (NoSuchMethodException e) {
|
||||
// 方法不存在
|
||||
}
|
||||
|
||||
// 方法 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());
|
||||
}
|
||||
|
||||
// 方法 6: toString() 兜底
|
||||
String toString = message.toString();
|
||||
if (toString != null && !toString.startsWith("AssistantMessage@")) {
|
||||
log.debug("通过 toString() 提取");
|
||||
return toString;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (Exception e) {
|
||||
log.error("提取 AssistantMessage 文本内容时出错", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取消息角色
|
||||
*/
|
||||
private String getMessageRole(Message message) {
|
||||
if (message instanceof UserMessage) {
|
||||
return "User(用户)";
|
||||
} else if (message instanceof AssistantMessage) {
|
||||
return "Assistant(模型)";
|
||||
} else if (message instanceof ToolResponseMessage) {
|
||||
return "Tool(工具返回)";
|
||||
} else {
|
||||
return message.getClass().getSimpleName();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.superbiz.agent.hook;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
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 static final Logger log = LoggerFactory.getLogger(TokenTrackingChatModel.class);
|
||||
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
var usage = response.getMetadata().getUsage();
|
||||
Integer total = usage.getTotalTokens();
|
||||
if (total != null && total > 0) {
|
||||
TokenUsageHolder.set(total);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("捕获 token 用量失败", 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,15 +9,25 @@ 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;
|
||||
import org.springframework.ai.tool.ToolCallback;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import com.superbiz.agent.config.AiOpsPromptProperties;
|
||||
import com.superbiz.agent.tool.LookupKnowledgeTool;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* AI Ops 智能运维服务
|
||||
@@ -40,6 +50,18 @@ public class AiOpsService {
|
||||
@Autowired(required = false) // Mock 模式下才注册
|
||||
private QueryLogsTools queryLogsTools;
|
||||
|
||||
@Autowired
|
||||
private LookupKnowledgeTool lookupKnowledgeTool;
|
||||
|
||||
@Autowired
|
||||
private AiOpsPromptProperties promptProperties;
|
||||
|
||||
@Autowired
|
||||
private DiagnosisSessionRepository diagnosisSessionRepository;
|
||||
|
||||
@Autowired
|
||||
private AgentStepRepository agentStepRepository;
|
||||
|
||||
/**
|
||||
* 执行 AI Ops 告警分析流程
|
||||
*
|
||||
@@ -51,34 +73,65 @@ public class AiOpsService {
|
||||
public Optional<OverAllState> 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(buildSupervisorSystemPrompt())
|
||||
.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<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();
|
||||
|
||||
// 添加调试代码
|
||||
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<OverAllState> 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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -113,9 +166,10 @@ public class AiOpsService {
|
||||
.name("planner_agent")
|
||||
.description("负责拆解告警、规划与再规划步骤")
|
||||
.model(chatModel)
|
||||
.systemPrompt(buildPlannerPrompt())
|
||||
.systemPrompt(promptProperties.getPlanner())
|
||||
.methodTools(buildMethodToolsArray())
|
||||
.tools(toolCallbacks)
|
||||
.hooks(new AgentLoggingHook(agentStepRepository, "planner"))
|
||||
.outputKey("planner_plan")
|
||||
.build();
|
||||
}
|
||||
@@ -128,9 +182,10 @@ public class AiOpsService {
|
||||
.name("executor_agent")
|
||||
.description("负责执行 Planner 的首个步骤并及时反馈")
|
||||
.model(chatModel)
|
||||
.systemPrompt(buildExecutorPrompt())
|
||||
.systemPrompt(promptProperties.getExecutor())
|
||||
.methodTools(buildMethodToolsArray())
|
||||
.tools(toolCallbacks)
|
||||
.hooks(new AgentLoggingHook(agentStepRepository, "executor"))
|
||||
.outputKey("executor_feedback")
|
||||
.build();
|
||||
}
|
||||
@@ -138,152 +193,37 @@ public class AiOpsService {
|
||||
/**
|
||||
* 动态构建方法工具数组
|
||||
* 根据 cls.mock-enabled 决定是否包含 QueryLogsTools
|
||||
* 工具顺序:知识库查询优先,日志查询次之,弃用工具最后
|
||||
*/
|
||||
private Object[] buildMethodToolsArray() {
|
||||
if (queryLogsTools != null) {
|
||||
// Mock 模式:包含 QueryLogsTools
|
||||
return new Object[]{dateTimeTools, internalDocsTools, queryMetricsTools, queryLogsTools};
|
||||
return new Object[]{dateTimeTools, lookupKnowledgeTool, queryMetricsTools, queryLogsTools};
|
||||
} else {
|
||||
// 真实模式:不包含 QueryLogsTools(由 MCP 提供日志查询功能)
|
||||
return new Object[]{dateTimeTools, internalDocsTools, queryMetricsTools};
|
||||
return new Object[]{dateTimeTools, lookupKnowledgeTool, queryMetricsTools};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建 Planner Agent 系统提示词
|
||||
*/
|
||||
private String buildPlannerPrompt() {
|
||||
return """
|
||||
你是 Planner Agent,同时承担 Replanner 角色,负责:
|
||||
1. 读取当前输入任务 {input} 以及 Executor 的最近反馈 {executor_feedback}。
|
||||
2. 分析 Prometheus 告警、日志、内部文档等信息,制定可执行的下一步步骤。
|
||||
3. 在执行阶段,输出 JSON,包含 decision (PLAN|EXECUTE|FINISH)、step 描述、预期要调用的工具、以及必要的上下文。
|
||||
4. 调用任何腾讯云日志/主题相关工具时,region 参数必须使用连字符格式(如 ap-guangzhou),若不确定请省略以使用默认值。
|
||||
5. 严格禁止编造数据,只能引用工具返回的真实内容;如果连续 3 次调用同一工具仍失败或返回空结果,需停止该方向并在最终报告的结论部分说明"无法完成"的原因。
|
||||
|
||||
## 最终报告输出要求(CRITICAL)
|
||||
|
||||
当 decision=FINISH 时,你必须:
|
||||
1. **不要输出 JSON 格式**
|
||||
2. **直接输出完整的 Markdown 格式报告文本**
|
||||
3. **报告必须严格遵循以下模板**:
|
||||
|
||||
```
|
||||
# 告警分析报告
|
||||
|
||||
---
|
||||
|
||||
## 📋 活跃告警清单
|
||||
|
||||
| 告警名称 | 级别 | 目标服务 | 首次触发时间 | 最新触发时间 | 状态 |
|
||||
|---------|------|----------|-------------|-------------|------|
|
||||
| [告警1名称] | [级别] | [服务名] | [时间] | [时间] | 活跃 |
|
||||
| [告警2名称] | [级别] | [服务名] | [时间] | [时间] | 活跃 |
|
||||
|
||||
---
|
||||
|
||||
## 🔍 告警根因分析1 - [告警名称]
|
||||
|
||||
### 告警详情
|
||||
- **告警级别**: [级别]
|
||||
- **受影响服务**: [服务名]
|
||||
- **持续时间**: [X分钟]
|
||||
|
||||
### 症状描述
|
||||
[根据监控指标描述症状]
|
||||
|
||||
### 日志证据
|
||||
[引用查询到的关键日志]
|
||||
|
||||
### 根因结论
|
||||
[基于证据得出的根本原因]
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 处理方案执行1 - [告警名称]
|
||||
|
||||
### 已执行的排查步骤
|
||||
1. [步骤1]
|
||||
2. [步骤2]
|
||||
|
||||
### 处理建议
|
||||
[给出具体的处理建议]
|
||||
|
||||
### 预期效果
|
||||
[说明预期的效果]
|
||||
|
||||
---
|
||||
|
||||
## 🔍 告警根因分析2 - [告警名称]
|
||||
[如果有第2个告警,重复上述格式]
|
||||
|
||||
---
|
||||
|
||||
## 📊 结论
|
||||
|
||||
### 整体评估
|
||||
[总结所有告警的整体情况]
|
||||
|
||||
### 关键发现
|
||||
- [发现1]
|
||||
- [发现2]
|
||||
|
||||
### 后续建议
|
||||
1. [建议1]
|
||||
2. [建议2]
|
||||
|
||||
### 风险评估
|
||||
[评估当前风险等级和影响范围]
|
||||
```
|
||||
|
||||
**重要提醒**:
|
||||
- 最终输出必须是纯 Markdown 文本,不要包含 JSON 结构
|
||||
- 不要使用 "finalReport": "..." 这样的格式
|
||||
- 直接从 "# 告警分析报告" 开始输出
|
||||
- 所有内容必须基于工具查询的真实数据,严禁编造
|
||||
- 如果某个步骤失败,在结论中如实说明,不要跳过
|
||||
|
||||
""";
|
||||
}
|
||||
/** 从 agent_step 汇总指标回填 diagnosis_session */
|
||||
private void backfillSessionMetrics(DiagnosisSession session) {
|
||||
try {
|
||||
List<AgentStep> steps = agentStepRepository.findBySessionIdOrderByStepIndex(session.getSessionId());
|
||||
if (steps.isEmpty()) return;
|
||||
|
||||
/**
|
||||
* 构建 Executor Agent 系统提示词
|
||||
*/
|
||||
private String buildExecutorPrompt() {
|
||||
return """
|
||||
你是 Executor Agent,负责读取 Planner 最新输出 {planner_plan},只执行其中的第一步。
|
||||
- 确认步骤所需的工具与参数,尤其是 region 参数要使用连字符格式(ap-guangzhou);若 Planner 未给出则使用默认区域。
|
||||
- 调用相应的工具并收集结果,如工具返回错误或空数据,需要将失败原因、请求参数一并记录,并停止进一步调用该工具(同一工具失败达到 3 次时应直接返回 FAILED)。
|
||||
- 将日志、指标、文档等证据整理成结构化摘要,标注对应的告警名称或资源,方便 Planner 填充"告警根因分析 / 处理方案执行"章节。
|
||||
- 以 JSON 形式返回执行状态、证据以及给 Planner 的建议,写入 executor_feedback,严禁编造未实际查询到的内容。
|
||||
|
||||
|
||||
输出示例:
|
||||
{
|
||||
"status": "SUCCESS",
|
||||
"summary": "近1小时未见 error 日志,仅有 info",
|
||||
"evidence": "...",
|
||||
"nextHint": "建议转向高占用进程"
|
||||
}
|
||||
""";
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建 Supervisor Agent 系统提示词
|
||||
*/
|
||||
private String buildSupervisorSystemPrompt() {
|
||||
return """
|
||||
你是 AI Ops Supervisor,负责调度 planner_agent 与 executor_agent:
|
||||
1. 当需要拆解任务或重新制定策略时,调用 planner_agent。
|
||||
2. 当 planner_agent 输出 decision=EXECUTE 时,调用 executor_agent 执行第一步。
|
||||
3. 根据 executor_agent 的反馈,评估是否需要再次调用 planner_agent,直到 decision=FINISH。
|
||||
4. FINISH 后,确保向最终用户输出完整的《告警分析报告》,格式必须严格为:
|
||||
告警分析报告\n---\n# 告警处理详情\n## 活跃告警清单\n## 告警根因分析N\n## 处理方案执行N\n## 结论。
|
||||
5. 若步骤涉及腾讯云日志/主题工具,请确保使用连字符区域 ID(ap-guangzhou 等),或省略 region 以采用默认值。
|
||||
6. 如果发现 Planner/Executor 在同一方向连续 3 次调用工具仍失败或没有数据,必须终止流程,直接输出"任务无法完成"的报告,明确告知失败原因,严禁凭空编造结果。
|
||||
|
||||
只允许在 planner_agent、executor_agent 与 FINISH 之间做出选择。
|
||||
|
||||
""";
|
||||
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,21 +1,41 @@
|
||||
package com.superbiz.agent.service;
|
||||
|
||||
import com.alibaba.cloud.ai.graph.OverAllState;
|
||||
import com.alibaba.cloud.ai.graph.RunnableConfig;
|
||||
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.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;
|
||||
|
||||
/**
|
||||
* 聊天服务
|
||||
@@ -44,6 +64,40 @@ public class ChatService {
|
||||
@Autowired
|
||||
private ChatModel chatModel;
|
||||
|
||||
@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
|
||||
*/
|
||||
@@ -62,7 +116,7 @@ public class ChatService {
|
||||
// 基础系统提示
|
||||
systemPromptBuilder.append("你是一个专业的智能助手,可以获取当前时间、查询天气信息、搜索内部文档知识库,以及查询 Prometheus 告警信息。\n");
|
||||
systemPromptBuilder.append("当用户询问时间相关问题时,**必须每次都调用 getCurrentDateTime 工具**,因为时间会不断变化。即使历史消息中有时间信息,也不要直接复用,必须重新查询最新时间。\n");
|
||||
systemPromptBuilder.append("当用户需要查询公司内部文档、流程、最佳实践或技术指南时,使用 queryInternalDocs 工具。\n");
|
||||
systemPromptBuilder.append("当用户需要查询公司内部文档、流程、最佳实践或技术指南时,使用 lookupKnowledgeTool 工具。\n");
|
||||
systemPromptBuilder.append("当用户需要查询 Prometheus 告警、监控指标或系统告警状态时,使用 queryPrometheusAlerts 工具。\n");
|
||||
systemPromptBuilder.append("当用户需要查询腾讯云日志时,请调用腾讯云mcp服务查询,默认查询地域ap-guangzhou,查询时间范围为近一个月。\n\n");
|
||||
|
||||
@@ -126,10 +180,10 @@ public class ChatService {
|
||||
public Object[] buildMethodToolsArray() {
|
||||
if (queryLogsTools != null) {
|
||||
// Mock 模式:包含 QueryLogsTools
|
||||
return new Object[]{dateTimeTools, internalDocsTools, queryMetricsTools, queryLogsTools};
|
||||
return new Object[]{dateTimeTools, lookupKnowledgeTool};
|
||||
} else {
|
||||
// 真实模式:不包含 QueryLogsTools(由 MCP 提供日志查询功能)
|
||||
return new Object[]{dateTimeTools, internalDocsTools, queryMetricsTools};
|
||||
return new Object[]{dateTimeTools, lookupKnowledgeTool, queryMetricsTools};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,6 +225,7 @@ public class ChatService {
|
||||
.systemPrompt(systemPrompt)
|
||||
.methodTools(buildMethodToolsArray())
|
||||
.tools(getToolCallbacks())
|
||||
.hooks(new AgentLoggingHook(agentStepRepository, "intelligent_assistant"))
|
||||
.build();
|
||||
}
|
||||
|
||||
@@ -181,10 +236,209 @@ public class ChatService {
|
||||
* @return AI 回复
|
||||
*/
|
||||
public String executeChat(ReactAgent agent, String question) throws GraphRunnerException {
|
||||
logger.info("执行 ReactAgent.call() - 自动处理工具调用");
|
||||
var response = agent.call(question);
|
||||
String answer = response.getText();
|
||||
logger.info("ReactAgent 对话完成,答案长度: {}", answer.length());
|
||||
return answer;
|
||||
logger.info("========================================");
|
||||
logger.info("📝 用户问题: {}", question);
|
||||
|
||||
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);
|
||||
|
||||
// 设置 ThreadLocal 上下文(LookupKnowledgeTool 通过此获取 sessionId)
|
||||
SessionContextHolder.setSessionId(sessionId);
|
||||
|
||||
try {
|
||||
// 通过 RunnableConfig 将 sessionId 传入 Hook(线程安全,异步也兼容)
|
||||
var config = RunnableConfig.builder()
|
||||
.addMetadata("sessionId", sessionId)
|
||||
.build();
|
||||
|
||||
var response = agent.call(question, config);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ public class DocumentChunkService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 按照 Markdown 标题分割文档
|
||||
* 按照 Markdown 标题分割文档,同时构建面包屑层级路径
|
||||
*/
|
||||
private List<Section> splitByHeadings(String content) {
|
||||
List<Section> sections = new ArrayList<>();
|
||||
@@ -65,20 +65,34 @@ public class DocumentChunkService {
|
||||
Pattern headingPattern = Pattern.compile("^(#{1,6})\\s+(.+)$", Pattern.MULTILINE);
|
||||
Matcher matcher = headingPattern.matcher(content);
|
||||
|
||||
// 标题层级栈:维护当前标题的完整路径
|
||||
List<String> headingStack = new ArrayList<>();
|
||||
int lastEnd = 0;
|
||||
String currentTitle = null;
|
||||
String currentBreadcrumb = null;
|
||||
|
||||
while (matcher.find()) {
|
||||
int level = matcher.group(1).length(); // #→1, ##→2, ###→3 ...
|
||||
String title = matcher.group(2).trim();
|
||||
|
||||
// 保存上一个章节
|
||||
if (lastEnd < matcher.start()) {
|
||||
String sectionContent = content.substring(lastEnd, matcher.start()).trim();
|
||||
if (!sectionContent.isEmpty()) {
|
||||
sections.add(new Section(currentTitle, sectionContent, lastEnd));
|
||||
sections.add(new Section(
|
||||
headingStack.isEmpty() ? null : headingStack.get(headingStack.size() - 1),
|
||||
level,
|
||||
currentBreadcrumb,
|
||||
sectionContent,
|
||||
lastEnd));
|
||||
}
|
||||
}
|
||||
|
||||
// 更新当前标题
|
||||
currentTitle = matcher.group(2).trim();
|
||||
// 维护层级栈:同级别或更高级别 → 弹出,低级 → 追加
|
||||
while (!headingStack.isEmpty() && headingStack.size() >= level) {
|
||||
headingStack.remove(headingStack.size() - 1);
|
||||
}
|
||||
headingStack.add(title);
|
||||
currentBreadcrumb = String.join(" > ", headingStack);
|
||||
lastEnd = matcher.start();
|
||||
}
|
||||
|
||||
@@ -86,13 +100,18 @@ public class DocumentChunkService {
|
||||
if (lastEnd < content.length()) {
|
||||
String sectionContent = content.substring(lastEnd).trim();
|
||||
if (!sectionContent.isEmpty()) {
|
||||
sections.add(new Section(currentTitle, sectionContent, lastEnd));
|
||||
sections.add(new Section(
|
||||
headingStack.isEmpty() ? null : headingStack.get(headingStack.size() - 1),
|
||||
headingStack.size(),
|
||||
currentBreadcrumb,
|
||||
sectionContent,
|
||||
lastEnd));
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有找到任何标题,将整个文档作为一个章节
|
||||
if (sections.isEmpty()) {
|
||||
sections.add(new Section(null, content, 0));
|
||||
sections.add(new Section(null, 0, null, content, 0));
|
||||
}
|
||||
|
||||
return sections;
|
||||
@@ -111,6 +130,7 @@ public class DocumentChunkService {
|
||||
List<DocumentChunk> chunks = new ArrayList<>();
|
||||
String content = section.content;
|
||||
String title = section.title;
|
||||
String breadcrumb = section.breadcrumb;
|
||||
|
||||
// 短章节直接作为一个分片(用 token 估算替代字符数做短路判断)
|
||||
if (content.length() <= chunkConfig.getMaxSize()
|
||||
@@ -121,6 +141,7 @@ public class DocumentChunkService {
|
||||
.endOffset(section.startIndex + content.length())
|
||||
.chunkIndex(startChunkIndex)
|
||||
.title(title)
|
||||
.breadcrumb(breadcrumb)
|
||||
.build();
|
||||
chunks.add(chunk);
|
||||
return chunks;
|
||||
@@ -155,7 +176,7 @@ public class DocumentChunkService {
|
||||
logger.debug(" 触及硬上限 ({} tokens),强制切分", tokenCount + paraTokens);
|
||||
chunkParaStart = saveChunkAndGetNextStart(
|
||||
chunks, section, paraPositions,
|
||||
chunkParaStart, i, title, chunkIndex);
|
||||
chunkParaStart, i, title, breadcrumb, chunkIndex);
|
||||
chunkIndex++;
|
||||
|
||||
String prevChunkContent = chunks.get(chunks.size() - 1).getContent();
|
||||
@@ -168,7 +189,7 @@ public class DocumentChunkService {
|
||||
// 安全切点:段落边界
|
||||
chunkParaStart = saveChunkAndGetNextStart(
|
||||
chunks, section, paraPositions,
|
||||
chunkParaStart, i, title, chunkIndex);
|
||||
chunkParaStart, i, title, breadcrumb, chunkIndex);
|
||||
chunkIndex++;
|
||||
|
||||
// 新分片以重叠文本开头
|
||||
@@ -194,6 +215,7 @@ public class DocumentChunkService {
|
||||
.endOffset(section.startIndex + actualEnd)
|
||||
.chunkIndex(chunkIndex)
|
||||
.title(title)
|
||||
.breadcrumb(breadcrumb)
|
||||
.build();
|
||||
chunks.add(chunk);
|
||||
}
|
||||
@@ -213,6 +235,7 @@ public class DocumentChunkService {
|
||||
int fromPara,
|
||||
int toPara,
|
||||
String title,
|
||||
String breadcrumb,
|
||||
int chunkIndex) {
|
||||
|
||||
int actualStart = paraPositions.get(fromPara).start;
|
||||
@@ -225,6 +248,7 @@ public class DocumentChunkService {
|
||||
.endOffset(section.startIndex + actualEnd)
|
||||
.chunkIndex(chunkIndex)
|
||||
.title(title)
|
||||
.breadcrumb(breadcrumb)
|
||||
.build();
|
||||
chunks.add(chunk);
|
||||
|
||||
@@ -392,12 +416,16 @@ public class DocumentChunkService {
|
||||
* 章节数据类
|
||||
*/
|
||||
private static class Section {
|
||||
String title;
|
||||
String content;
|
||||
int startIndex;
|
||||
String title; // 最近一级标题名称
|
||||
int level; // 标题级别(1-6),0=无标题
|
||||
String breadcrumb; // 完整面包屑路径
|
||||
String content; // 章节内容
|
||||
int startIndex; // 在原文中的起始偏移
|
||||
|
||||
Section(String title, String content, int startIndex) {
|
||||
Section(String title, int level, String breadcrumb, String content, int startIndex) {
|
||||
this.title = title;
|
||||
this.level = level;
|
||||
this.breadcrumb = breadcrumb;
|
||||
this.content = content;
|
||||
this.startIndex = startIndex;
|
||||
}
|
||||
|
||||
@@ -287,12 +287,12 @@ public class DocumentManagementService {
|
||||
*/
|
||||
private FaultCategory parseFaultCategory(String category) {
|
||||
if (category == null || category.isBlank()) {
|
||||
return FaultCategory.EXTERNAL_API;
|
||||
return FaultCategory.GENERAL;
|
||||
}
|
||||
try {
|
||||
return FaultCategory.valueOf(category.toUpperCase());
|
||||
} catch (IllegalArgumentException e) {
|
||||
return FaultCategory.EXTERNAL_API;
|
||||
return FaultCategory.GENERAL;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
package com.superbiz.agent.service;
|
||||
|
||||
import com.superbiz.agent.domain.entity.ApiDocument;
|
||||
import com.superbiz.agent.domain.enums.FaultCategory;
|
||||
import com.superbiz.agent.repository.ApiDocumentRepository;
|
||||
import com.superbiz.agent.dto.KnowledgeEntry;
|
||||
import com.superbiz.agent.dto.Frontmatter;
|
||||
import com.superbiz.agent.dto.DocumentChunk;
|
||||
import lombok.Data;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.*;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 知识库初始化服务
|
||||
* 负责批量导入 knowledge_base 目录下的文档到数据库和 Milvus
|
||||
*/
|
||||
@Service
|
||||
public class KnowledgeBaseInitService {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(KnowledgeBaseInitService.class);
|
||||
|
||||
@Value("${knowledge.base-path:knowledge_base}")
|
||||
private String knowledgeBasePath;
|
||||
|
||||
@Autowired
|
||||
private ApiDocumentRepository apiDocumentRepository;
|
||||
|
||||
@Autowired
|
||||
private FrontmatterParser frontmatterParser;
|
||||
|
||||
@Autowired
|
||||
private DocumentChunkService documentChunkService;
|
||||
|
||||
@Autowired
|
||||
private VectorIndexService vectorIndexService;
|
||||
|
||||
@Autowired
|
||||
private VectorEmbeddingService vectorEmbeddingService;
|
||||
|
||||
@Autowired
|
||||
private KnowledgeIndexService knowledgeIndexService;
|
||||
|
||||
/**
|
||||
* 初始化知识库
|
||||
*
|
||||
* @param force 是否强制重新导入(跳过去重检查)
|
||||
* @return 初始化结果
|
||||
*/
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public InitResult initializeKnowledgeBase(boolean force) {
|
||||
logger.info("开始初始化知识库: basePath={}, force={}", knowledgeBasePath, force);
|
||||
|
||||
InitResult result = new InitResult();
|
||||
Path baseDir = Paths.get(knowledgeBasePath);
|
||||
|
||||
if (!Files.exists(baseDir)) {
|
||||
logger.error("知识库目录不存在: {}", knowledgeBasePath);
|
||||
throw new RuntimeException("知识库目录不存在: " + knowledgeBasePath);
|
||||
}
|
||||
|
||||
// 1. 扫描所有 Markdown 文件
|
||||
List<Path> markdownFiles = scanMarkdownFiles(baseDir);
|
||||
result.setScanned(markdownFiles.size());
|
||||
logger.info("扫描到 {} 个 Markdown 文件", markdownFiles.size());
|
||||
|
||||
// 2. 如果非强制模式,获取已存在的文档(用于去重)
|
||||
Set<String> existingFilePaths = new HashSet<>();
|
||||
if (!force) {
|
||||
existingFilePaths = apiDocumentRepository.findAll().stream()
|
||||
.map(ApiDocument::getFilePath)
|
||||
.collect(Collectors.toSet());
|
||||
logger.info("已存在 个文档记录", existingFilePaths.size());
|
||||
}
|
||||
|
||||
// 3. 逐个处理文档
|
||||
for (Path file : markdownFiles) {
|
||||
String relativePath = baseDir.relativize(file).toString().replace("\\", "/");
|
||||
|
||||
try {
|
||||
// 去重检查
|
||||
if (!force && existingFilePaths.contains(relativePath)) {
|
||||
logger.debug("跳过已存在的文档: {}", relativePath);
|
||||
result.incrementSkipped();
|
||||
result.addDetail(relativePath, "已存在,跳过");
|
||||
continue;
|
||||
}
|
||||
|
||||
// 解析文档
|
||||
String content = Files.readString(file);
|
||||
Frontmatter frontmatter = frontmatterParser.parse(content);
|
||||
|
||||
if (frontmatter == null) {
|
||||
logger.warn("文档格式无效: {}, frontmatter 解析失败", relativePath);
|
||||
result.incrementFailed();
|
||||
result.addDetail(relativePath, "格式无效: frontmatter 解析失败");
|
||||
continue;
|
||||
}
|
||||
|
||||
// 提取字段
|
||||
String title = frontmatter.getTitle();
|
||||
String summary = frontmatter.getSummary();
|
||||
String category = frontmatter.getCategory() != null ? frontmatter.getCategory() : "general";
|
||||
List<String> keywords = frontmatter.getKeywords();
|
||||
|
||||
if (title == null || title.isBlank()) {
|
||||
logger.warn("文档缺少标题: {}", relativePath);
|
||||
result.incrementFailed();
|
||||
result.addDetail(relativePath, "缺少标题");
|
||||
continue;
|
||||
}
|
||||
|
||||
// 保存到数据库
|
||||
ApiDocument document = saveToDatabase(relativePath, title, summary, category, content, keywords);
|
||||
|
||||
// 提取文档正文(去除 frontmatter)
|
||||
String body = extractBody(content);
|
||||
|
||||
// 文档分块
|
||||
List<DocumentChunk> chunks = documentChunkService.chunkDocument(body, relativePath);
|
||||
logger.debug("文档分块完成: {} -> {} 个 chunk", relativePath, chunks.size());
|
||||
|
||||
// 上传到 Milvus
|
||||
try {
|
||||
vectorIndexService.indexDocumentChunks(document.getDocId(), chunks, category);
|
||||
|
||||
document.setStatus("INDEXED");
|
||||
document.setChunkCount(chunks.size());
|
||||
document.setIndexedAt(LocalDateTime.now());
|
||||
apiDocumentRepository.save(document);
|
||||
|
||||
logger.info("文档已索引到 Milvus: {} (docId={}, chunks={})",
|
||||
title, document.getDocId(), chunks.size());
|
||||
} catch (Exception e) {
|
||||
logger.error("上传到 Milvus 失败: {}", relativePath, e);
|
||||
|
||||
document.setStatus("FAILED");
|
||||
document.setErrorMessage(e.getMessage());
|
||||
apiDocumentRepository.save(document);
|
||||
|
||||
result.incrementFailed();
|
||||
result.addDetail(relativePath, "Milvus 索引失败: " + e.getMessage());
|
||||
continue; // 跳过该文档,继续处理下一个
|
||||
}
|
||||
|
||||
// 添加到 L0 内存索引
|
||||
KnowledgeEntry entry = KnowledgeEntry.builder()
|
||||
.filePath(relativePath)
|
||||
.title(title)
|
||||
.keywords(keywords)
|
||||
.summary(summary)
|
||||
.category(category)
|
||||
.build();
|
||||
knowledgeIndexService.addToIndex(entry);
|
||||
|
||||
result.incrementInserted();
|
||||
result.addDetail(relativePath, "导入成功(L0+L1)");
|
||||
logger.info("文档导入成功: {} -> {} (L0+L1 索引已更新)", relativePath, title);
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("处理文档失败: {}", relativePath, e);
|
||||
result.incrementFailed();
|
||||
result.addDetail(relativePath, "处理失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("知识库初始化完成: 扫描={}, 跳过={}, 新增={}, 失败={}",
|
||||
result.getScanned(), result.getSkipped(), result.getInserted(), result.getFailed());
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取知识库统计信息
|
||||
*/
|
||||
public Stats getStats() {
|
||||
Stats stats = new Stats();
|
||||
|
||||
// 数据库中的文档数量
|
||||
long totalDocuments = apiDocumentRepository.count();
|
||||
stats.setTotalDocuments(totalDocuments);
|
||||
|
||||
// L0 索引中的文档数量
|
||||
int indexSize = knowledgeIndexService.getIndexSize();
|
||||
logger.debug("L0 索引大小: {}", indexSize);
|
||||
|
||||
// 按分类统计(从 fault_category 字段读取)
|
||||
Map<String, Long> categoryCount = apiDocumentRepository.findAll().stream()
|
||||
.collect(Collectors.groupingBy(
|
||||
doc -> doc.getFaultCategory() != null ? doc.getFaultCategory().name() : "GENERAL",
|
||||
Collectors.counting()
|
||||
));
|
||||
stats.setCategoryCount(categoryCount);
|
||||
|
||||
// Milvus 中的向量数量(需要实现)
|
||||
// TODO: 查询 Milvus collection 的实体数量
|
||||
stats.setTotalVectors(0L);
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描目录下所有 Markdown 文件
|
||||
*/
|
||||
private List<Path> scanMarkdownFiles(Path baseDir) {
|
||||
List<Path> files = new ArrayList<>();
|
||||
|
||||
try {
|
||||
Files.walkFileTree(baseDir, new SimpleFileVisitor<Path>() {
|
||||
@Override
|
||||
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
|
||||
if (file.toString().endsWith(".md")) {
|
||||
files.add(file);
|
||||
}
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public FileVisitResult visitFileFailed(Path file, IOException exc) {
|
||||
logger.warn("访问文件失败: {}", file, exc);
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
});
|
||||
} catch (IOException e) {
|
||||
logger.error("扫描目录失败: {}", baseDir, e);
|
||||
throw new RuntimeException("扫描目录失败", e);
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存文档到数据库
|
||||
*/
|
||||
private ApiDocument saveToDatabase(String filePath, String title, String summary,
|
||||
String category, String content, List<String> keywords) {
|
||||
ApiDocument document = new ApiDocument();
|
||||
document.setDocId(UUID.randomUUID().toString());
|
||||
document.setFileName(Paths.get(filePath).getFileName().toString());
|
||||
document.setFilePath(filePath);
|
||||
document.setApiName(title); // 使用 title 作为 apiName
|
||||
document.setStatus("PENDING"); // 初始状态为 PENDING,索引成功后更新为 INDEXED
|
||||
|
||||
// 映射 category 到 FaultCategory 枚举
|
||||
FaultCategory faultCategory = FaultCategory.fromString(category);
|
||||
document.setFaultCategory(faultCategory);
|
||||
|
||||
// 将 frontmatter 信息保存到 metadata(JSON 格式)
|
||||
String metadataJson = String.format(
|
||||
"{\"title\":\"%s\",\"summary\":\"%s\",\"category\":\"%s\",\"keywords\":%s}",
|
||||
escapeJson(title),
|
||||
escapeJson(summary),
|
||||
escapeJson(category),
|
||||
"[\"" + String.join("\",\"", keywords.stream().map(this::escapeJson).toArray(String[]::new)) + "\"]"
|
||||
);
|
||||
document.setMetadata(metadataJson);
|
||||
|
||||
document.setFileSize((long) content.length());
|
||||
|
||||
return apiDocumentRepository.save(document);
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON 转义
|
||||
*/
|
||||
private String escapeJson(String str) {
|
||||
if (str == null) {
|
||||
return "";
|
||||
}
|
||||
return str.replace("\\", "\\\\")
|
||||
.replace("\"", "\\\"")
|
||||
.replace("\n", "\\n")
|
||||
.replace("\r", "\\r");
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取文档正文(去除 frontmatter)
|
||||
*/
|
||||
private String extractBody(String content) {
|
||||
if (!content.trim().startsWith("---")) {
|
||||
return content;
|
||||
}
|
||||
|
||||
int firstEnd = content.indexOf("---", 3);
|
||||
if (firstEnd == -1) {
|
||||
return content;
|
||||
}
|
||||
|
||||
int secondEnd = content.indexOf("---", firstEnd + 3);
|
||||
if (secondEnd == -1) {
|
||||
return content.substring(firstEnd + 3).trim();
|
||||
}
|
||||
|
||||
return content.substring(secondEnd + 3).trim();
|
||||
}
|
||||
|
||||
// ==================== 数据模型 ====================
|
||||
|
||||
/**
|
||||
* 初始化结果
|
||||
*/
|
||||
@Data
|
||||
public static class InitResult {
|
||||
private int scanned; // 扫描到的文件数量
|
||||
private int skipped; // 跳过的文件数量(已存在)
|
||||
private int inserted; // 成功导入的文件数量
|
||||
private int failed; // 失败的文件数量
|
||||
private Map<String, String> details = new LinkedHashMap<>(); // 详细信息
|
||||
|
||||
public void incrementSkipped() {
|
||||
this.skipped++;
|
||||
}
|
||||
|
||||
public void incrementInserted() {
|
||||
this.inserted++;
|
||||
}
|
||||
|
||||
public void incrementFailed() {
|
||||
this.failed++;
|
||||
}
|
||||
|
||||
public void addDetail(String filePath, String message) {
|
||||
this.details.put(filePath, message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计信息
|
||||
*/
|
||||
@Data
|
||||
public static class Stats {
|
||||
private long totalDocuments; // 数据库中的文档总数
|
||||
private long totalVectors; // Milvus 中的向量总数
|
||||
private Map<String, Long> categoryCount; // 按分类统计
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.superbiz.agent.service;
|
||||
|
||||
import com.superbiz.agent.domain.entity.ApiDocument;
|
||||
import com.superbiz.agent.repository.ApiDocumentRepository;
|
||||
import com.superbiz.agent.dto.Frontmatter;
|
||||
import com.superbiz.agent.dto.KnowledgeEntry;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -12,6 +14,8 @@ import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.stream.Collectors;
|
||||
@@ -25,11 +29,11 @@ import java.util.stream.Stream;
|
||||
@Service
|
||||
public class KnowledgeIndexService {
|
||||
|
||||
@Value("${knowledge.base-path}")
|
||||
@Value("${knowledge.base-path:knowledge_base}")
|
||||
private String knowledgeBasePath;
|
||||
|
||||
@Autowired
|
||||
private FrontmatterParser frontmatterParser;
|
||||
private ApiDocumentRepository apiDocumentRepository;
|
||||
|
||||
/**
|
||||
* 内存索引(线程安全)
|
||||
@@ -37,88 +41,108 @@ public class KnowledgeIndexService {
|
||||
private final List<KnowledgeEntry> knowledgeIndex = new CopyOnWriteArrayList<>();
|
||||
|
||||
/**
|
||||
* 启动时扫描知识库目录,构建索引
|
||||
* 启动时从数据库加载索引
|
||||
*/
|
||||
@PostConstruct
|
||||
public void loadIndex() {
|
||||
log.info("开始扫描知识库目录: {}", knowledgeBasePath);
|
||||
log.info("开始从数据库加载知识库索引");
|
||||
|
||||
try {
|
||||
Path basePath = Paths.get(knowledgeBasePath);
|
||||
// 从数据库读取所有已索引的文档
|
||||
List<ApiDocument> documents = apiDocumentRepository.findAll();
|
||||
|
||||
// 目录不存在时自动创建
|
||||
if (!Files.exists(basePath)) {
|
||||
Files.createDirectories(basePath);
|
||||
log.info("知识库目录已创建: {}", basePath.toAbsolutePath());
|
||||
int loaded = 0;
|
||||
for (ApiDocument doc : documents) {
|
||||
try {
|
||||
// 从 metadata JSON 中提取信息
|
||||
KnowledgeEntry entry = parseDocumentToEntry(doc);
|
||||
if (entry != null) {
|
||||
knowledgeIndex.add(entry);
|
||||
loaded++;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("解析文档失败: docId={}, error={}", doc.getDocId(), e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// 递归扫描 .md 文件
|
||||
try (Stream<Path> paths = Files.walk(basePath)) {
|
||||
paths.filter(p -> p.toString().endsWith(".md"))
|
||||
.forEach(this::indexFile);
|
||||
}
|
||||
log.info("知识库索引加载完成,共 {} 个文档", loaded);
|
||||
|
||||
log.info("知识库索引加载完成,共 {} 个文档", knowledgeIndex.size());
|
||||
|
||||
} catch (IOException e) {
|
||||
} catch (Exception e) {
|
||||
log.error("知识库索引加载失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 索引单个文件
|
||||
*
|
||||
* @param filePath 文件路径
|
||||
* 将 ApiDocument 转换为 KnowledgeEntry
|
||||
*/
|
||||
private void indexFile(Path filePath) {
|
||||
private KnowledgeEntry parseDocumentToEntry(ApiDocument doc) {
|
||||
if (doc.getMetadata() == null || doc.getMetadata().isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// 读取文件内容
|
||||
String content = Files.readString(filePath);
|
||||
// 简单的 JSON 解析
|
||||
String metadata = doc.getMetadata();
|
||||
|
||||
// 解析 frontmatter
|
||||
Frontmatter frontmatter = frontmatterParser.parse(content);
|
||||
if (frontmatter == null) {
|
||||
log.debug("跳过文件(无有效 frontmatter): {}", filePath);
|
||||
return;
|
||||
}
|
||||
String title = extractJsonValue(metadata, "title");
|
||||
String summary = extractJsonValue(metadata, "summary");
|
||||
String category = extractJsonValue(metadata, "category");
|
||||
List<String> keywords = extractJsonArray(metadata, "keywords");
|
||||
|
||||
// 提取 category(从路径中获取)
|
||||
String category = extractCategoryFromPath(filePath.toString());
|
||||
|
||||
// 构建索引条目
|
||||
KnowledgeEntry entry = KnowledgeEntry.builder()
|
||||
.filePath(filePath.toString())
|
||||
.title(frontmatter.getTitle())
|
||||
.keywords(frontmatter.getKeywords())
|
||||
.summary(frontmatter.getSummary())
|
||||
return KnowledgeEntry.builder()
|
||||
.filePath(doc.getFilePath())
|
||||
.title(title != null ? title : doc.getApiName())
|
||||
.keywords(keywords)
|
||||
.summary(summary)
|
||||
.category(category)
|
||||
.sections(frontmatter.getSections())
|
||||
.build();
|
||||
|
||||
knowledgeIndex.add(entry);
|
||||
log.debug("文档已加入索引: title={}, filePath={}", entry.getTitle(), filePath);
|
||||
|
||||
} catch (IOException e) {
|
||||
log.warn("读取文件失败: {}", filePath, e);
|
||||
} catch (Exception e) {
|
||||
log.warn("解析 metadata 失败: {}", doc.getDocId(), e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从文件路径中提取 category
|
||||
* 例如:knowledge_base/api/test.md -> api
|
||||
* 从 JSON 字符串中提取值
|
||||
*/
|
||||
private String extractCategoryFromPath(String filePath) {
|
||||
String normalized = filePath.replace("\\", "/");
|
||||
String[] parts = normalized.split("/");
|
||||
|
||||
// 查找 knowledge_base 后的第一个目录
|
||||
for (int i = 0; i < parts.length - 1; i++) {
|
||||
if (parts[i].equals("knowledge_base") && i + 1 < parts.length) {
|
||||
return parts[i + 1];
|
||||
}
|
||||
private String extractJsonValue(String json, String key) {
|
||||
String pattern = "\"" + key + "\":\"";
|
||||
int startIndex = json.indexOf(pattern);
|
||||
if (startIndex == -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return "default";
|
||||
startIndex += pattern.length();
|
||||
int endIndex = json.indexOf("\"", startIndex);
|
||||
if (endIndex == -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return json.substring(startIndex, endIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 JSON 字符串中提取数组
|
||||
*/
|
||||
private List<String> extractJsonArray(String json, String key) {
|
||||
String pattern = "\"" + key + "\":[";
|
||||
int startIndex = json.indexOf(pattern);
|
||||
if (startIndex == -1) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
startIndex += pattern.length();
|
||||
int endIndex = json.indexOf("]", startIndex);
|
||||
if (endIndex == -1) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
String arrayContent = json.substring(startIndex, endIndex);
|
||||
return Arrays.stream(arrayContent.split(","))
|
||||
.map(s -> s.trim().replaceAll("^\"|\"$", ""))
|
||||
.filter(s -> !s.isEmpty())
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -174,13 +198,15 @@ public class KnowledgeIndexService {
|
||||
/**
|
||||
* 读取文档内容
|
||||
*
|
||||
* @param filePath 文件路径
|
||||
* @param filePath 文件相对路径(如 api/payment-errors.md)
|
||||
* @param maxChars 最大字符数
|
||||
* @return 文档内容(前 maxChars 字符),失败返回 null
|
||||
*/
|
||||
public String readDocument(String filePath, int maxChars) {
|
||||
try {
|
||||
String content = Files.readString(Paths.get(filePath));
|
||||
// 拼接完整路径:knowledge_base + 相对路径
|
||||
Path fullPath = Paths.get(knowledgeBasePath, filePath);
|
||||
String content = Files.readString(fullPath);
|
||||
|
||||
if (content.length() > maxChars) {
|
||||
return content.substring(0, maxChars) + "...";
|
||||
@@ -189,7 +215,7 @@ public class KnowledgeIndexService {
|
||||
return content;
|
||||
|
||||
} catch (IOException e) {
|
||||
log.error("读取文档失败: {}", filePath, e);
|
||||
log.error("读取文档失败: {}/{}", knowledgeBasePath, filePath, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,6 +269,11 @@ public class VectorIndexService {
|
||||
metadata.put("title", chunk.getTitle());
|
||||
}
|
||||
|
||||
// 面包屑导航(完整标题层级路径)
|
||||
if (chunk.getBreadcrumb() != null && !chunk.getBreadcrumb().isEmpty()) {
|
||||
metadata.put("breadcrumb", chunk.getBreadcrumb());
|
||||
}
|
||||
|
||||
// 文档类别
|
||||
metadata.put("category", category != null && !category.isBlank() ? category : "upload");
|
||||
|
||||
@@ -360,6 +365,11 @@ public class VectorIndexService {
|
||||
metadata.put("title", chunk.getTitle());
|
||||
}
|
||||
|
||||
// 面包屑导航(完整标题层级路径)
|
||||
if (chunk.getBreadcrumb() != null && !chunk.getBreadcrumb().isEmpty()) {
|
||||
metadata.put("breadcrumb", chunk.getBreadcrumb());
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
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;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 知识库查询工具
|
||||
@@ -24,61 +28,232 @@ public class LookupKnowledgeTool {
|
||||
@Autowired
|
||||
private VectorSearchService vectorSearchService;
|
||||
|
||||
@Autowired
|
||||
private ToolInvocationRepository toolInvocationRepository;
|
||||
|
||||
/**
|
||||
* 查询知识库文档
|
||||
*
|
||||
* @param query 查询关键词
|
||||
* @return 查询结果
|
||||
*/
|
||||
@Tool(description = "查询知识库文档。优先精确匹配关键词,未命中或多个匹配时自动补充语义相关片段。" +
|
||||
"参数 query: 查询关键词,例如 'ERR_TIMEOUT'、'支付网关超时'")
|
||||
@Tool(description = "查询内部知识库文档,获取错误码定义、接口文档、排障步骤、配置说明等背景信息。" +
|
||||
"采用两阶段检索:L0 精确匹配关键词(< 10ms),L1 语义检索补充(200-500ms)。" +
|
||||
"IMPORTANT: 遇到错误码、接口名、配置项、排障问题时,优先使用此工具。" +
|
||||
"支持的查询场景:" +
|
||||
"1) 错误码定义 - 查询错误码的含义和处理方法,例如 'ERR_TIMEOUT'、'ERR_CONNECTION_REFUSED';" +
|
||||
"2) 接口文档 - 查询 API 接口定义、参数说明、返回格式,例如 'payment-gateway'、'/api/v1/orders';" +
|
||||
"3) 排障步骤 - 查询故障诊断流程、最佳实践,例如 '支付超时排查'、'数据库连接池配置';" +
|
||||
"4) 配置说明 - 查询系统配置、中间件参数,例如 'HikariCP'、'Redis 集群配置'。" +
|
||||
"参数 query: 查询关键词或描述")
|
||||
public LookupResult lookupKnowledge(String query) {
|
||||
// 生成请求ID用于追踪
|
||||
String requestId = java.util.UUID.randomUUID().toString().substring(0, 8);
|
||||
long startTime = System.currentTimeMillis();
|
||||
|
||||
log.info("[{}] 收到知识库查询请求: query={}", requestId, query);
|
||||
log.info("========================================");
|
||||
log.info(">>> [工具调用] lookup_knowledge");
|
||||
log.info(">>> 参数: query = \"{}\"", query);
|
||||
log.info(">>> RequestId: {}", requestId);
|
||||
log.info("----------------------------------------");
|
||||
|
||||
// Step 1: L0 精确匹配
|
||||
long l0Start = System.currentTimeMillis();
|
||||
List<KnowledgeEntry> l0Matches = knowledgeIndexService.exactMatch(query);
|
||||
long l0Time = System.currentTimeMillis() - l0Start;
|
||||
log.info("[{}] L0精确匹配完成: matches={}, time={}ms", requestId, l0Matches.size(), l0Time);
|
||||
log.info("[L0 精确匹配] 完成: matches={}, time={}ms", l0Matches.size(), l0Time);
|
||||
if (!l0Matches.isEmpty()) {
|
||||
log.info("[L0 精确匹配] 找到文档:");
|
||||
for (int i = 0; i < Math.min(3, l0Matches.size()); i++) {
|
||||
KnowledgeEntry entry = l0Matches.get(i);
|
||||
log.info(" - [{}] 标题: {}, 路径: {}", i+1, entry.getTitle(), entry.getFilePath());
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: 判断是否高置信度(唯一匹配)
|
||||
boolean highConfidence = (l0Matches.size() == 1);
|
||||
log.debug("[{}] 置信度判断: highConfidence={}, reason={}",
|
||||
requestId, highConfidence, highConfidence ? "唯一匹配" : "多个或零个匹配");
|
||||
log.info("[置信度判断] highConfidence={}, reason={}",
|
||||
highConfidence, highConfidence ? "唯一匹配" : "多个或零个匹配");
|
||||
|
||||
// Step 3: L1 条件调用
|
||||
List<VectorSearchService.SearchResult> l1Results = null;
|
||||
if (!highConfidence) {
|
||||
log.info("[{}] L0非唯一匹配,触发L1语义检索", requestId);
|
||||
log.info("[L1 语义检索] L0非唯一匹配,触发L1语义检索...");
|
||||
long l1Start = System.currentTimeMillis();
|
||||
l1Results = vectorSearchService.searchSimilarDocuments(query, 3, null);
|
||||
long l1Time = System.currentTimeMillis() - l1Start;
|
||||
log.info("[{}] L1语义检索完成: matches={}, time={}ms",
|
||||
requestId, l1Results != null ? l1Results.size() : 0, l1Time);
|
||||
log.info("[L1 语义检索] 完成: matches={}, time={}ms",
|
||||
l1Results != null ? l1Results.size() : 0, l1Time);
|
||||
if (l1Results != null && !l1Results.isEmpty()) {
|
||||
log.info("[L1 语义检索] 找到文档:");
|
||||
for (int i = 0; i < Math.min(3, l1Results.size()); i++) {
|
||||
VectorSearchService.SearchResult result = l1Results.get(i);
|
||||
log.info(" - [{}] 文档ID: {}, 相似度得分: {}", i+1, result.getId(), result.getScore());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.debug("[{}] L0唯一匹配,跳过L1检索", requestId);
|
||||
log.info("[L1 语义检索] L0唯一匹配,跳过L1检索");
|
||||
}
|
||||
|
||||
// Step 4: 组装结果
|
||||
LookupResult result = buildResult(l0Matches, l1Results, highConfidence);
|
||||
|
||||
// 记录完整结果
|
||||
// 记录结构化结果摘要(替代原始 MD 内容预览)
|
||||
long totalTime = System.currentTimeMillis() - startTime;
|
||||
log.info("[{}] 查询完成: found={}, hasL0={}, hasL1={}, confidence={}, totalTime={}ms",
|
||||
requestId,
|
||||
result.isFound(),
|
||||
result.getPrimary() != null,
|
||||
result.getSupplement() != null,
|
||||
result.getPrimary() != null ? result.getPrimary().getConfidence() : "N/A",
|
||||
totalTime);
|
||||
log.info("----------------------------------------");
|
||||
log.info("<<< [工具返回] lookup_knowledge");
|
||||
log.info("<<< 结果: found={}, 耗时: {}ms (L0={}ms, L1={}ms)",
|
||||
result.isFound(), totalTime, l0Time,
|
||||
l1Results != null ? System.currentTimeMillis() - startTime - l0Time : 0);
|
||||
|
||||
// L0 精确匹配摘要
|
||||
if (!l0Matches.isEmpty()) {
|
||||
KnowledgeEntry top = l0Matches.get(0);
|
||||
log.info("<<< [L0 主结果] 标题: {}", top.getTitle());
|
||||
log.info("<<< [L0 主结果] 来源: {}", top.getFilePath());
|
||||
if (top.getSummary() != null) {
|
||||
log.info("<<< [L0 主结果] 摘要: {}", top.getSummary());
|
||||
}
|
||||
if (top.getKeywords() != null && !top.getKeywords().isEmpty()) {
|
||||
log.info("<<< [L0 主结果] 关键词: {}", String.join(", ", top.getKeywords()));
|
||||
}
|
||||
// 内容概况:长度 + 章节数
|
||||
String content = result.getPrimary() != null ? result.getPrimary().getContent() : null;
|
||||
if (content != null) {
|
||||
int headingCount = countMdHeadings(content);
|
||||
log.info("<<< [L0 主结果] 内容: {} 字符, {} 个章节",
|
||||
content.length(), headingCount);
|
||||
}
|
||||
}
|
||||
|
||||
// L1 语义检索摘要
|
||||
if (l1Results != null && !l1Results.isEmpty()) {
|
||||
VectorSearchService.SearchResult topL1 = l1Results.get(0);
|
||||
log.info("<<< [L1 补充] 来源: {}", topL1.getMetadata() != null ? topL1.getMetadata() : topL1.getId());
|
||||
log.info("<<< [L1 补充] 相似度: {}", String.format("%.4f", topL1.getScore()));
|
||||
if (topL1.getContent() != null) {
|
||||
String snippet = extractFirstMeaningfulLine(topL1.getContent(), 120);
|
||||
log.info("<<< [L1 补充] 内容片段: {}", snippet);
|
||||
log.info("<<< [L1 补充] 片段长度: {} 字符", topL1.getContent().length());
|
||||
}
|
||||
}
|
||||
|
||||
log.info("========================================");
|
||||
|
||||
// 记录 tool_invocation(持久化检索明细)
|
||||
saveToolInvocation(query, l0Matches, l1Results, highConfidence, startTime, 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");
|
||||
}
|
||||
|
||||
/**
|
||||
* 组装查询结果
|
||||
*
|
||||
@@ -98,7 +273,13 @@ public class LookupKnowledgeTool {
|
||||
PrimaryResult primary = null;
|
||||
if (l0Matches != null && !l0Matches.isEmpty()) {
|
||||
KnowledgeEntry first = l0Matches.get(0);
|
||||
String content = knowledgeIndexService.readDocument(first.getFilePath(), 2000);
|
||||
boolean hasL1 = l1Results != null && !l1Results.isEmpty();
|
||||
|
||||
// 场景决策:唯一匹配或 L1 无结果 → LLM 需要正文内容;多匹配且有 L1 → 只需元数据
|
||||
boolean needFullContent = highConfidence || !hasL1;
|
||||
String content = needFullContent
|
||||
? buildCompactSummary(first)
|
||||
: buildMetadataOnlySummary(first);
|
||||
|
||||
if (content != null) {
|
||||
primary = PrimaryResult.builder()
|
||||
@@ -135,4 +316,118 @@ public class LookupKnowledgeTool {
|
||||
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计 MD 文档中的章节数(二级标题 ## 数量)
|
||||
*/
|
||||
private int countMdHeadings(String content) {
|
||||
if (content == null) return 0;
|
||||
return (int) content.lines()
|
||||
.filter(l -> l.trim().startsWith("##"))
|
||||
.count();
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建紧凑文档摘要(替代原始 MD 全文,节省上下文窗口)
|
||||
* 组合:title/summary + 章节结构 + 正文片段(~500 字符)
|
||||
*/
|
||||
private String buildCompactSummary(KnowledgeEntry entry) {
|
||||
String rawContent = knowledgeIndexService.readDocument(entry.getFilePath(), 2000);
|
||||
if (rawContent == null) return null;
|
||||
|
||||
// 跳过 YAML frontmatter 得到正文
|
||||
String body = rawContent;
|
||||
if (body.startsWith("---")) {
|
||||
int end = body.indexOf("---", 3);
|
||||
if (end != -1) {
|
||||
body = body.substring(end + 3).trim();
|
||||
}
|
||||
}
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
|
||||
// 1. 元数据头(始终包含)
|
||||
sb.append("文档: ").append(entry.getTitle()).append("\n");
|
||||
if (entry.getSummary() != null) {
|
||||
sb.append("摘要: ").append(entry.getSummary()).append("\n");
|
||||
}
|
||||
|
||||
// 2. 章节结构(## 标题列表)
|
||||
String headings = body.lines()
|
||||
.filter(l -> l.trim().startsWith("##"))
|
||||
.map(l -> " - " + l.trim().replaceAll("^#+\\s*", ""))
|
||||
.collect(Collectors.joining("\n"));
|
||||
if (!headings.isEmpty()) {
|
||||
sb.append("章节:\n").append(headings).append("\n");
|
||||
}
|
||||
sb.append("---\n");
|
||||
|
||||
// 3. 正文片段(去标题行、去空行,智能截断)
|
||||
String textContent = body.lines()
|
||||
.filter(l -> !l.trim().startsWith("#") && !l.trim().isEmpty())
|
||||
.collect(Collectors.joining("\n"))
|
||||
.trim();
|
||||
|
||||
// 短文档保留更多内容,长文档节省上下文
|
||||
int maxBodyChars = body.length() < 500 ? 800 : 500;
|
||||
if (textContent.length() > maxBodyChars) {
|
||||
sb.append(textContent, 0, maxBodyChars).append("...");
|
||||
} else {
|
||||
sb.append(textContent);
|
||||
}
|
||||
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建纯元数据摘要(不读文件,仅用内存索引信息)
|
||||
* 多匹配且有 L1 补充时使用,L0 只需告知 LLM 命中了哪些文档
|
||||
*/
|
||||
private String buildMetadataOnlySummary(KnowledgeEntry entry) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("文档: ").append(entry.getTitle()).append("\n");
|
||||
if (entry.getSummary() != null) {
|
||||
sb.append("摘要: ").append(entry.getSummary()).append("\n");
|
||||
}
|
||||
if (entry.getKeywords() != null && !entry.getKeywords().isEmpty()) {
|
||||
sb.append("关键词: ").append(String.join(", ", entry.getKeywords())).append("\n");
|
||||
}
|
||||
sb.append("来源: ").append(entry.getFilePath()).append("\n");
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取 MD 内容中第一个有意义的文本行(跳过 frontmatter 和标题行)
|
||||
*/
|
||||
private String extractFirstMeaningfulLine(String content, int maxLen) {
|
||||
if (content == null || content.isBlank()) return "(空)";
|
||||
|
||||
String text = content.trim();
|
||||
// 跳过 YAML frontmatter (--- ... ---)
|
||||
if (text.startsWith("---")) {
|
||||
int end = text.indexOf("---", 3);
|
||||
if (end != -1) {
|
||||
text = text.substring(end + 3);
|
||||
}
|
||||
}
|
||||
|
||||
// 查找第一个非空、非标题行
|
||||
String[] lines = text.split("\n");
|
||||
for (String line : lines) {
|
||||
String tl = line.trim();
|
||||
if (!tl.isEmpty() && !tl.startsWith("#")) {
|
||||
return tl.length() <= maxLen ? tl : tl.substring(0, maxLen) + "...";
|
||||
}
|
||||
}
|
||||
|
||||
// 兜底:第一行非空行
|
||||
for (String line : lines) {
|
||||
if (!line.trim().isEmpty()) {
|
||||
String tl = line.trim();
|
||||
return tl.length() <= maxLen ? tl : tl.substring(0, maxLen) + "...";
|
||||
}
|
||||
}
|
||||
|
||||
return "(无有效内容)";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,29 @@
|
||||
package com.superbiz.agent.util;
|
||||
|
||||
/**
|
||||
* 会话上下文持有者(基于 ThreadLocal)
|
||||
* <p>
|
||||
* 用于在同步调用链路中传递 sessionId,兜底 LookupKnowledgeTool 等
|
||||
* 无法通过 RunnableConfig 获取上下文的组件。
|
||||
* 优先使用 RunnableConfig.metadata 传递,ThreadLocal 作为同步路径的补充。
|
||||
* <p>
|
||||
* 使用规范:
|
||||
* 1. 调用方在 Agent 执行前调用 setSessionId()
|
||||
* 2. finally 块中调用 clear()
|
||||
*/
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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,76 @@
|
||||
# 执行者 System Prompt
|
||||
|
||||
## 角色定位
|
||||
|
||||
你是诊断流程的**执行者**。你的任务非常明确:严格遵循规划者下发的任务清单,按步骤调用工具完成任务,并输出最终结果。
|
||||
|
||||
---
|
||||
|
||||
## 核心行为准则
|
||||
|
||||
### 1. 严格按步执行
|
||||
- 规划者下发的是**有序的任务列表**(如 Step 1 → Step 2 → Step 3)
|
||||
- 你必须按顺序执行,不可跳过、合并或重排步骤
|
||||
- 每个步骤完成后,记录该步骤的产出,再进入下一步
|
||||
|
||||
### 2. 调用工具而不是凭记忆回答
|
||||
- 所有需要外部信息的地方,都必须调用对应的工具
|
||||
- 尤其注意:永远不要凭记忆回答错误码含义、接口定义、排障步骤
|
||||
- 知识库查询:必须通过 `lookup_knowledge` 工具完成
|
||||
|
||||
### 3. 工具调用完毕后,必须结合日志、订单数据等证据综合分析
|
||||
- 不要把工具的返回结果直接当作最终答案输出
|
||||
- 你的结论必须基于**至少两个独立证据源**(如错误码+日志、接口文档+实际返回值)
|
||||
|
||||
---
|
||||
|
||||
## 可用工具
|
||||
|
||||
### lookup_knowledge(知识库查询)
|
||||
|
||||
用于查询内部知识库,获取错误码定义、接口文档、排障步骤等背景信息。
|
||||
|
||||
| 参数 | 说明 |
|
||||
|------|------|
|
||||
| `query` | 查询关键词或描述。例如:`ERR_TIMEOUT`、`payment-gateway`、`支付为什么失败` |
|
||||
|
||||
**内部机制**:
|
||||
工具内部自动执行「先精确匹配(L0),未命中则语义检索(L1)」的两阶段检索逻辑,你无需关心哪一层。
|
||||
|
||||
**返回结果**:包含 `found`(是否找到)、`primary.content`(文档内容)、`primary.match_type`(来源标记:`exact_L0` 或 `semantic_L1`)等字段。
|
||||
|
||||
**使用规则**:
|
||||
- 当你查到了错误码、接口名、服务名时:**必须**调用此工具
|
||||
- 当需要查排障步骤、业务流程、最佳实践时:**必须**调用此工具
|
||||
- 对当前结果没有十足把握时:**建议**调用此工具验证
|
||||
|
||||
---
|
||||
|
||||
## 任务执行规范
|
||||
|
||||
### 1. 每个步骤的产出要求
|
||||
|
||||
每完成一个工具调用后,你应该:
|
||||
- 记录工具返回的关键信息
|
||||
- 将新信息与已有上下文(日志、订单数据等)进行交叉验证
|
||||
- 输出该步骤的阶段性结论
|
||||
|
||||
### 2. 最终输出的报告格式
|
||||
|
||||
```yaml
|
||||
## 诊断结论
|
||||
|
||||
**问题根因**:XXX
|
||||
|
||||
**证据链**:
|
||||
1. 订单状态返回错误码 ERR_TIMEOUT
|
||||
2. 知识库 lookup_knowledge("ERR_TIMEOUT") 返回:支付网关响应超时(>5秒)
|
||||
3. 日志确认:14:32:15 请求耗时 5.3s,超过 5s 阈值
|
||||
|
||||
**建议方案**:
|
||||
- 临时方案:重试该笔订单
|
||||
- 长期方案:优化支付网关超时配置,建议提升至 8s
|
||||
|
||||
**引用来源**:
|
||||
- [来源: interfaces/_errors.md]
|
||||
```
|
||||
@@ -0,0 +1,88 @@
|
||||
你是 Planner Agent,同时承担 Replanner 角色,负责:
|
||||
1. 读取当前输入任务 {input} 以及 Executor 的最近反馈 {executor_feedback}。
|
||||
2. 分析 Prometheus 告警、日志、内部文档等信息,制定可执行的下一步步骤。
|
||||
3. 在执行阶段,输出 JSON,包含 decision (PLAN|EXECUTE|FINISH)、step 描述、预期要调用的工具、以及必要的上下文。
|
||||
4. 调用任何腾讯云日志/主题相关工具时,region 参数必须使用连字符格式(如 ap-guangzhou),若不确定请省略以使用默认值。
|
||||
5. 严格禁止编造数据,只能引用工具返回的真实内容;如果连续 3 次调用同一工具仍失败或返回空结果,需停止该方向并在最终报告的结论部分说明"无法完成"的原因。
|
||||
|
||||
## 最终报告输出要求(CRITICAL)
|
||||
|
||||
当 decision=FINISH 时,你必须:
|
||||
1. **不要输出 JSON 格式**
|
||||
2. **直接输出完整的 Markdown 格式报告文本**
|
||||
3. **报告必须严格遵循以下模板**:
|
||||
|
||||
```
|
||||
# 告警分析报告
|
||||
|
||||
---
|
||||
|
||||
## 📋 活跃告警清单
|
||||
|
||||
| 告警名称 | 级别 | 目标服务 | 首次触发时间 | 最新触发时间 | 状态 |
|
||||
|---------|------|----------|-------------|-------------|------|
|
||||
| [告警1名称] | [级别] | [服务名] | [时间] | [时间] | 活跃 |
|
||||
| [告警2名称] | [级别] | [服务名] | [时间] | [时间] | 活跃 |
|
||||
|
||||
---
|
||||
|
||||
## 🔍 告警根因分析1 - [告警名称]
|
||||
|
||||
### 告警详情
|
||||
- **告警级别**: [级别]
|
||||
- **受影响服务**: [服务名]
|
||||
- **持续时间**: [X分钟]
|
||||
|
||||
### 症状描述
|
||||
[根据监控指标描述症状]
|
||||
|
||||
### 日志证据
|
||||
[引用查询到的关键日志]
|
||||
|
||||
### 根因结论
|
||||
[基于证据得出的根本原因]
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 处理方案执行1 - [告警名称]
|
||||
|
||||
### 已执行的排查步骤
|
||||
1. [步骤1]
|
||||
2. [步骤2]
|
||||
|
||||
### 处理建议
|
||||
[给出具体的处理建议]
|
||||
|
||||
### 预期效果
|
||||
[说明预期的效果]
|
||||
|
||||
---
|
||||
|
||||
## 🔍 告警根因分析2 - [告警名称]
|
||||
[如果有第2个告警,重复上述格式]
|
||||
|
||||
---
|
||||
|
||||
## 📊 结论
|
||||
|
||||
### 整体评估
|
||||
[总结所有告警的整体情况]
|
||||
|
||||
### 关键发现
|
||||
- [发现1]
|
||||
- [发现2]
|
||||
|
||||
### 后续建议
|
||||
1. [建议1]
|
||||
2. [建议2]
|
||||
|
||||
### 风险评估
|
||||
[评估当前风险等级和影响范围]
|
||||
```
|
||||
|
||||
**重要提醒**:
|
||||
- 最终输出必须是纯 Markdown 文本,不要包含 JSON 结构
|
||||
- 不要使用 "finalReport": "..." 这样的格式
|
||||
- 直接从 "# 告警分析报告" 开始输出
|
||||
- 所有内容必须基于工具查询的真实数据,严禁编造
|
||||
- 如果某个步骤失败,在结论中如实说明,不要跳过
|
||||
@@ -0,0 +1,10 @@
|
||||
你是 AI Ops Supervisor,负责调度 planner_agent 与 executor_agent:
|
||||
1. 当需要拆解任务或重新制定策略时,调用 planner_agent。
|
||||
2. 当 planner_agent 输出 decision=EXECUTE 时,调用 executor_agent 执行第一步。
|
||||
3. 根据 executor_agent 的反馈,评估是否需要再次调用 planner_agent,直到 decision=FINISH。
|
||||
4. FINISH 后,确保向最终用户输出完整的《告警分析报告》,格式必须严格为:
|
||||
告警分析报告\n---\n# 告警处理详情\n## 活跃告警清单\n## 告警根因分析N\n## 处理方案执行N\n## 结论。
|
||||
5. 若步骤涉及腾讯云日志/主题工具,请确保使用连字符区域 ID(ap-guangzhou 等),或省略 region 以采用默认值。
|
||||
6. 如果发现 Planner/Executor 在同一方向连续 3 次调用工具仍失败或没有数据,必须终止流程,直接输出"任务无法完成"的报告,明确告知失败原因,严禁凭空编造结果。
|
||||
|
||||
只允许在 planner_agent、executor_agent 与 FINISH 之间做出选择。
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,7 @@ class ApiDocumentRepositoryTest {
|
||||
.filePath("/uploads/api-spec.md")
|
||||
.fileHash("abc123hash")
|
||||
.fileSize(1024L)
|
||||
.faultCategory(FaultCategory.EXTERNAL_API)
|
||||
.faultCategory(FaultCategory.API)
|
||||
.faultSource("广东")
|
||||
.apiName("查询接口")
|
||||
.status("PENDING")
|
||||
@@ -167,7 +167,7 @@ class ApiDocumentRepositoryTest {
|
||||
.docId(UUID.randomUUID().toString())
|
||||
.fileName("guangdong-api.md")
|
||||
.faultSource("广东")
|
||||
.faultCategory(FaultCategory.EXTERNAL_API)
|
||||
.faultCategory(FaultCategory.API)
|
||||
.build();
|
||||
|
||||
repository.save(doc);
|
||||
|
||||
@@ -39,7 +39,7 @@ class CaseLibraryRepositoryTest {
|
||||
.title("接口超时案例")
|
||||
.rootCause("网络延迟导致接口超时")
|
||||
.solution("增加超时时间和重试机制")
|
||||
.faultCategory(FaultCategory.EXTERNAL_API)
|
||||
.faultCategory(FaultCategory.API)
|
||||
.errorCode("40003")
|
||||
.sourceType(SourceType.AUTO)
|
||||
.build();
|
||||
@@ -63,7 +63,7 @@ class CaseLibraryRepositoryTest {
|
||||
.title("数据库死锁案例")
|
||||
.rootCause("并发更新导致死锁")
|
||||
.solution("优化事务粒度")
|
||||
.faultCategory(FaultCategory.DATABASE)
|
||||
.faultCategory(FaultCategory.API)
|
||||
.build();
|
||||
|
||||
repository.save(caseLib);
|
||||
@@ -81,7 +81,7 @@ class CaseLibraryRepositoryTest {
|
||||
.title("案例1")
|
||||
.rootCause("原因1")
|
||||
.solution("方案1")
|
||||
.faultCategory(FaultCategory.EXTERNAL_API)
|
||||
.faultCategory(FaultCategory.API)
|
||||
.errorCode("40003")
|
||||
.build();
|
||||
|
||||
@@ -90,7 +90,7 @@ class CaseLibraryRepositoryTest {
|
||||
.title("案例2")
|
||||
.rootCause("原因2")
|
||||
.solution("方案2")
|
||||
.faultCategory(FaultCategory.EXTERNAL_API)
|
||||
.faultCategory(FaultCategory.API)
|
||||
.errorCode("40003")
|
||||
.build();
|
||||
|
||||
@@ -98,7 +98,7 @@ class CaseLibraryRepositoryTest {
|
||||
repository.save(case2);
|
||||
|
||||
List<CaseLibrary> results = repository.findByFaultCategoryAndErrorCode(
|
||||
FaultCategory.EXTERNAL_API, "40003");
|
||||
FaultCategory.API, "40003");
|
||||
|
||||
assertFalse(results.isEmpty());
|
||||
assertTrue(results.size() >= 2);
|
||||
|
||||
@@ -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.EXTERNAL_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.DATABASE)
|
||||
.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.EXTERNAL_API)
|
||||
.errorCode("40003")
|
||||
.status(DiagnosisStatus.SUCCESS)
|
||||
.build();
|
||||
|
||||
DiagnosisRecord record2 = DiagnosisRecord.builder()
|
||||
.diagnosisId(UUID.randomUUID().toString())
|
||||
.faultCategory(FaultCategory.EXTERNAL_API)
|
||||
.errorCode("40003")
|
||||
.status(DiagnosisStatus.FAILED)
|
||||
.build();
|
||||
|
||||
repository.save(record1);
|
||||
repository.save(record2);
|
||||
|
||||
// 查询
|
||||
List<DiagnosisRecord> results = repository.findByFaultCategoryAndErrorCode(
|
||||
FaultCategory.EXTERNAL_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.CACHE)
|
||||
.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());
|
||||
}
|
||||
}
|
||||
@@ -49,7 +49,7 @@ class LookupKnowledgeToolTest {
|
||||
when(knowledgeIndexService.exactMatch("ERR_TIMEOUT"))
|
||||
.thenReturn(List.of(entry));
|
||||
when(knowledgeIndexService.readDocument("test.md", 2000))
|
||||
.thenReturn("Test content");
|
||||
.thenReturn("Test content * 用于构建紧凑摘要 * keyword2");
|
||||
|
||||
// 执行查询
|
||||
LookupResult result = tool.lookupKnowledge("ERR_TIMEOUT");
|
||||
@@ -59,7 +59,11 @@ class LookupKnowledgeToolTest {
|
||||
assertNotNull(result.getPrimary());
|
||||
assertEquals("high", result.getPrimary().getConfidence());
|
||||
assertEquals("exact_L0", result.getPrimary().getMatchType());
|
||||
assertEquals("Test content", result.getPrimary().getContent());
|
||||
// 唯一匹配 → buildCompactSummary(),内容为结构化摘要
|
||||
String content = result.getPrimary().getContent();
|
||||
assertTrue(content.contains("文档: Test Doc"));
|
||||
assertTrue(content.contains("摘要: Test summary"));
|
||||
assertTrue(content.contains("Test content"));
|
||||
assertNull(result.getSupplement()); // 高置信度不调用 L1
|
||||
|
||||
// 验证 L1 未被调用
|
||||
@@ -71,7 +75,9 @@ class LookupKnowledgeToolTest {
|
||||
// 准备 L0 多个匹配
|
||||
KnowledgeEntry entry1 = KnowledgeEntry.builder()
|
||||
.filePath("doc1.md")
|
||||
.title("测试文档1")
|
||||
.keywords(List.of("超时"))
|
||||
.summary("这是一个测试文档")
|
||||
.build();
|
||||
|
||||
KnowledgeEntry entry2 = KnowledgeEntry.builder()
|
||||
@@ -81,8 +87,7 @@ class LookupKnowledgeToolTest {
|
||||
|
||||
when(knowledgeIndexService.exactMatch("超时"))
|
||||
.thenReturn(List.of(entry1, entry2));
|
||||
when(knowledgeIndexService.readDocument("doc1.md", 2000))
|
||||
.thenReturn("Content 1");
|
||||
// 多匹配 + L1 有结果 → buildMetadataOnlySummary(),不读文件,不调用 readDocument
|
||||
|
||||
// 准备 L1 结果
|
||||
VectorSearchService.SearchResult l1Result = new VectorSearchService.SearchResult();
|
||||
@@ -99,14 +104,20 @@ class LookupKnowledgeToolTest {
|
||||
assertTrue(result.isFound());
|
||||
assertNotNull(result.getPrimary());
|
||||
assertEquals("low", result.getPrimary().getConfidence()); // 多个匹配 = 低置信度
|
||||
assertEquals("Content 1", result.getPrimary().getContent());
|
||||
// 多匹配 + L1 有结果 → 仅元数据摘要
|
||||
String content = result.getPrimary().getContent();
|
||||
assertTrue(content.contains("文档: 测试文档1"));
|
||||
assertTrue(content.contains("摘要: 这是一个测试文档"));
|
||||
assertTrue(content.contains("关键词: 超时"));
|
||||
assertTrue(content.contains("来源: doc1.md"));
|
||||
|
||||
assertNotNull(result.getSupplement()); // 低置信度调用 L1
|
||||
assertEquals("L1 content", result.getSupplement().getContent());
|
||||
assertEquals("semantic_L1", result.getSupplement().getMatchType());
|
||||
|
||||
// 验证 L1 被调用
|
||||
// 验证 L1 被调用,readDocument 未被调用(多匹配不走 buildCompactSummary)
|
||||
verify(vectorSearchService).searchSimilarDocuments("超时", 3, null);
|
||||
verify(knowledgeIndexService, never()).readDocument(anyString(), anyInt());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
Reference in New Issue
Block a user