feat: implement streaming chat, skill routing, and SAFe PI planning tools
- Add /api/chat/stream endpoint with Server-Sent Events (SSE) for real-time message streaming * Implement StreamEvent types (thought, tool_call, tool_result, final, error) * Add StreamEventCallback mechanism for event propagation * Create StreamChatHandler in webui/bot with proper HTTP headers and flushing - Implement LLM-based skill router for intelligent capability selection * Add optional routerLLM client for semantic routing * Implement routeSkillsWithLLM() to match user intent to available skills * Add matchSkillsByName() for fuzzy skill matching * Update buildUnifiedSystemPrompt() to use routed skills - Add streaming support to ReAct pipeline * Implement runUnifiedReActStream() for streaming thought/action/observation * Emit StreamEvent at each ReAct step * Support callback error handling in streaming mode - Integrate three new DevOps tools * tools/filedoc: Extract document content from file_id via OpenAI * tools/giteaticket: Create Gitea issues from PI plan items with SAFe metadata * tools/piplan: Publish PI planning blueprints with dependency tracking - Add SAFe PI Planning skill * Implement PM/SA/RTE (iron triangle) workflow * Support for Feature, Enabler, and Dependency definition * Automatic task decomposition and Gitea integration - Create frontend integration documentation * Complete SSE protocol specification * TypeScript fetch + ReadableStream example * LLM-ready refactoring template for other projects - Simplify file handling * Remove legacy file context structures and dual-mode processing * Consolidate file operations into UploadAndCacheFiles() * Remove FilePromptMode configuration and related complexity - Update configuration * Add Router model support (LLM_ROUTER_MODEL) * Add Gitea configuration (BaseURL, Token, Owner, Repo) * WebSearch and additional tool infrastructure Tests: All 22 test packages passing, 8/8 webui tests including 3 new stream tests
This commit is contained in:
@@ -126,9 +126,19 @@ func main() {
|
|||||||
// 实例化 LLM 客户端
|
// 实例化 LLM 客户端
|
||||||
llmClient := llm.NewOpenAICompatibleClient(cfg.LLM, appLogger.WithComponent("llm"))
|
llmClient := llm.NewOpenAICompatibleClient(cfg.LLM, appLogger.WithComponent("llm"))
|
||||||
|
|
||||||
|
// 实例化路由 LLM 客户端(如果配置了独立的路由模型)
|
||||||
|
var routerLLMClient llm.Client
|
||||||
|
if cfg.LLM.RouterModel != "" {
|
||||||
|
routerCfg := cfg.LLM
|
||||||
|
routerCfg.Model = cfg.LLM.RouterModel
|
||||||
|
routerLLMClient = llm.NewOpenAICompatibleClient(routerCfg, appLogger.WithComponent("llm.router"))
|
||||||
|
appLogger.Infof("skill router enabled, model=%s", cfg.LLM.RouterModel)
|
||||||
|
}
|
||||||
|
|
||||||
// 创建编排器,整合 LLM、记忆系统、知识技能库与各种工具
|
// 创建编排器,整合 LLM、记忆系统、知识技能库与各种工具
|
||||||
engine := agent.NewOrchestrator(
|
engine := agent.NewOrchestrator(
|
||||||
llmClient,
|
llmClient,
|
||||||
|
routerLLMClient,
|
||||||
store,
|
store,
|
||||||
toolRegistry,
|
toolRegistry,
|
||||||
soul,
|
soul,
|
||||||
@@ -207,11 +217,18 @@ func runMessageChannel(ctx context.Context, cfg config.Config, engine *agent.Orc
|
|||||||
return wb.Run(
|
return wb.Run(
|
||||||
ctx,
|
ctx,
|
||||||
func(ctx context.Context, msg webui.IncomingMessage) (string, error) {
|
func(ctx context.Context, msg webui.IncomingMessage) (string, error) {
|
||||||
if len(msg.FileIDs) > 0 {
|
|
||||||
return engine.HandleMessageWithFileIDs(ctx, msg.ChatID, msg.UserID, msg.Text, msg.FileIDs)
|
|
||||||
}
|
|
||||||
return engine.HandleMessage(ctx, msg.ChatID, msg.UserID, msg.Text)
|
return engine.HandleMessage(ctx, msg.ChatID, msg.UserID, msg.Text)
|
||||||
},
|
},
|
||||||
|
func(ctx context.Context, msg webui.IncomingMessage, callback webui.StreamEventCallback) (string, error) {
|
||||||
|
return engine.HandleMessageStream(ctx, msg.ChatID, msg.UserID, msg.Text, func(event agent.StreamEvent) error {
|
||||||
|
return callback(webui.StreamEvent{
|
||||||
|
Type: webui.StreamEventType(event.Type),
|
||||||
|
Content: event.Content,
|
||||||
|
Step: event.Step,
|
||||||
|
ToolName: event.ToolName,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
func(ctx context.Context, chatID, userID string, files []llm.InputFile) ([]string, error) {
|
func(ctx context.Context, chatID, userID string, files []llm.InputFile) ([]string, error) {
|
||||||
return engine.UploadAndCacheFiles(ctx, chatID, userID, files)
|
return engine.UploadAndCacheFiles(ctx, chatID, userID, files)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ TELEGRAM_POLL_TIMEOUT_SECONDS=30
|
|||||||
FEISHU_APP_ID=
|
FEISHU_APP_ID=
|
||||||
FEISHU_APP_SECRET=
|
FEISHU_APP_SECRET=
|
||||||
FEISHU_VERIFY_TOKEN=
|
FEISHU_VERIFY_TOKEN=
|
||||||
|
FEISHU_LISTEN_ADDR=:8080
|
||||||
|
FEISHU_EVENT_PATH=/feishu/events
|
||||||
WEBUI_LISTEN_ADDR=:8090
|
WEBUI_LISTEN_ADDR=:8090
|
||||||
WEBUI_MAX_UPLOAD_MB=20
|
WEBUI_MAX_UPLOAD_MB=20
|
||||||
|
|
||||||
@@ -24,7 +26,15 @@ LLM_BASE_URL=https://api.openai.com/v1
|
|||||||
LLM_API_KEY=
|
LLM_API_KEY=
|
||||||
LLM_MODEL=gpt-4o-mini
|
LLM_MODEL=gpt-4o-mini
|
||||||
LLM_FILE_MODEL=gpt-4o-mini
|
LLM_FILE_MODEL=gpt-4o-mini
|
||||||
LLM_FILE_PROMPT_MODE=user_content_file_parts
|
LLM_ROUTER_MODEL=
|
||||||
|
|
||||||
|
WEB_SEARCH_ENGINE=duckduckgo
|
||||||
|
WEB_SEARCH_API_KEY=
|
||||||
|
|
||||||
|
GITEA_BASE_URL=
|
||||||
|
GITEA_TOKEN=
|
||||||
|
GITEA_OWNER=
|
||||||
|
GITEA_REPO=
|
||||||
|
|
||||||
SQLITE_PATH=./data/laodingbot.db
|
SQLITE_PATH=./data/laodingbot.db
|
||||||
ALLOWED_DIRS=./workspace,./data,./skills
|
ALLOWED_DIRS=./workspace,./data,./skills
|
||||||
|
|||||||
187
doc/WebUI_Stream_API_前端对接说明.md
Normal file
187
doc/WebUI_Stream_API_前端对接说明.md
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
# WebUI `/api/chat/stream` 前端对接说明
|
||||||
|
|
||||||
|
本文档用于指导前端项目接入 LaodingBot 的流式聊天接口,并可直接作为提示词输入给 LLM,批量改造其他前端代码。
|
||||||
|
|
||||||
|
## 1. 接口总览
|
||||||
|
|
||||||
|
- 方法: `POST`
|
||||||
|
- 路径: `/api/chat/stream`
|
||||||
|
- 请求头: `Content-Type: application/json`
|
||||||
|
- 响应类型: `text/event-stream`
|
||||||
|
- 协议: SSE (Server-Sent Events)
|
||||||
|
|
||||||
|
说明: 该接口为单次请求、多次推送。后端会持续推送 `data: <json>\n\n` 格式的事件。
|
||||||
|
|
||||||
|
## 2. 请求体
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"text": "请帮我分析当前目录",
|
||||||
|
"session_id": "sess_abc",
|
||||||
|
"user_id": "user_001"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
字段说明:
|
||||||
|
|
||||||
|
- `text` (string, required): 用户输入文本,去除空白后不能为空。
|
||||||
|
- `session_id` (string, optional): 会话 ID,不传时后端自动生成。
|
||||||
|
- `user_id` (string, optional): 用户 ID,不传时后端自动生成。
|
||||||
|
|
||||||
|
兼容字段:
|
||||||
|
|
||||||
|
- `sessionId` 等价于 `session_id`
|
||||||
|
- `userId` 等价于 `user_id`
|
||||||
|
|
||||||
|
## 3. SSE 事件格式
|
||||||
|
|
||||||
|
每条 SSE 消息只包含 `data` 字段,内容是 JSON:
|
||||||
|
|
||||||
|
```text
|
||||||
|
data: {"type":"thought","content":"我先判断是否需要调用工具","step":1}
|
||||||
|
|
||||||
|
data: {"type":"tool_call","content":"{\"input\":\"pwd\"}","step":1,"tool_name":"shell"}
|
||||||
|
|
||||||
|
data: {"type":"tool_result","content":"C:/Project/MyProject","step":1,"tool_name":"shell"}
|
||||||
|
|
||||||
|
data: {"type":"final","content":"当前目录是 C:/Project/MyProject","step":2}
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
事件字段:
|
||||||
|
|
||||||
|
- `type` (string): 事件类型
|
||||||
|
- `content` (string): 事件文本内容
|
||||||
|
- `step` (number, optional): ReAct 步骤编号
|
||||||
|
- `tool_name` (string, optional): 工具名
|
||||||
|
|
||||||
|
事件类型:
|
||||||
|
|
||||||
|
- `thought`: 模型思考过程
|
||||||
|
- `tool_call`: 工具调用请求
|
||||||
|
- `tool_result`: 工具返回结果
|
||||||
|
- `final`: 最终回答
|
||||||
|
- `error`: 错误信息
|
||||||
|
|
||||||
|
## 4. 连接生命周期
|
||||||
|
|
||||||
|
- 正常结束: 收到 `type=final` 后结束渲染,连接可由浏览器自然关闭。
|
||||||
|
- 异常结束: 收到 `type=error`,前端应显示错误并结束当前轮次。
|
||||||
|
- 网络中断: 前端应允许用户重试,并保留已收到的事件记录。
|
||||||
|
|
||||||
|
## 5. 前端渲染建议
|
||||||
|
|
||||||
|
推荐将一次请求的事件按 `step` 分组后渲染,典型展示区块:
|
||||||
|
|
||||||
|
- 思考区: 逐条显示 `thought`
|
||||||
|
- 工具区: 成对显示 `tool_call` 与 `tool_result`
|
||||||
|
- 答案区: 显示最后一个 `final`
|
||||||
|
- 错误区: 显示 `error`
|
||||||
|
|
||||||
|
建议状态机:
|
||||||
|
|
||||||
|
- `idle`: 初始状态
|
||||||
|
- `streaming`: 请求中且持续接收事件
|
||||||
|
- `done`: 收到 `final`
|
||||||
|
- `failed`: 收到 `error` 或请求异常
|
||||||
|
|
||||||
|
## 6. TypeScript 对接示例 (fetch + ReadableStream)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type StreamEventType = 'thought' | 'tool_call' | 'tool_result' | 'final' | 'error';
|
||||||
|
|
||||||
|
interface StreamEvent {
|
||||||
|
type: StreamEventType;
|
||||||
|
content: string;
|
||||||
|
step?: number;
|
||||||
|
tool_name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function streamChat(
|
||||||
|
payload: { text: string; session_id?: string; user_id?: string },
|
||||||
|
onEvent: (event: StreamEvent) => void,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<void> {
|
||||||
|
const resp = await fetch('/api/chat/stream', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
throw new Error(`HTTP ${resp.status}`);
|
||||||
|
}
|
||||||
|
if (!resp.body) {
|
||||||
|
throw new Error('ReadableStream is not available');
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = resp.body.getReader();
|
||||||
|
const decoder = new TextDecoder('utf-8');
|
||||||
|
let buffer = '';
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
|
||||||
|
// SSE message delimiter: blank line
|
||||||
|
let idx = buffer.indexOf('\n\n');
|
||||||
|
while (idx >= 0) {
|
||||||
|
const chunk = buffer.slice(0, idx).trim();
|
||||||
|
buffer = buffer.slice(idx + 2);
|
||||||
|
|
||||||
|
for (const line of chunk.split('\n')) {
|
||||||
|
const text = line.trim();
|
||||||
|
if (!text.startsWith('data:')) continue;
|
||||||
|
const raw = text.slice(5).trim();
|
||||||
|
if (!raw) continue;
|
||||||
|
|
||||||
|
const event = JSON.parse(raw) as StreamEvent;
|
||||||
|
onEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
idx = buffer.indexOf('\n\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. 给 LLM 的改造任务提示词模板
|
||||||
|
|
||||||
|
将下面模板发给 LLM,可用于自动改造其他前端项目:
|
||||||
|
|
||||||
|
```text
|
||||||
|
你要改造一个前端项目的聊天页面,把非流式接口 `/api/chat` 改为流式接口 `/api/chat/stream`。
|
||||||
|
|
||||||
|
后端协议约束:
|
||||||
|
1) 请求方法 POST,Content-Type=application/json
|
||||||
|
2) 请求体: { text, session_id?, user_id? }
|
||||||
|
3) 响应是 SSE 文本流,事件格式为 `data: <json>\n\n`
|
||||||
|
4) JSON 结构:
|
||||||
|
- type: thought | tool_call | tool_result | final | error
|
||||||
|
- content: string
|
||||||
|
- step?: number
|
||||||
|
- tool_name?: string
|
||||||
|
5) 收到 final 视为本轮完成;收到 error 视为失败
|
||||||
|
|
||||||
|
你的改造要求:
|
||||||
|
1) 保留现有 UI 风格和组件结构,不做无关重构
|
||||||
|
2) 新增流式读取逻辑,支持中途取消 (AbortController)
|
||||||
|
3) 将事件按 step 渲染到消息区
|
||||||
|
4) 兼容旧会话字段命名 (session_id / sessionId, user_id / userId)
|
||||||
|
5) 增加错误态与重试按钮
|
||||||
|
6) 不破坏原有上传、历史消息和输入框行为
|
||||||
|
7) 输出改动文件列表 + 每个文件的关键变更说明
|
||||||
|
|
||||||
|
请直接给出可运行代码补丁。
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8. 调试清单
|
||||||
|
|
||||||
|
- 检查响应头是否为 `text/event-stream`
|
||||||
|
- 检查每条事件是否以 `data:` 开头并以空行结尾
|
||||||
|
- 确认 `final` 和 `error` 都能正确结束当前轮次
|
||||||
|
- 验证弱网下不会丢失已收到的事件
|
||||||
|
- 验证用户快速连续提问时,旧流可被取消
|
||||||
@@ -18,9 +18,32 @@ import (
|
|||||||
"laodingbot/internal/tools"
|
"laodingbot/internal/tools"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// StreamEventType 定义流式输出事件类型
|
||||||
|
type StreamEventType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
StreamEventTypeThought StreamEventType = "thought" // LLM 思考过程
|
||||||
|
StreamEventTypeToolCall StreamEventType = "tool_call" // 工具调用请求
|
||||||
|
StreamEventTypeToolResult StreamEventType = "tool_result" // 工具执行结果
|
||||||
|
StreamEventTypeFinal StreamEventType = "final" // 最终答案
|
||||||
|
StreamEventTypeError StreamEventType = "error" // 错误信息
|
||||||
|
)
|
||||||
|
|
||||||
|
// StreamEvent 代表流式输出中的一个事件
|
||||||
|
type StreamEvent struct {
|
||||||
|
Type StreamEventType `json:"type"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Step int `json:"step,omitempty"`
|
||||||
|
ToolName string `json:"tool_name,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// StreamEventCallback 是流式事件回调函数类型,用于推送事件到客户端
|
||||||
|
type StreamEventCallback func(event StreamEvent) error
|
||||||
|
|
||||||
// Orchestrator 负责协调和组合业务逻辑,包含 LLM 计算、上下文管理、技能匹配计算和工具调用。
|
// Orchestrator 负责协调和组合业务逻辑,包含 LLM 计算、上下文管理、技能匹配计算和工具调用。
|
||||||
type Orchestrator struct {
|
type Orchestrator struct {
|
||||||
llm llm.Client
|
llm llm.Client
|
||||||
|
routerLLM llm.Client // 可选:轻量路由模型,用于技能意图路由;为 nil 则仅用关键词匹配
|
||||||
store *memory.SQLiteStore
|
store *memory.SQLiteStore
|
||||||
tools *tools.Registry
|
tools *tools.Registry
|
||||||
soul string
|
soul string
|
||||||
@@ -44,16 +67,10 @@ type pendingFileRef struct {
|
|||||||
MimeType string
|
MimeType string
|
||||||
}
|
}
|
||||||
|
|
||||||
type filePromptContext struct {
|
|
||||||
Summary string
|
|
||||||
FatalReason string
|
|
||||||
FileIDs []string
|
|
||||||
Uploaded []pendingFileRef
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewOrchestrator 创建一个新的编排器对象,初始化关键路径和超时控制等。
|
// NewOrchestrator 创建一个新的编排器对象,初始化关键路径和超时控制等。
|
||||||
func NewOrchestrator(
|
func NewOrchestrator(
|
||||||
llmClient llm.Client,
|
llmClient llm.Client,
|
||||||
|
routerLLM llm.Client,
|
||||||
store *memory.SQLiteStore,
|
store *memory.SQLiteStore,
|
||||||
registry *tools.Registry,
|
registry *tools.Registry,
|
||||||
soul string,
|
soul string,
|
||||||
@@ -81,6 +98,7 @@ func NewOrchestrator(
|
|||||||
}
|
}
|
||||||
return &Orchestrator{
|
return &Orchestrator{
|
||||||
llm: llmClient,
|
llm: llmClient,
|
||||||
|
routerLLM: routerLLM,
|
||||||
store: store,
|
store: store,
|
||||||
tools: registry,
|
tools: registry,
|
||||||
soul: soul,
|
soul: soul,
|
||||||
@@ -103,52 +121,88 @@ func NewOrchestrator(
|
|||||||
// - 是否需要调用工具(action + action_input)
|
// - 是否需要调用工具(action + action_input)
|
||||||
// 循环持续进行,直到 LLM 返回 is_final_answer=true。
|
// 循环持续进行,直到 LLM 返回 is_final_answer=true。
|
||||||
func (o *Orchestrator) HandleMessage(ctx context.Context, chatID, userID, text string) (string, error) {
|
func (o *Orchestrator) HandleMessage(ctx context.Context, chatID, userID, text string) (string, error) {
|
||||||
return o.handleMessageInternal(ctx, chatID, userID, text, nil, false)
|
return o.handleMessageInternal(ctx, chatID, userID, text)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HandleMessageWithFiles 接收用户消息和文件,上传文件获取 file_id 并缓存,然后进入普通消息处理流程。
|
||||||
func (o *Orchestrator) HandleMessageWithFiles(ctx context.Context, chatID, userID, text string, files []llm.InputFile) (string, error) {
|
func (o *Orchestrator) HandleMessageWithFiles(ctx context.Context, chatID, userID, text string, files []llm.InputFile) (string, error) {
|
||||||
return o.handleMessageInternal(ctx, chatID, userID, text, files, false)
|
if len(files) > 0 {
|
||||||
|
ids, err := o.UploadAndCacheFiles(ctx, chatID, userID, files)
|
||||||
|
if err != nil && o.log != nil {
|
||||||
|
o.log.Warnf("upload files failed chat_id=%s err=%v", chatID, err)
|
||||||
|
}
|
||||||
|
_ = ids
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(text) == "" {
|
||||||
|
return "文件已接收。请继续发送你的问题。", nil
|
||||||
|
}
|
||||||
|
return o.handleMessageInternal(ctx, chatID, userID, text)
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleMessageWithFileIDs 接收用户文本与外部 file_id 列表,复用统一 ReAct 链路。
|
// HandleMessageStream 接收用户消息并通过流式方式返回回复。
|
||||||
// 该方法会先把 file_id 注入当前会话上下文,然后调用常规 HandleMessage 流程。
|
// 通过 callback 推送实时事件,包括思考过程、工具调用、工具结果和最终答案。
|
||||||
func (o *Orchestrator) HandleMessageWithFileIDs(ctx context.Context, chatID, userID, text string, fileIDs []string) (string, error) {
|
func (o *Orchestrator) HandleMessageStream(ctx context.Context, chatID, userID, text string, callback StreamEventCallback) (string, error) {
|
||||||
ids := nonEmptyIDs(fileIDs)
|
if callback == nil {
|
||||||
if len(ids) > 0 {
|
return "", fmt.Errorf("stream callback is required")
|
||||||
refs := make([]pendingFileRef, 0, len(ids))
|
|
||||||
for _, id := range ids {
|
|
||||||
refs = append(refs, pendingFileRef{ID: id})
|
|
||||||
}
|
}
|
||||||
o.appendPendingFiles(chatID, userID, refs)
|
return o.handleMessageStreamInternal(ctx, chatID, userID, text, callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleMessageStreamWithFiles 接收用户消息和文件,上传文件后进入流式处理流程。
|
||||||
|
func (o *Orchestrator) HandleMessageStreamWithFiles(ctx context.Context, chatID, userID, text string, files []llm.InputFile, callback StreamEventCallback) (string, error) {
|
||||||
|
if callback == nil {
|
||||||
|
return "", fmt.Errorf("stream callback is required")
|
||||||
}
|
}
|
||||||
return o.handleMessageInternal(ctx, chatID, userID, text, nil, true)
|
if len(files) > 0 {
|
||||||
|
ids, err := o.UploadAndCacheFiles(ctx, chatID, userID, files)
|
||||||
|
if err != nil && o.log != nil {
|
||||||
|
o.log.Warnf("upload files failed chat_id=%s err=%v", chatID, err)
|
||||||
|
}
|
||||||
|
_ = ids
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(text) == "" {
|
||||||
|
return "文件已接收。请继续发送你的问题。", nil
|
||||||
|
}
|
||||||
|
return o.handleMessageStreamInternal(ctx, chatID, userID, text, callback)
|
||||||
}
|
}
|
||||||
|
|
||||||
// UploadAndCacheFiles 上传文件到 LLM 并缓存 file_id,供后续同会话文本问答复用。
|
// UploadAndCacheFiles 上传文件到 LLM 并缓存 file_id,供后续同会话文本问答复用。
|
||||||
// 该方法不会写入 messages 表,仅更新内存中的 pending file 上下文。
|
|
||||||
func (o *Orchestrator) UploadAndCacheFiles(ctx context.Context, chatID, userID string, files []llm.InputFile) ([]string, error) {
|
func (o *Orchestrator) UploadAndCacheFiles(ctx context.Context, chatID, userID string, files []llm.InputFile) ([]string, error) {
|
||||||
if len(files) == 0 {
|
if len(files) == 0 {
|
||||||
return nil, fmt.Errorf("no files provided")
|
return nil, fmt.Errorf("no files provided")
|
||||||
}
|
}
|
||||||
uploadCtx := o.prepareFilePromptContext(ctx, files, nil)
|
uploader, ok := o.llm.(llm.FileUploader)
|
||||||
if strings.TrimSpace(uploadCtx.FatalReason) != "" {
|
if !ok {
|
||||||
return nil, fmt.Errorf(uploadCtx.FatalReason)
|
return nil, fmt.Errorf("当前 LLM 客户端不支持文件上传接口")
|
||||||
}
|
}
|
||||||
ids := nonEmptyIDs(uploadCtx.FileIDs)
|
var ids []string
|
||||||
if len(ids) == 0 {
|
var refs []pendingFileRef
|
||||||
return nil, fmt.Errorf("file upload completed but no valid file_id returned")
|
for i, f := range files {
|
||||||
|
if strings.TrimSpace(f.FileName) == "" || len(f.Content) == 0 {
|
||||||
|
return nil, fmt.Errorf("file[%d] 缺少文件名或内容", i+1)
|
||||||
}
|
}
|
||||||
o.appendPendingFiles(chatID, userID, uploadCtx.toPendingRefs())
|
fileID, err := uploader.UploadFile(ctx, f, "file-extract")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("file[%d] name=%s 上传失败: %w", i+1, f.FileName, err)
|
||||||
|
}
|
||||||
|
ids = append(ids, fileID)
|
||||||
|
refs = append(refs, pendingFileRef{
|
||||||
|
ID: fileID,
|
||||||
|
Name: strings.TrimSpace(f.FileName),
|
||||||
|
MimeType: defaultIfEmpty(strings.TrimSpace(f.MimeType), "application/octet-stream"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
o.appendPendingFiles(chatID, userID, refs)
|
||||||
return ids, nil
|
return ids, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o *Orchestrator) handleMessageInternal(ctx context.Context, chatID, userID, text string, files []llm.InputFile, appendFileIDText bool) (string, error) {
|
func (o *Orchestrator) handleMessageInternal(ctx context.Context, chatID, userID, text string) (string, error) {
|
||||||
// 为链路追踪设置唯一的 TraceID
|
// 为链路追踪设置唯一的 TraceID
|
||||||
traceID := logger.NewTraceID()
|
traceID := logger.NewTraceID()
|
||||||
ctx = logger.WithTraceID(ctx, traceID)
|
ctx = logger.WithTraceID(ctx, traceID)
|
||||||
traceLogPrefix := "trace_id=" + traceID
|
traceLogPrefix := "trace_id=" + traceID
|
||||||
if o.log != nil {
|
if o.log != nil {
|
||||||
o.log.Infof("%s handle message chat_id=%s user_id=%s text_len=%d files=%d", traceLogPrefix, chatID, userID, len(text), len(files))
|
o.log.Infof("%s handle message chat_id=%s user_id=%s text_len=%d", traceLogPrefix, chatID, userID, len(text))
|
||||||
o.log.Debugf("%s handle message text=%q", traceLogPrefix, text)
|
o.log.Debugf("%s handle message text=%q", traceLogPrefix, text)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,38 +223,6 @@ func (o *Orchestrator) handleMessageInternal(ctx context.Context, chatID, userID
|
|||||||
return report, nil
|
return report, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
trimmedText := strings.TrimSpace(text)
|
|
||||||
isFileOnly := len(files) > 0 && trimmedText == ""
|
|
||||||
|
|
||||||
if isFileOnly {
|
|
||||||
if err := o.store.SaveMessage(chatID, userID, "user", "[FILE_UPLOAD]"); err != nil {
|
|
||||||
if o.log != nil {
|
|
||||||
o.log.Errorf("%s save file-only user marker failed chat_id=%s err=%v", traceLogPrefix, chatID, err)
|
|
||||||
}
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
uploadCtx := o.prepareFilePromptContext(ctx, files, nil)
|
|
||||||
if strings.TrimSpace(uploadCtx.FatalReason) != "" {
|
|
||||||
finalText := "文件上传失败,无法建立文档上下文。" + "\n" + uploadCtx.FatalReason
|
|
||||||
if err := o.store.SaveMessage(chatID, userID, "assistant", finalText); err != nil && o.log != nil {
|
|
||||||
o.log.Warnf("%s save upload failure message failed chat_id=%s err=%v", traceLogPrefix, chatID, err)
|
|
||||||
}
|
|
||||||
return finalText, nil
|
|
||||||
}
|
|
||||||
o.appendPendingFiles(chatID, userID, uploadCtx.toPendingRefs())
|
|
||||||
finalText := o.buildFileUploadAck(uploadCtx)
|
|
||||||
if err := o.store.SaveMessage(chatID, userID, "assistant", finalText); err != nil {
|
|
||||||
if o.log != nil {
|
|
||||||
o.log.Errorf("%s save file upload ack failed chat_id=%s err=%v", traceLogPrefix, chatID, err)
|
|
||||||
}
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if o.log != nil {
|
|
||||||
o.log.Infof("%s file-only message handled chat_id=%s cached_files=%d", traceLogPrefix, chatID, len(uploadCtx.FileIDs))
|
|
||||||
}
|
|
||||||
return finalText, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 保存用户消息到 SQLite 中
|
// 保存用户消息到 SQLite 中
|
||||||
if err := o.store.SaveMessage(chatID, userID, "user", text); err != nil {
|
if err := o.store.SaveMessage(chatID, userID, "user", text); err != nil {
|
||||||
if o.log != nil {
|
if o.log != nil {
|
||||||
@@ -223,28 +245,13 @@ func (o *Orchestrator) handleMessageInternal(ctx context.Context, chatID, userID
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 进入统一 ReAct 循环
|
// 进入统一 ReAct 循环
|
||||||
pendingRefs := o.getPendingFiles(chatID, userID)
|
response, err := o.runUnifiedReAct(ctx, chatID, userID, compressed, text)
|
||||||
fileCtx := o.prepareFilePromptContext(ctx, files, pendingRefs)
|
|
||||||
if strings.TrimSpace(fileCtx.FatalReason) != "" {
|
|
||||||
finalText := "文件上传失败,无法继续进行文档解析。" + "\n" + fileCtx.FatalReason
|
|
||||||
if err := o.store.SaveMessage(chatID, userID, "assistant", finalText); err != nil && o.log != nil {
|
|
||||||
o.log.Warnf("%s save assistant failure message failed chat_id=%s err=%v", traceLogPrefix, chatID, err)
|
|
||||||
}
|
|
||||||
if o.log != nil {
|
|
||||||
o.log.Warnf("%s stop before react due to file upload failure reason=%s", traceLogPrefix, fileCtx.FatalReason)
|
|
||||||
}
|
|
||||||
return finalText, nil
|
|
||||||
}
|
|
||||||
response, err := o.runUnifiedReAct(ctx, chatID, userID, compressed, text, fileCtx, appendFileIDText)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if o.log != nil {
|
if o.log != nil {
|
||||||
o.log.Errorf("%s message generation failed chat_id=%s err=%v", traceLogPrefix, chatID, err)
|
o.log.Errorf("%s message generation failed chat_id=%s err=%v", traceLogPrefix, chatID, err)
|
||||||
}
|
}
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
if len(pendingRefs) > 0 {
|
|
||||||
o.clearPendingFiles(chatID, userID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 最终将机器人的回复也加入记忆缓存
|
// 最终将机器人的回复也加入记忆缓存
|
||||||
if err := o.store.SaveMessage(chatID, userID, "assistant", response); err != nil {
|
if err := o.store.SaveMessage(chatID, userID, "assistant", response); err != nil {
|
||||||
@@ -260,11 +267,89 @@ func (o *Orchestrator) handleMessageInternal(ctx context.Context, chatID, userID
|
|||||||
return response, nil
|
return response, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleMessageStreamInternal 处理流式消息的内部逻辑,类似于handleMessageInternal但支持流式回调
|
||||||
|
func (o *Orchestrator) handleMessageStreamInternal(ctx context.Context, chatID, userID, text string, callback StreamEventCallback) (string, error) {
|
||||||
|
// 为链路追踪设置唯一的 TraceID
|
||||||
|
traceID := logger.NewTraceID()
|
||||||
|
ctx = logger.WithTraceID(ctx, traceID)
|
||||||
|
traceLogPrefix := "trace_id=" + traceID
|
||||||
|
if o.log != nil {
|
||||||
|
o.log.Infof("%s handle message stream chat_id=%s user_id=%s text_len=%d", traceLogPrefix, chatID, userID, len(text))
|
||||||
|
o.log.Debugf("%s handle message stream text=%q", traceLogPrefix, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理特殊的重载指令
|
||||||
|
if strings.EqualFold(strings.TrimSpace(text), "/reload_skills") {
|
||||||
|
if err := o.ReloadSkills(); err != nil {
|
||||||
|
return "技能热加载失败: " + err.Error(), nil
|
||||||
|
}
|
||||||
|
return "技能已热加载完成。", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果用户请求能力缺口报告,则生成报告格式化输出
|
||||||
|
if strings.EqualFold(strings.TrimSpace(text), "/capability_gaps") {
|
||||||
|
report, err := o.BuildCapabilityGapReport(10)
|
||||||
|
if err != nil {
|
||||||
|
return "缺口报告生成失败: " + err.Error(), nil
|
||||||
|
}
|
||||||
|
return report, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存用户消息到 SQLite 中
|
||||||
|
if err := o.store.SaveMessage(chatID, userID, "user", text); err != nil {
|
||||||
|
if o.log != nil {
|
||||||
|
o.log.Errorf("%s save user message failed chat_id=%s err=%v", traceLogPrefix, chatID, err)
|
||||||
|
}
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取最近的会话记忆并压缩成 Prompt 上下文
|
||||||
|
recent, err := o.store.LoadRecent(chatID, 16)
|
||||||
|
if err != nil {
|
||||||
|
if o.log != nil {
|
||||||
|
o.log.Errorf("%s load recent failed chat_id=%s err=%v", traceLogPrefix, chatID, err)
|
||||||
|
}
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
compressed := memory.CompressForPrompt(recent, 6000)
|
||||||
|
if o.log != nil {
|
||||||
|
o.log.Debugf("%s stream prompt context prepared chat_id=%s recent_count=%d compressed_len=%d", traceLogPrefix, chatID, len(recent), len(compressed))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 进入流式统一 ReAct 循环
|
||||||
|
response, err := o.runUnifiedReActStream(ctx, chatID, userID, compressed, text, callback)
|
||||||
|
if err != nil {
|
||||||
|
if o.log != nil {
|
||||||
|
o.log.Errorf("%s stream message generation failed chat_id=%s err=%v", traceLogPrefix, chatID, err)
|
||||||
|
}
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 最终将机器人的回复也加入记忆缓存
|
||||||
|
if err := o.store.SaveMessage(chatID, userID, "assistant", response); err != nil {
|
||||||
|
if o.log != nil {
|
||||||
|
o.log.Errorf("%s save assistant response failed chat_id=%s err=%v", traceLogPrefix, chatID, err)
|
||||||
|
}
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if o.log != nil {
|
||||||
|
o.log.Infof("%s stream message handled chat_id=%s response_len=%d", traceLogPrefix, chatID, len(response))
|
||||||
|
}
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
// buildUnifiedSystemPrompt 构建统一 ReAct 循环的 system prompt。
|
// buildUnifiedSystemPrompt 构建统一 ReAct 循环的 system prompt。
|
||||||
// 工具定义通过 API 的 tools 字段传递;此处只需包含人格、技能、运行环境和思考指引。
|
// 工具定义通过 API 的 tools 字段传递;此处只需包含人格、技能、运行环境和思考指引。
|
||||||
func (o *Orchestrator) buildUnifiedSystemPrompt(userInput string) string {
|
// routedSkills 为 LLM 路由预选的技能列表;如果为 nil,则回退到关键词匹配。
|
||||||
|
func (o *Orchestrator) buildUnifiedSystemPrompt(userInput string, routedSkills []knowledge.Skill) string {
|
||||||
skillMetaDoc := o.formatSkillSummariesForPrompt()
|
skillMetaDoc := o.formatSkillSummariesForPrompt()
|
||||||
relevantSkillsDoc := o.formatSelectedSkillsForPrompt(userInput, nil)
|
var relevantSkillsDoc string
|
||||||
|
if routedSkills != nil {
|
||||||
|
relevantSkillsDoc = o.formatSelectedSkillsForPrompt(userInput, routedSkills)
|
||||||
|
} else {
|
||||||
|
relevantSkillsDoc = o.formatSelectedSkillsForPrompt(userInput, nil)
|
||||||
|
}
|
||||||
runtimeDoc := formatRuntimeContextForPrompt()
|
runtimeDoc := formatRuntimeContextForPrompt()
|
||||||
|
|
||||||
return strings.Join([]string{
|
return strings.Join([]string{
|
||||||
@@ -292,20 +377,151 @@ func (o *Orchestrator) buildUnifiedSystemPrompt(userInput string) string {
|
|||||||
"",
|
"",
|
||||||
"===== 本轮相关技能(按用户问题筛选) =====",
|
"===== 本轮相关技能(按用户问题筛选) =====",
|
||||||
relevantSkillsDoc,
|
relevantSkillsDoc,
|
||||||
|
"",
|
||||||
|
"===== 关键约束 =====",
|
||||||
}, "\n")
|
}, "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// routeSkillsWithLLM 使用轻量 LLM 模型对用户输入进行语义路由,判断是否需要加载技能以及选择哪些技能。
|
||||||
|
// 返回匹配到的技能列表(可能为空切片表示不需要技能,nil 表示调用失败应回退)。
|
||||||
|
func (o *Orchestrator) routeSkillsWithLLM(ctx context.Context, userInput string) ([]knowledge.Skill, error) {
|
||||||
|
traceLogPrefix := "trace_id=" + logger.TraceIDFromContext(ctx)
|
||||||
|
|
||||||
|
summaries := o.getSkillSummariesSnapshot()
|
||||||
|
if len(summaries) == 0 {
|
||||||
|
if o.log != nil {
|
||||||
|
o.log.Debugf("%s skill router: no skills available, skip", traceLogPrefix)
|
||||||
|
}
|
||||||
|
return []knowledge.Skill{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建技能池描述
|
||||||
|
skillPool := strings.Builder{}
|
||||||
|
for _, s := range summaries {
|
||||||
|
name := strings.TrimSpace(s.Name)
|
||||||
|
desc := strings.TrimSpace(s.Description)
|
||||||
|
if name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
skillPool.WriteString("- ")
|
||||||
|
skillPool.WriteString(name)
|
||||||
|
if desc != "" {
|
||||||
|
skillPool.WriteString(": ")
|
||||||
|
skillPool.WriteString(desc)
|
||||||
|
}
|
||||||
|
skillPool.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
routerSystemPrompt := strings.Join([]string{
|
||||||
|
"你是一个意图路由器。根据用户输入,从技能池中挑选最合适的技能。",
|
||||||
|
"",
|
||||||
|
"规则:",
|
||||||
|
"1. 如果用户的问题可以直接回答(闲聊、简单问答)或只需简单工具调用,设置 need_skills=false,selected_skills 为空数组。",
|
||||||
|
"2. 如果用户的问题涉及专业流程、复杂任务或与某个技能高度相关,设置 need_skills=true 并选择最相关的技能名称。",
|
||||||
|
"3. 最多选择 3 个技能。",
|
||||||
|
"4. 仅返回 JSON,不要附加任何其他文字。",
|
||||||
|
"",
|
||||||
|
"可用技能池:",
|
||||||
|
strings.TrimSpace(skillPool.String()),
|
||||||
|
"",
|
||||||
|
"输出格式(严格 JSON):",
|
||||||
|
`{"need_skills": true, "selected_skills": ["技能名称1"], "reason": "简要说明"}`,
|
||||||
|
}, "\n")
|
||||||
|
|
||||||
|
routerUserPrompt := "用户输入:" + userInput
|
||||||
|
|
||||||
|
if o.log != nil {
|
||||||
|
o.log.Debugf("%s skill router request: skills_count=%d input_len=%d", traceLogPrefix, len(summaries), len(userInput))
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := o.routerLLM.Generate(ctx, routerSystemPrompt, routerUserPrompt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("router llm call failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if o.log != nil {
|
||||||
|
o.log.Debugf("%s skill router response: %s", traceLogPrefix, truncateForLog(raw, 500))
|
||||||
|
}
|
||||||
|
|
||||||
|
decision, err := parseCapabilityRoute(raw)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("router response parse failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !decision.NeedSkills || len(decision.SelectedSkills) == 0 {
|
||||||
|
if o.log != nil {
|
||||||
|
o.log.Infof("%s skill router: no skills needed, reason=%s", traceLogPrefix, decision.Reason)
|
||||||
|
}
|
||||||
|
return []knowledge.Skill{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据路由结果匹配完整技能内容
|
||||||
|
allSkills := o.getSkillsSnapshot()
|
||||||
|
selected := matchSkillsByName(allSkills, decision.SelectedSkills)
|
||||||
|
|
||||||
|
if o.log != nil {
|
||||||
|
o.log.Infof("%s skill router: need_skills=true requested=%v matched=%d reason=%s",
|
||||||
|
traceLogPrefix, decision.SelectedSkills, len(selected), decision.Reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
return selected, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchSkillsByName 根据名称列表从全量技能中模糊匹配。
|
||||||
|
func matchSkillsByName(allSkills []knowledge.Skill, names []string) []knowledge.Skill {
|
||||||
|
if len(names) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
matched := make([]knowledge.Skill, 0, len(names))
|
||||||
|
for _, wantName := range names {
|
||||||
|
want := strings.ToLower(strings.TrimSpace(wantName))
|
||||||
|
if want == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, sk := range allSkills {
|
||||||
|
skName := strings.ToLower(strings.TrimSpace(sk.Name))
|
||||||
|
if skName == want || strings.Contains(skName, want) || strings.Contains(want, skName) {
|
||||||
|
matched = append(matched, sk)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return matched
|
||||||
|
}
|
||||||
|
|
||||||
// runUnifiedReAct 执行统一的 ReAct 循环,使用原生 function calling API。
|
// runUnifiedReAct 执行统一的 ReAct 循环,使用原生 function calling API。
|
||||||
// messages 数组随交互动态增长:system → history → user → assistant(tool_calls) → tool → ...
|
// messages 数组随交互动态增长:system → history → user → assistant(tool_calls) → tool → ...
|
||||||
// 循环持续到 LLM 返回无 tool_calls 的纯文本回复(即最终回答)或达到安全上限。
|
// 循环持续到 LLM 返回无 tool_calls 的纯文本回复(即最终回答)或达到安全上限。
|
||||||
func (o *Orchestrator) runUnifiedReAct(ctx context.Context, chatID, userID, compressedContext, userInput string, fileCtx filePromptContext, appendFileIDText bool) (string, error) {
|
func (o *Orchestrator) runUnifiedReAct(ctx context.Context, chatID, userID, compressedContext, userInput string) (string, error) {
|
||||||
traceID := logger.TraceIDFromContext(ctx)
|
traceID := logger.TraceIDFromContext(ctx)
|
||||||
traceLogPrefix := "trace_id=" + traceID
|
traceLogPrefix := "trace_id=" + traceID
|
||||||
|
|
||||||
systemPrompt := o.buildUnifiedSystemPrompt(userInput)
|
// ===== LLM 意图路由:使用轻量模型判断是否需要加载技能 =====
|
||||||
|
var routedSkills []knowledge.Skill
|
||||||
|
if o.routerLLM != nil {
|
||||||
|
routed, routeErr := o.routeSkillsWithLLM(ctx, userInput)
|
||||||
|
if routeErr != nil {
|
||||||
|
if o.log != nil {
|
||||||
|
o.log.Warnf("%s skill router failed, fallback to keyword matching err=%v", traceLogPrefix, routeErr)
|
||||||
|
}
|
||||||
|
// 路由失败时 routedSkills 保持 nil,buildUnifiedSystemPrompt 回退到关键词匹配
|
||||||
|
} else {
|
||||||
|
routedSkills = routed
|
||||||
|
if o.log != nil {
|
||||||
|
names := make([]string, 0, len(routedSkills))
|
||||||
|
for _, sk := range routedSkills {
|
||||||
|
names = append(names, sk.Name)
|
||||||
|
}
|
||||||
|
o.log.Infof("%s skill router selected %d skills: %v", traceLogPrefix, len(routedSkills), names)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
systemPrompt := o.buildUnifiedSystemPrompt(userInput, routedSkills)
|
||||||
|
|
||||||
if o.log != nil {
|
if o.log != nil {
|
||||||
o.log.Infof("%s unified react start", traceLogPrefix)
|
o.log.Infof("%s unified react start", traceLogPrefix)
|
||||||
|
o.log.Debugf("%s system_prompt_len=%d", traceLogPrefix, len(systemPrompt))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查 LLM 客户端是否支持原生 tool_calls
|
// 检查 LLM 客户端是否支持原生 tool_calls
|
||||||
@@ -314,7 +530,7 @@ func (o *Orchestrator) runUnifiedReAct(ctx context.Context, chatID, userID, comp
|
|||||||
if o.log != nil {
|
if o.log != nil {
|
||||||
o.log.Warnf("%s llm client does not support ToolCallChatClient, falling back to legacy ReAct", traceLogPrefix)
|
o.log.Warnf("%s llm client does not support ToolCallChatClient, falling back to legacy ReAct", traceLogPrefix)
|
||||||
}
|
}
|
||||||
return o.runLegacyReAct(ctx, chatID, userID, compressedContext, userInput, fileCtx, appendFileIDText)
|
return o.runLegacyReAct(ctx, chatID, userID, compressedContext, userInput)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 构建初始 messages 数组
|
// 构建初始 messages 数组
|
||||||
@@ -322,13 +538,20 @@ func (o *Orchestrator) runUnifiedReAct(ctx context.Context, chatID, userID, comp
|
|||||||
messages = append(messages, llm.PromptMessage{Role: "system", Content: systemPrompt})
|
messages = append(messages, llm.PromptMessage{Role: "system", Content: systemPrompt})
|
||||||
|
|
||||||
// 加入历史会话上下文
|
// 加入历史会话上下文
|
||||||
//messages = append(messages, parseCompressedHistoryMessages(compressedContext)...)
|
messages = append(messages, parseCompressedHistoryMessages(compressedContext)...)
|
||||||
|
|
||||||
// 加入当前用户消息
|
// 加入当前用户消息
|
||||||
messages = append(messages, llm.PromptMessage{Role: "user", Content: userInput})
|
messages = append(messages, llm.PromptMessage{Role: "user", Content: userInput})
|
||||||
|
|
||||||
// 构建工具定义列表(通过 API tools 字段传递)
|
// 构建工具定义列表(通过 API tools 字段传递)
|
||||||
toolDefs := o.buildToolDefinitions()
|
toolDefs := o.buildToolDefinitions()
|
||||||
|
if o.log != nil {
|
||||||
|
toolNames := make([]string, 0, len(toolDefs))
|
||||||
|
for _, td := range toolDefs {
|
||||||
|
toolNames = append(toolNames, td.Function.Name)
|
||||||
|
}
|
||||||
|
o.log.Debugf("%s tool_defs_count=%d names=%v", traceLogPrefix, len(toolDefs), toolNames)
|
||||||
|
}
|
||||||
|
|
||||||
const maxSteps = 20
|
const maxSteps = 20
|
||||||
for step := 1; step <= maxSteps; step++ {
|
for step := 1; step <= maxSteps; step++ {
|
||||||
@@ -337,7 +560,7 @@ func (o *Orchestrator) runUnifiedReAct(ctx context.Context, chatID, userID, comp
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 调用 LLM(传入完整 messages + tools 定义)
|
// 调用 LLM(传入完整 messages + tools 定义)
|
||||||
completion, err := toolCallClient.GenerateWithTools(ctx, messages, toolDefs, fileCtx.FileIDs, appendFileIDText)
|
completion, err := toolCallClient.GenerateWithTools(ctx, messages, toolDefs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -391,7 +614,8 @@ func (o *Orchestrator) runUnifiedReAct(ctx context.Context, chatID, userID, comp
|
|||||||
}
|
}
|
||||||
|
|
||||||
if o.log != nil {
|
if o.log != nil {
|
||||||
o.log.Infof("%s react step=%d tool_call tool=%s input=%q", traceLogPrefix, step, toolName, toolInput)
|
o.log.Infof("%s react step=%d tool_call tool=%s input_len=%d", traceLogPrefix, step, toolName, len(toolInput))
|
||||||
|
o.log.Debugf("%s react step=%d tool=%s input=%q", traceLogPrefix, step, toolName, toolInput)
|
||||||
}
|
}
|
||||||
|
|
||||||
toolOut, toolErr := tool.Call(ctx, toolInput)
|
toolOut, toolErr := tool.Call(ctx, toolInput)
|
||||||
@@ -410,6 +634,7 @@ func (o *Orchestrator) runUnifiedReAct(ctx context.Context, chatID, userID, comp
|
|||||||
|
|
||||||
if o.log != nil {
|
if o.log != nil {
|
||||||
o.log.Infof("%s react step=%d tool=%s observation_len=%d", traceLogPrefix, step, toolName, len(obs))
|
o.log.Infof("%s react step=%d tool=%s observation_len=%d", traceLogPrefix, step, toolName, len(obs))
|
||||||
|
o.log.Debugf("%s react step=%d tool=%s observation=%q", traceLogPrefix, step, toolName, truncateForLog(obs, 500))
|
||||||
}
|
}
|
||||||
|
|
||||||
messages = append(messages, llm.PromptMessage{
|
messages = append(messages, llm.PromptMessage{
|
||||||
@@ -426,8 +651,217 @@ func (o *Orchestrator) runUnifiedReAct(ctx context.Context, chatID, userID, comp
|
|||||||
return "我尝试了多轮推理与工具调用,但仍未得到稳定结论。请给我更具体的约束或允许我继续尝试。", nil
|
return "我尝试了多轮推理与工具调用,但仍未得到稳定结论。请给我更具体的约束或允许我继续尝试。", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// runUnifiedReActStream 执行统一的 ReAct 循环并通过回调推送流式事件。
|
||||||
|
func (o *Orchestrator) runUnifiedReActStream(ctx context.Context, chatID, userID, compressedContext, userInput string, callback StreamEventCallback) (string, error) {
|
||||||
|
traceID := logger.TraceIDFromContext(ctx)
|
||||||
|
traceLogPrefix := "trace_id=" + traceID
|
||||||
|
|
||||||
|
// ===== LLM 意图路由:使用轻量模型判断是否需要加载技能 =====
|
||||||
|
var routedSkills []knowledge.Skill
|
||||||
|
if o.routerLLM != nil {
|
||||||
|
routed, routeErr := o.routeSkillsWithLLM(ctx, userInput)
|
||||||
|
if routeErr != nil {
|
||||||
|
if o.log != nil {
|
||||||
|
o.log.Warnf("%s skill router failed, fallback to keyword matching err=%v", traceLogPrefix, routeErr)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
routedSkills = routed
|
||||||
|
if o.log != nil {
|
||||||
|
names := make([]string, 0, len(routedSkills))
|
||||||
|
for _, sk := range routedSkills {
|
||||||
|
names = append(names, sk.Name)
|
||||||
|
}
|
||||||
|
o.log.Infof("%s skill router selected %d skills: %v", traceLogPrefix, len(routedSkills), names)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
systemPrompt := o.buildUnifiedSystemPrompt(userInput, routedSkills)
|
||||||
|
|
||||||
|
if o.log != nil {
|
||||||
|
o.log.Infof("%s unified react stream start", traceLogPrefix)
|
||||||
|
o.log.Debugf("%s system_prompt_len=%d", traceLogPrefix, len(systemPrompt))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查 LLM 客户端是否支持原生 tool_calls
|
||||||
|
toolCallClient, supportsToolCalls := o.llm.(llm.ToolCallChatClient)
|
||||||
|
if !supportsToolCalls {
|
||||||
|
if o.log != nil {
|
||||||
|
o.log.Warnf("%s llm client does not support ToolCallChatClient, stream mode not available", traceLogPrefix)
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("stream mode requires ToolCallChatClient support")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建初始 messages 数组
|
||||||
|
messages := make([]llm.PromptMessage, 0, 32)
|
||||||
|
messages = append(messages, llm.PromptMessage{Role: "system", Content: systemPrompt})
|
||||||
|
messages = append(messages, parseCompressedHistoryMessages(compressedContext)...)
|
||||||
|
messages = append(messages, llm.PromptMessage{Role: "user", Content: userInput})
|
||||||
|
|
||||||
|
// 构建工具定义列表
|
||||||
|
toolDefs := o.buildToolDefinitions()
|
||||||
|
if o.log != nil {
|
||||||
|
toolNames := make([]string, 0, len(toolDefs))
|
||||||
|
for _, td := range toolDefs {
|
||||||
|
toolNames = append(toolNames, td.Function.Name)
|
||||||
|
}
|
||||||
|
o.log.Debugf("%s tool_defs_count=%d names=%v", traceLogPrefix, len(toolDefs), toolNames)
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxSteps = 20
|
||||||
|
for step := 1; step <= maxSteps; step++ {
|
||||||
|
if o.log != nil {
|
||||||
|
o.log.Infof("%s react stream step=%d start messages_count=%d", traceLogPrefix, step, len(messages))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用 LLM
|
||||||
|
completion, err := toolCallClient.GenerateWithTools(ctx, messages, toolDefs)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if o.log != nil {
|
||||||
|
o.log.Infof("%s react stream step=%d content_len=%d tool_calls=%d",
|
||||||
|
traceLogPrefix, step, len(completion.Content), len(completion.ToolCalls))
|
||||||
|
if completion.Content != "" {
|
||||||
|
o.log.Debugf("%s react stream step=%d thought=%q", traceLogPrefix, step, completion.Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 推送思考过程事件
|
||||||
|
if completion.Content != "" {
|
||||||
|
if err := callback(StreamEvent{
|
||||||
|
Type: StreamEventTypeThought,
|
||||||
|
Content: completion.Content,
|
||||||
|
Step: step,
|
||||||
|
}); err != nil {
|
||||||
|
return "", fmt.Errorf("callback error: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 无 tool_calls → 最终回答 ==========
|
||||||
|
if len(completion.ToolCalls) == 0 {
|
||||||
|
finalText := strings.TrimSpace(completion.Content)
|
||||||
|
if finalText == "" {
|
||||||
|
finalText = "已完成处理。"
|
||||||
|
}
|
||||||
|
if o.log != nil {
|
||||||
|
o.log.Infof("%s react stream final at step=%d answer_len=%d", traceLogPrefix, step, len(finalText))
|
||||||
|
}
|
||||||
|
// 推送最终答案事件
|
||||||
|
if err := callback(StreamEvent{
|
||||||
|
Type: StreamEventTypeFinal,
|
||||||
|
Content: finalText,
|
||||||
|
Step: step,
|
||||||
|
}); err != nil {
|
||||||
|
return "", fmt.Errorf("callback error: %w", err)
|
||||||
|
}
|
||||||
|
return finalText, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 有 tool_calls → 执行工具 ==========
|
||||||
|
assistantMsg := llm.PromptMessage{
|
||||||
|
Role: "assistant",
|
||||||
|
Content: completion.Content,
|
||||||
|
ToolCalls: completion.ToolCalls,
|
||||||
|
}
|
||||||
|
messages = append(messages, assistantMsg)
|
||||||
|
|
||||||
|
// 逐个执行工具调用
|
||||||
|
for _, tc := range completion.ToolCalls {
|
||||||
|
toolName := strings.ToLower(strings.TrimSpace(tc.Function.Name))
|
||||||
|
toolInput := extractToolInput(tc.Function.Arguments)
|
||||||
|
|
||||||
|
// 推送工具调用事件
|
||||||
|
if err := callback(StreamEvent{
|
||||||
|
Type: StreamEventTypeToolCall,
|
||||||
|
Content: toolInput,
|
||||||
|
Step: step,
|
||||||
|
ToolName: toolName,
|
||||||
|
}); err != nil {
|
||||||
|
return "", fmt.Errorf("callback error: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tool, ok := o.tools.Get(toolName)
|
||||||
|
if !ok {
|
||||||
|
if o.log != nil {
|
||||||
|
o.log.Warnf("%s react stream step=%d tool_not_found=%s", traceLogPrefix, step, toolName)
|
||||||
|
}
|
||||||
|
// 推送错误事件
|
||||||
|
errMsg := "工具不存在:" + toolName
|
||||||
|
if err := callback(StreamEvent{
|
||||||
|
Type: StreamEventTypeError,
|
||||||
|
Content: errMsg,
|
||||||
|
Step: step,
|
||||||
|
ToolName: toolName,
|
||||||
|
}); err != nil {
|
||||||
|
return "", fmt.Errorf("callback error: %w", err)
|
||||||
|
}
|
||||||
|
messages = append(messages, llm.PromptMessage{
|
||||||
|
Role: "tool",
|
||||||
|
ToolCallID: tc.ID,
|
||||||
|
Name: tc.Function.Name,
|
||||||
|
Content: formatToolErrorObservation("TOOL_NOT_FOUND", toolName, "该工具不存在,请检查工具名称后重试"),
|
||||||
|
})
|
||||||
|
o.emitCapabilityGap(chatID, userID, userInput, "tool_not_found:"+toolName)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if o.log != nil {
|
||||||
|
o.log.Infof("%s react stream step=%d tool_call tool=%s input_len=%d", traceLogPrefix, step, toolName, len(toolInput))
|
||||||
|
o.log.Debugf("%s react stream step=%d tool=%s input=%q", traceLogPrefix, step, toolName, toolInput)
|
||||||
|
}
|
||||||
|
|
||||||
|
toolOut, toolErr := tool.Call(ctx, toolInput)
|
||||||
|
obs := strings.TrimSpace(toolOut)
|
||||||
|
if obs == "" {
|
||||||
|
obs = "(empty output)"
|
||||||
|
}
|
||||||
|
if toolErr != nil {
|
||||||
|
obs = formatToolErrorObservation("TOOL_EXEC_ERROR", toolName, toolErr.Error()) + "\nOUTPUT:\n" + obs
|
||||||
|
o.emitCapabilityGap(chatID, userID, userInput, "tool_call_failed:"+toolName)
|
||||||
|
}
|
||||||
|
// 限制观察值长度防止超出 LLM 上下文窗口
|
||||||
|
if len(obs) > 4000 {
|
||||||
|
obs = obs[:4000] + "\n...(truncated)"
|
||||||
|
}
|
||||||
|
|
||||||
|
if o.log != nil {
|
||||||
|
o.log.Infof("%s react stream step=%d tool=%s observation_len=%d", traceLogPrefix, step, toolName, len(obs))
|
||||||
|
o.log.Debugf("%s react stream step=%d tool=%s observation=%q", traceLogPrefix, step, toolName, truncateForLog(obs, 500))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 推送工具结果事件
|
||||||
|
if err := callback(StreamEvent{
|
||||||
|
Type: StreamEventTypeToolResult,
|
||||||
|
Content: obs,
|
||||||
|
Step: step,
|
||||||
|
ToolName: toolName,
|
||||||
|
}); err != nil {
|
||||||
|
return "", fmt.Errorf("callback error: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
messages = append(messages, llm.PromptMessage{
|
||||||
|
Role: "tool",
|
||||||
|
ToolCallID: tc.ID,
|
||||||
|
Name: tc.Function.Name,
|
||||||
|
Content: obs,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 达到安全上限
|
||||||
|
o.emitCapabilityGap(chatID, userID, userInput, "react_step_exhausted")
|
||||||
|
errMsg := "我尝试了多轮推理与工具调用,但仍未得到稳定结论。请给我更具体的约束或允许我继续尝试。"
|
||||||
|
_ = callback(StreamEvent{
|
||||||
|
Type: StreamEventTypeError,
|
||||||
|
Content: errMsg,
|
||||||
|
})
|
||||||
|
return errMsg, nil
|
||||||
|
}
|
||||||
|
|
||||||
// runLegacyReAct 是旧版基于 JSON 决策解析的 ReAct 循环,作为不支持 tool_calls 的 LLM 的降级方案。
|
// runLegacyReAct 是旧版基于 JSON 决策解析的 ReAct 循环,作为不支持 tool_calls 的 LLM 的降级方案。
|
||||||
func (o *Orchestrator) runLegacyReAct(ctx context.Context, chatID, userID, compressedContext, userInput string, fileCtx filePromptContext, appendFileIDText bool) (string, error) {
|
func (o *Orchestrator) runLegacyReAct(ctx context.Context, chatID, userID, compressedContext, userInput string) (string, error) {
|
||||||
traceID := logger.TraceIDFromContext(ctx)
|
traceID := logger.TraceIDFromContext(ctx)
|
||||||
traceLogPrefix := "trace_id=" + traceID
|
traceLogPrefix := "trace_id=" + traceID
|
||||||
|
|
||||||
@@ -441,8 +875,8 @@ func (o *Orchestrator) runLegacyReAct(ctx context.Context, chatID, userID, compr
|
|||||||
o.log.Infof("%s legacy react step=%d start", traceLogPrefix, step)
|
o.log.Infof("%s legacy react step=%d start", traceLogPrefix, step)
|
||||||
}
|
}
|
||||||
|
|
||||||
messages := buildReActMessages(systemPrompt, compressedContext, userInput, fileCtx.Summary, scratchpad)
|
messages := buildReActMessages(systemPrompt, compressedContext, userInput, scratchpad)
|
||||||
raw, err := o.generateWithOptionalFilesMessages(ctx, messages, fileCtx.FileIDs, appendFileIDText)
|
raw, err := o.generateMessages(ctx, messages)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -584,105 +1018,19 @@ func extractToolInput(arguments string) string {
|
|||||||
return arguments
|
return arguments
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o *Orchestrator) prepareFilePromptContext(ctx context.Context, files []llm.InputFile, pending []pendingFileRef) filePromptContext {
|
func (o *Orchestrator) generateMessages(ctx context.Context, messages []llm.PromptMessage) (string, error) {
|
||||||
ctxOut := filePromptContext{}
|
|
||||||
if len(pending) > 0 {
|
|
||||||
for _, p := range pending {
|
|
||||||
id := strings.TrimSpace(p.ID)
|
|
||||||
if id == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
ctxOut.FileIDs = append(ctxOut.FileIDs, id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(files) == 0 {
|
|
||||||
ctxOut.Summary = buildFileSummary(pending, nil)
|
|
||||||
return ctxOut
|
|
||||||
}
|
|
||||||
uploader, ok := o.llm.(llm.FileUploader)
|
|
||||||
if !ok {
|
|
||||||
return filePromptContext{FatalReason: "检测到文件输入,但当前 LLM 客户端不支持文件上传接口。"}
|
|
||||||
}
|
|
||||||
|
|
||||||
uploaded := make([]pendingFileRef, 0, len(files))
|
|
||||||
for i, f := range files {
|
|
||||||
if strings.TrimSpace(f.FileName) == "" || len(f.Content) == 0 {
|
|
||||||
return filePromptContext{FatalReason: fmt.Sprintf("file[%d] 缺少文件名或内容,无法上传。", i+1)}
|
|
||||||
}
|
|
||||||
fileID, err := uploader.UploadFile(ctx, f, "file-extract")
|
|
||||||
if err != nil {
|
|
||||||
return filePromptContext{FatalReason: fmt.Sprintf("file[%d] name=%s 上传失败: %v", i+1, f.FileName, err)}
|
|
||||||
}
|
|
||||||
ctxOut.FileIDs = append(ctxOut.FileIDs, fileID)
|
|
||||||
uploaded = append(uploaded, pendingFileRef{
|
|
||||||
ID: fileID,
|
|
||||||
Name: strings.TrimSpace(f.FileName),
|
|
||||||
MimeType: defaultIfEmpty(strings.TrimSpace(f.MimeType), "application/octet-stream"),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
ctxOut.Uploaded = uploaded
|
|
||||||
ctxOut.Summary = buildFileSummary(pending, uploaded)
|
|
||||||
return ctxOut
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildFileSummary(pending, uploaded []pendingFileRef) string {
|
|
||||||
if len(pending) == 0 && len(uploaded) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
lines := make([]string, 0, len(pending)+len(uploaded)+2)
|
|
||||||
lines = append(lines, "以下文件 file_id 可用于本轮问答:")
|
|
||||||
idx := 1
|
|
||||||
for _, p := range pending {
|
|
||||||
id := strings.TrimSpace(p.ID)
|
|
||||||
if id == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
lines = append(lines, fmt.Sprintf("- cached_file[%d] name=%s mime=%s file_id=%s", idx, defaultIfEmpty(strings.TrimSpace(p.Name), "(unknown)"), defaultIfEmpty(strings.TrimSpace(p.MimeType), "application/octet-stream"), id))
|
|
||||||
idx++
|
|
||||||
}
|
|
||||||
for _, p := range uploaded {
|
|
||||||
id := strings.TrimSpace(p.ID)
|
|
||||||
if id == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
lines = append(lines, fmt.Sprintf("- uploaded_file[%d] name=%s mime=%s file_id=%s", idx, defaultIfEmpty(strings.TrimSpace(p.Name), "(unknown)"), defaultIfEmpty(strings.TrimSpace(p.MimeType), "application/octet-stream"), id))
|
|
||||||
idx++
|
|
||||||
}
|
|
||||||
if len(lines) == 1 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return strings.Join(lines, "\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (o *Orchestrator) generateWithOptionalFilesMessages(ctx context.Context, messages []llm.PromptMessage, fileIDs []string, appendFileIDText bool) (string, error) {
|
|
||||||
ids := nonEmptyIDs(fileIDs)
|
|
||||||
if len(ids) == 0 {
|
|
||||||
if client, ok := o.llm.(llm.MessageChatClient); ok {
|
if client, ok := o.llm.(llm.MessageChatClient); ok {
|
||||||
return client.GenerateMessages(ctx, messages)
|
return client.GenerateMessages(ctx, messages)
|
||||||
}
|
}
|
||||||
systemPrompt, userPrompt := fallbackPromptsFromMessages(messages)
|
systemPrompt, userPrompt := fallbackPromptsFromMessages(messages)
|
||||||
return o.llm.Generate(ctx, systemPrompt, userPrompt)
|
return o.llm.Generate(ctx, systemPrompt, userPrompt)
|
||||||
}
|
|
||||||
if client, ok := o.llm.(llm.FileMessageChatClient); ok {
|
|
||||||
return client.GenerateMessagesWithFiles(ctx, messages, ids, appendFileIDText)
|
|
||||||
}
|
|
||||||
client, ok := o.llm.(llm.FileChatClient)
|
|
||||||
if !ok {
|
|
||||||
systemPrompt, userPrompt := fallbackPromptsFromMessages(messages)
|
|
||||||
return o.llm.Generate(ctx, systemPrompt, userPrompt)
|
|
||||||
}
|
|
||||||
systemPrompt, userPrompt := fallbackPromptsFromMessages(messages)
|
|
||||||
return client.GenerateWithFiles(ctx, systemPrompt, userPrompt, ids, appendFileIDText)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildReActMessages(systemPrompt, compressedContext, userInput, fileSummary, scratchpad string) []llm.PromptMessage {
|
func buildReActMessages(systemPrompt, compressedContext, userInput, scratchpad string) []llm.PromptMessage {
|
||||||
msgs := make([]llm.PromptMessage, 0, 16)
|
msgs := make([]llm.PromptMessage, 0, 16)
|
||||||
msgs = append(msgs, llm.PromptMessage{Role: "system", Content: systemPrompt})
|
msgs = append(msgs, llm.PromptMessage{Role: "system", Content: systemPrompt})
|
||||||
msgs = append(msgs, parseCompressedHistoryMessages(compressedContext)...)
|
msgs = append(msgs, parseCompressedHistoryMessages(compressedContext)...)
|
||||||
|
|
||||||
if strings.TrimSpace(fileSummary) != "" {
|
|
||||||
msgs = append(msgs, llm.PromptMessage{Role: "assistant", Content: "文件上下文摘要:\n" + strings.TrimSpace(fileSummary)})
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(scratchpad) != "" {
|
if strings.TrimSpace(scratchpad) != "" {
|
||||||
msgs = append(msgs, llm.PromptMessage{Role: "assistant", Content: "推理记录:\n" + strings.TrimSpace(scratchpad)})
|
msgs = append(msgs, llm.PromptMessage{Role: "assistant", Content: "推理记录:\n" + strings.TrimSpace(scratchpad)})
|
||||||
}
|
}
|
||||||
@@ -735,20 +1083,6 @@ func fallbackPromptsFromMessages(messages []llm.PromptMessage) (string, string)
|
|||||||
return strings.Join(sysParts, "\n\n"), strings.Join(userParts, "\n")
|
return strings.Join(sysParts, "\n\n"), strings.Join(userParts, "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o *Orchestrator) buildFileUploadAck(ctx filePromptContext) string {
|
|
||||||
if len(ctx.FileIDs) == 0 {
|
|
||||||
return "文件已接收,但未拿到有效 file_id。请重新上传一次。"
|
|
||||||
}
|
|
||||||
lines := []string{
|
|
||||||
fmt.Sprintf("文件上传完成,已缓存 %d 个 file_id。", len(ctx.FileIDs)),
|
|
||||||
"请继续发送你的问题,我会结合这些文件内容和历史对话一起回答。",
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(ctx.Summary) != "" {
|
|
||||||
lines = append(lines, "", ctx.Summary)
|
|
||||||
}
|
|
||||||
return strings.Join(lines, "\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
func nonEmptyIDs(ids []string) []string {
|
func nonEmptyIDs(ids []string) []string {
|
||||||
if len(ids) == 0 {
|
if len(ids) == 0 {
|
||||||
return nil
|
return nil
|
||||||
@@ -769,20 +1103,6 @@ func nonEmptyIDs(ids []string) []string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c filePromptContext) toPendingRefs() []pendingFileRef {
|
|
||||||
if len(c.Uploaded) > 0 {
|
|
||||||
copied := make([]pendingFileRef, len(c.Uploaded))
|
|
||||||
copy(copied, c.Uploaded)
|
|
||||||
return sanitizePendingRefs(copied)
|
|
||||||
}
|
|
||||||
ids := nonEmptyIDs(c.FileIDs)
|
|
||||||
out := make([]pendingFileRef, 0, len(ids))
|
|
||||||
for _, id := range ids {
|
|
||||||
out = append(out, pendingFileRef{ID: id})
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func (o *Orchestrator) appendPendingFiles(chatID, userID string, refs []pendingFileRef) {
|
func (o *Orchestrator) appendPendingFiles(chatID, userID string, refs []pendingFileRef) {
|
||||||
refs = sanitizePendingRefs(refs)
|
refs = sanitizePendingRefs(refs)
|
||||||
if len(refs) == 0 {
|
if len(refs) == 0 {
|
||||||
@@ -890,6 +1210,9 @@ func (o *Orchestrator) selectRelevantSkills(userInput string, maxCount int) []kn
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
ranked = append(ranked, item{skill: sk, score: score})
|
ranked = append(ranked, item{skill: sk, score: score})
|
||||||
|
if o.log != nil {
|
||||||
|
o.log.Debugf("selectRelevantSkills skill=%q score=%d", sk.Name, score)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(ranked) == 0 {
|
if len(ranked) == 0 {
|
||||||
@@ -910,6 +1233,13 @@ func (o *Orchestrator) selectRelevantSkills(userInput string, maxCount int) []kn
|
|||||||
for _, r := range ranked {
|
for _, r := range ranked {
|
||||||
out = append(out, r.skill)
|
out = append(out, r.skill)
|
||||||
}
|
}
|
||||||
|
if o.log != nil {
|
||||||
|
selectedNames := make([]string, 0, len(out))
|
||||||
|
for _, sk := range out {
|
||||||
|
selectedNames = append(selectedNames, sk.Name)
|
||||||
|
}
|
||||||
|
o.log.Debugf("selectRelevantSkills query=%q matched=%d selected=%v", query, len(ranked), selectedNames)
|
||||||
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1181,3 +1511,10 @@ func (o *Orchestrator) formatToolDoc() string {
|
|||||||
}
|
}
|
||||||
return strings.TrimSpace(b.String())
|
return strings.TrimSpace(b.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func truncateForLog(s string, maxLen int) string {
|
||||||
|
if len(s) <= maxLen {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:maxLen] + "...(truncated)"
|
||||||
|
}
|
||||||
|
|||||||
@@ -46,3 +46,50 @@ func TestFormatRuntimeContextForPromptIncludesGOOS(t *testing.T) {
|
|||||||
t.Fatalf("expected runtime context contains GOOS=%s, got: %s", runtime.GOOS, doc)
|
t.Fatalf("expected runtime context contains GOOS=%s, got: %s", runtime.GOOS, doc)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMatchSkillsByNameExact(t *testing.T) {
|
||||||
|
all := []knowledge.Skill{
|
||||||
|
{Name: "SAFe PI Planning", Content: "PI规划技能"},
|
||||||
|
{Name: "文件系统查询专家", Content: "文件查询"},
|
||||||
|
{Name: "代码生成", Content: "代码生成技能"},
|
||||||
|
}
|
||||||
|
matched := matchSkillsByName(all, []string{"SAFe PI Planning"})
|
||||||
|
if len(matched) != 1 {
|
||||||
|
t.Fatalf("expected 1 match, got %d", len(matched))
|
||||||
|
}
|
||||||
|
if matched[0].Name != "SAFe PI Planning" {
|
||||||
|
t.Fatalf("expected SAFe PI Planning, got %s", matched[0].Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatchSkillsByNameFuzzy(t *testing.T) {
|
||||||
|
all := []knowledge.Skill{
|
||||||
|
{Name: "SAFe PI Planning", Content: "PI规划技能"},
|
||||||
|
{Name: "文件系统查询专家", Content: "文件查询"},
|
||||||
|
}
|
||||||
|
matched := matchSkillsByName(all, []string{"pi planning", "文件"})
|
||||||
|
if len(matched) != 2 {
|
||||||
|
t.Fatalf("expected 2 matches, got %d", len(matched))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatchSkillsByNameNoMatch(t *testing.T) {
|
||||||
|
all := []knowledge.Skill{
|
||||||
|
{Name: "文件系统查询专家", Content: "文件查询"},
|
||||||
|
}
|
||||||
|
matched := matchSkillsByName(all, []string{"不存在的技能"})
|
||||||
|
if len(matched) != 0 {
|
||||||
|
t.Fatalf("expected 0 matches, got %d", len(matched))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatchSkillsByNameEmpty(t *testing.T) {
|
||||||
|
matched := matchSkillsByName(nil, []string{"any"})
|
||||||
|
if len(matched) != 0 {
|
||||||
|
t.Fatalf("expected 0 matches, got %d", len(matched))
|
||||||
|
}
|
||||||
|
matched = matchSkillsByName([]knowledge.Skill{{Name: "test"}}, nil)
|
||||||
|
if len(matched) != 0 {
|
||||||
|
t.Fatalf("expected 0 matches, got %d", len(matched))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ type Config struct {
|
|||||||
LLM LLMConfig
|
LLM LLMConfig
|
||||||
Security SecurityConfig
|
Security SecurityConfig
|
||||||
WebSearch WebSearchConfig
|
WebSearch WebSearchConfig
|
||||||
|
Gitea GiteaConfig
|
||||||
|
|
||||||
SQLitePath string
|
SQLitePath string
|
||||||
}
|
}
|
||||||
@@ -56,7 +57,7 @@ type LLMConfig struct {
|
|||||||
APIKey string
|
APIKey string
|
||||||
Model string
|
Model string
|
||||||
FileModel string
|
FileModel string
|
||||||
FilePromptMode string
|
RouterModel string // 轻量路由模型,用于技能意图路由;为空则仅用关键词匹配
|
||||||
}
|
}
|
||||||
|
|
||||||
type SecurityConfig struct {
|
type SecurityConfig struct {
|
||||||
@@ -70,6 +71,13 @@ type WebSearchConfig struct {
|
|||||||
APIKey string
|
APIKey string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type GiteaConfig struct {
|
||||||
|
BaseURL string // Gitea 实例地址
|
||||||
|
Token string // Personal Access Token
|
||||||
|
Owner string // 仓库所有者
|
||||||
|
Repo string // 仓库名称
|
||||||
|
}
|
||||||
|
|
||||||
func Load() (Config, error) {
|
func Load() (Config, error) {
|
||||||
agentWorkspaceDir := resolveAgentWorkspaceDir()
|
agentWorkspaceDir := resolveAgentWorkspaceDir()
|
||||||
if err := preloadEnvFiles(); err != nil {
|
if err := preloadEnvFiles(); err != nil {
|
||||||
@@ -110,7 +118,7 @@ func Load() (Config, error) {
|
|||||||
APIKey: strings.TrimSpace(os.Getenv("LLM_API_KEY")),
|
APIKey: strings.TrimSpace(os.Getenv("LLM_API_KEY")),
|
||||||
Model: defaultIfEmpty(os.Getenv("LLM_MODEL"), "gpt-4o-mini"),
|
Model: defaultIfEmpty(os.Getenv("LLM_MODEL"), "gpt-4o-mini"),
|
||||||
FileModel: defaultIfEmpty(os.Getenv("LLM_FILE_MODEL"), defaultIfEmpty(os.Getenv("LLM_MODEL"), "gpt-4o-mini")),
|
FileModel: defaultIfEmpty(os.Getenv("LLM_FILE_MODEL"), defaultIfEmpty(os.Getenv("LLM_MODEL"), "gpt-4o-mini")),
|
||||||
FilePromptMode: normalizeFilePromptMode(defaultIfEmpty(os.Getenv("LLM_FILE_PROMPT_MODE"), "user_content_file_parts")),
|
RouterModel: strings.TrimSpace(os.Getenv("LLM_ROUTER_MODEL")),
|
||||||
},
|
},
|
||||||
SQLitePath: defaultIfEmpty(os.Getenv("SQLITE_PATH"), filepath.Join(defaultDataDir, "laodingbot.db")),
|
SQLitePath: defaultIfEmpty(os.Getenv("SQLITE_PATH"), filepath.Join(defaultDataDir, "laodingbot.db")),
|
||||||
WebSearch: WebSearchConfig{
|
WebSearch: WebSearchConfig{
|
||||||
@@ -122,6 +130,12 @@ func Load() (Config, error) {
|
|||||||
AllowedCommands: splitCSV(defaultIfEmpty(os.Getenv("ALLOWED_COMMANDS"), "pwd,ls,cat,echo,grep,find,head,tail,go")),
|
AllowedCommands: splitCSV(defaultIfEmpty(os.Getenv("ALLOWED_COMMANDS"), "pwd,ls,cat,echo,grep,find,head,tail,go")),
|
||||||
WorkDir: defaultIfEmpty(os.Getenv("WORK_DIR"), defaultWorkSubdir),
|
WorkDir: defaultIfEmpty(os.Getenv("WORK_DIR"), defaultWorkSubdir),
|
||||||
},
|
},
|
||||||
|
Gitea: GiteaConfig{
|
||||||
|
BaseURL: strings.TrimRight(strings.TrimSpace(os.Getenv("GITEA_BASE_URL")), "/"),
|
||||||
|
Token: strings.TrimSpace(os.Getenv("GITEA_TOKEN")),
|
||||||
|
Owner: strings.TrimSpace(os.Getenv("GITEA_OWNER")),
|
||||||
|
Repo: strings.TrimSpace(os.Getenv("GITEA_REPO")),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg.MessageChannel = strings.ToLower(strings.TrimSpace(cfg.MessageChannel))
|
cfg.MessageChannel = strings.ToLower(strings.TrimSpace(cfg.MessageChannel))
|
||||||
@@ -178,9 +192,6 @@ func Load() (Config, error) {
|
|||||||
if cfg.LLM.APIKey == "" {
|
if cfg.LLM.APIKey == "" {
|
||||||
return Config{}, fmt.Errorf("LLM_API_KEY is required")
|
return Config{}, fmt.Errorf("LLM_API_KEY is required")
|
||||||
}
|
}
|
||||||
if cfg.LLM.FilePromptMode != "user_content_file_parts" && cfg.LLM.FilePromptMode != "system_fileid_uri" {
|
|
||||||
return Config{}, fmt.Errorf("LLM_FILE_PROMPT_MODE must be one of: user_content_file_parts, system_fileid_uri")
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg.SoulPath = resolvePathInWorkspace(cfg.SoulPath, agentWorkspaceDir)
|
cfg.SoulPath = resolvePathInWorkspace(cfg.SoulPath, agentWorkspaceDir)
|
||||||
cfg.SkillsDir = resolvePathInWorkspace(cfg.SkillsDir, agentWorkspaceDir)
|
cfg.SkillsDir = resolvePathInWorkspace(cfg.SkillsDir, agentWorkspaceDir)
|
||||||
@@ -417,14 +428,3 @@ func splitCSV(raw string) []string {
|
|||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeFilePromptMode(v string) string {
|
|
||||||
v = strings.ToLower(strings.TrimSpace(v))
|
|
||||||
if v == "" {
|
|
||||||
return "user_content_file_parts"
|
|
||||||
}
|
|
||||||
if v == "system_fileid" || v == "system_fileid_url" || v == "system_fileid_uri" {
|
|
||||||
return "system_fileid_uri"
|
|
||||||
}
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -33,21 +33,13 @@ type MessageChatClient interface {
|
|||||||
GenerateMessages(ctx context.Context, messages []PromptMessage) (string, error)
|
GenerateMessages(ctx context.Context, messages []PromptMessage) (string, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type FileChatClient interface {
|
|
||||||
GenerateWithFiles(ctx context.Context, systemPrompt, userPrompt string, fileIDs []string, appendFileIDText bool) (string, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
type FileMessageChatClient interface {
|
|
||||||
GenerateMessagesWithFiles(ctx context.Context, messages []PromptMessage, fileIDs []string, appendFileIDText bool) (string, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
type FileUploader interface {
|
type FileUploader interface {
|
||||||
UploadFile(ctx context.Context, file InputFile, purpose string) (string, error)
|
UploadFile(ctx context.Context, file InputFile, purpose string) (string, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ToolCallChatClient 支持原生 function calling 的 LLM 客户端接口。
|
// ToolCallChatClient 支持原生 function calling 的 LLM 客户端接口。
|
||||||
type ToolCallChatClient interface {
|
type ToolCallChatClient interface {
|
||||||
GenerateWithTools(ctx context.Context, messages []PromptMessage, tools []ToolDefinition, fileIDs []string, appendFileIDText bool) (*ChatCompletion, error)
|
GenerateWithTools(ctx context.Context, messages []PromptMessage, tools []ToolDefinition) (*ChatCompletion, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ToolDefinition 描述一个可供 LLM 调用的工具函数定义。
|
// ToolDefinition 描述一个可供 LLM 调用的工具函数定义。
|
||||||
@@ -91,8 +83,6 @@ type InputFile struct {
|
|||||||
type OpenAICompatibleClient struct {
|
type OpenAICompatibleClient struct {
|
||||||
client openai.Client
|
client openai.Client
|
||||||
model string
|
model string
|
||||||
fileModel string
|
|
||||||
filePromptMode string
|
|
||||||
log *logger.Logger
|
log *logger.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,8 +97,6 @@ func NewOpenAICompatibleClient(cfg config.LLMConfig, log *logger.Logger) *OpenAI
|
|||||||
return &OpenAICompatibleClient{
|
return &OpenAICompatibleClient{
|
||||||
client: openai.NewClient(opts...),
|
client: openai.NewClient(opts...),
|
||||||
model: cfg.Model,
|
model: cfg.Model,
|
||||||
fileModel: cfg.FileModel,
|
|
||||||
filePromptMode: cfg.FilePromptMode,
|
|
||||||
log: log,
|
log: log,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -118,38 +106,22 @@ func (c *OpenAICompatibleClient) Generate(ctx context.Context, systemPrompt, use
|
|||||||
{Role: "system", Content: systemPrompt},
|
{Role: "system", Content: systemPrompt},
|
||||||
{Role: "user", Content: userPrompt},
|
{Role: "user", Content: userPrompt},
|
||||||
}
|
}
|
||||||
return c.generateWithMessagesInternal(ctx, messages, nil, false)
|
return c.generateWithMessagesInternal(ctx, messages)
|
||||||
}
|
|
||||||
|
|
||||||
func (c *OpenAICompatibleClient) GenerateWithFiles(ctx context.Context, systemPrompt, userPrompt string, fileIDs []string, appendFileIDText bool) (string, error) {
|
|
||||||
messages := []PromptMessage{
|
|
||||||
{Role: "system", Content: systemPrompt},
|
|
||||||
{Role: "user", Content: userPrompt},
|
|
||||||
}
|
|
||||||
return c.generateWithMessagesInternal(ctx, messages, fileIDs, appendFileIDText)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *OpenAICompatibleClient) GenerateMessages(ctx context.Context, messages []PromptMessage) (string, error) {
|
func (c *OpenAICompatibleClient) GenerateMessages(ctx context.Context, messages []PromptMessage) (string, error) {
|
||||||
return c.generateWithMessagesInternal(ctx, messages, nil, false)
|
return c.generateWithMessagesInternal(ctx, messages)
|
||||||
}
|
|
||||||
|
|
||||||
func (c *OpenAICompatibleClient) GenerateMessagesWithFiles(ctx context.Context, messages []PromptMessage, fileIDs []string, appendFileIDText bool) (string, error) {
|
|
||||||
return c.generateWithMessagesInternal(ctx, messages, fileIDs, appendFileIDText)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateWithTools 使用原生 function calling 发送请求,返回结构化的 ChatCompletion。
|
// GenerateWithTools 使用原生 function calling 发送请求,返回结构化的 ChatCompletion。
|
||||||
func (c *OpenAICompatibleClient) GenerateWithTools(ctx context.Context, messages []PromptMessage, tools []ToolDefinition, fileIDs []string, appendFileIDText bool) (*ChatCompletion, error) {
|
func (c *OpenAICompatibleClient) GenerateWithTools(ctx context.Context, messages []PromptMessage, tools []ToolDefinition) (*ChatCompletion, error) {
|
||||||
model := c.model
|
model := c.model
|
||||||
ids := nonEmptyIDs(fileIDs)
|
|
||||||
if len(ids) > 0 && strings.TrimSpace(c.fileModel) != "" {
|
|
||||||
model = c.fileModel
|
|
||||||
}
|
|
||||||
|
|
||||||
sdkMessages := buildSDKMessages(messages, ids, c.normalizedFilePromptMode(), appendFileIDText)
|
sdkMessages := buildSDKMessages(messages)
|
||||||
sdkTools := toSDKTools(tools)
|
sdkTools := toSDKTools(tools)
|
||||||
|
|
||||||
if c.log != nil {
|
if c.log != nil {
|
||||||
c.log.Debugf("llm tool-call request start model=%s messages=%d tools=%d files=%d", model, len(sdkMessages), len(sdkTools), len(ids))
|
c.log.Debugf("llm tool-call request start model=%s messages=%d tools=%d", model, len(sdkMessages), len(sdkTools))
|
||||||
}
|
}
|
||||||
|
|
||||||
params := openai.ChatCompletionNewParams{
|
params := openai.ChatCompletionNewParams{
|
||||||
@@ -188,12 +160,8 @@ func (c *OpenAICompatibleClient) GenerateWithTools(ctx context.Context, messages
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *OpenAICompatibleClient) generateWithMessagesInternal(ctx context.Context, messages []PromptMessage, fileIDs []string, appendFileIDText bool) (string, error) {
|
func (c *OpenAICompatibleClient) generateWithMessagesInternal(ctx context.Context, messages []PromptMessage) (string, error) {
|
||||||
model := c.model
|
model := c.model
|
||||||
ids := nonEmptyIDs(fileIDs)
|
|
||||||
if len(ids) > 0 && strings.TrimSpace(c.fileModel) != "" {
|
|
||||||
model = c.fileModel
|
|
||||||
}
|
|
||||||
|
|
||||||
baseMessages := normalizePromptMessages(messages)
|
baseMessages := normalizePromptMessages(messages)
|
||||||
if len(baseMessages) == 0 {
|
if len(baseMessages) == 0 {
|
||||||
@@ -202,10 +170,10 @@ func (c *OpenAICompatibleClient) generateWithMessagesInternal(ctx context.Contex
|
|||||||
|
|
||||||
systemLen, userLen := promptMessageLengths(baseMessages)
|
systemLen, userLen := promptMessageLengths(baseMessages)
|
||||||
if c.log != nil {
|
if c.log != nil {
|
||||||
c.log.Debugf("llm request start model=%s system_len=%d user_len=%d file_count=%d file_prompt_mode=%s", model, systemLen, userLen, len(ids), c.normalizedFilePromptMode())
|
c.log.Debugf("llm request start model=%s system_len=%d user_len=%d", model, systemLen, userLen)
|
||||||
}
|
}
|
||||||
|
|
||||||
sdkMessages := buildSDKMessages(baseMessages, ids, c.normalizedFilePromptMode(), appendFileIDText)
|
sdkMessages := buildSDKMessages(baseMessages)
|
||||||
|
|
||||||
params := openai.ChatCompletionNewParams{
|
params := openai.ChatCompletionNewParams{
|
||||||
Model: shared.ChatModel(model),
|
Model: shared.ChatModel(model),
|
||||||
@@ -234,10 +202,9 @@ func (c *OpenAICompatibleClient) generateWithMessagesInternal(ctx context.Contex
|
|||||||
return content, nil
|
return content, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildSDKMessages 将 PromptMessage 列表转换为 openai SDK 的消息格式,并注入 file_id(如需要)。
|
// buildSDKMessages 将 PromptMessage 列表转换为 openai SDK 的消息格式。
|
||||||
func buildSDKMessages(base []PromptMessage, fileIDs []string, mode string, appendFileIDText bool) []openai.ChatCompletionMessageParamUnion {
|
func buildSDKMessages(base []PromptMessage) []openai.ChatCompletionMessageParamUnion {
|
||||||
mode = strings.ToLower(strings.TrimSpace(mode))
|
out := make([]openai.ChatCompletionMessageParamUnion, 0, len(base))
|
||||||
out := make([]openai.ChatCompletionMessageParamUnion, 0, len(base)+2)
|
|
||||||
|
|
||||||
for _, m := range base {
|
for _, m := range base {
|
||||||
role := normalizeRole(m.Role)
|
role := normalizeRole(m.Role)
|
||||||
@@ -247,34 +214,6 @@ func buildSDKMessages(base []PromptMessage, fileIDs []string, mode string, appen
|
|||||||
out = append(out, toSDKMessage(m, role))
|
out = append(out, toSDKMessage(m, role))
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(fileIDs) == 0 {
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
if appendFileIDText {
|
|
||||||
// WebUI 场景:将首个 fileID 作为 text part 追加到最后一个 user 消息。
|
|
||||||
firstFileID := strings.TrimSpace(fileIDs[0])
|
|
||||||
if firstFileID == "" {
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
for i := len(out) - 1; i >= 0; i-- {
|
|
||||||
if r := out[i].GetRole(); r != nil && *r == "user" {
|
|
||||||
out[i] = buildUserMessageWithFileIDText(out[i], firstFileID)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out = append(out, buildUserMessageWithFileIDText(openai.UserMessage(""), firstFileID))
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// 非 WebUI 场景:保持原有 file content part 方式。
|
|
||||||
for i := len(out) - 1; i >= 0; i-- {
|
|
||||||
if r := out[i].GetRole(); r != nil && *r == "user" {
|
|
||||||
out[i] = buildUserMessageWithFiles(out[i], fileIDs)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out = append(out, buildUserMessageWithFiles(openai.UserMessage(""), fileIDs))
|
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,53 +248,6 @@ func toSDKMessage(m PromptMessage, role string) openai.ChatCompletionMessagePara
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildUserMessageWithFileIDText 为 user 消息追加一个 text part,内容为 fileID。
|
|
||||||
func buildUserMessageWithFileIDText(msg openai.ChatCompletionMessageParamUnion, fileID string) openai.ChatCompletionMessageParamUnion {
|
|
||||||
// 提取已有的文本内容
|
|
||||||
text := ""
|
|
||||||
if s, ok := msg.GetContent().AsAny().(*string); ok && s != nil {
|
|
||||||
text = *s
|
|
||||||
}
|
|
||||||
fileID = strings.TrimSpace(fileID)
|
|
||||||
if fileID == "" {
|
|
||||||
return msg
|
|
||||||
}
|
|
||||||
|
|
||||||
parts := make([]openai.ChatCompletionContentPartUnionParam, 0, 2)
|
|
||||||
if strings.TrimSpace(text) != "" {
|
|
||||||
parts = append(parts, openai.TextContentPart(text))
|
|
||||||
}
|
|
||||||
parts = append(parts, openai.TextContentPart(fileID))
|
|
||||||
if len(parts) == 0 {
|
|
||||||
return msg
|
|
||||||
}
|
|
||||||
return openai.UserMessage(parts)
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildUserMessageWithFiles 为 user 消息追加 file content parts。
|
|
||||||
func buildUserMessageWithFiles(msg openai.ChatCompletionMessageParamUnion, fileIDs []string) openai.ChatCompletionMessageParamUnion {
|
|
||||||
text := ""
|
|
||||||
if s, ok := msg.GetContent().AsAny().(*string); ok && s != nil {
|
|
||||||
text = *s
|
|
||||||
}
|
|
||||||
|
|
||||||
parts := make([]openai.ChatCompletionContentPartUnionParam, 0, len(fileIDs)+1)
|
|
||||||
if strings.TrimSpace(text) != "" {
|
|
||||||
parts = append(parts, openai.TextContentPart(text))
|
|
||||||
}
|
|
||||||
for _, id := range fileIDs {
|
|
||||||
id = strings.TrimSpace(id)
|
|
||||||
if id == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
parts = append(parts, openai.FileContentPart(openai.ChatCompletionContentPartFileFileParam{FileID: param.NewOpt(id)}))
|
|
||||||
}
|
|
||||||
if len(parts) == 0 {
|
|
||||||
return msg
|
|
||||||
}
|
|
||||||
return openai.UserMessage(parts)
|
|
||||||
}
|
|
||||||
|
|
||||||
// toSDKTools 将内部 ToolDefinition 列表转换为 openai SDK 的 ChatCompletionToolParam 列表。
|
// toSDKTools 将内部 ToolDefinition 列表转换为 openai SDK 的 ChatCompletionToolParam 列表。
|
||||||
func toSDKTools(tools []ToolDefinition) []openai.ChatCompletionToolParam {
|
func toSDKTools(tools []ToolDefinition) []openai.ChatCompletionToolParam {
|
||||||
if len(tools) == 0 {
|
if len(tools) == 0 {
|
||||||
@@ -397,6 +289,46 @@ func fromSDKToolCalls(sdkCalls []openai.ChatCompletionMessageToolCall) []ToolCal
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizePromptMessages(messages []PromptMessage) []PromptMessage {
|
||||||
|
out := make([]PromptMessage, 0, len(messages))
|
||||||
|
for _, m := range messages {
|
||||||
|
role := normalizeRole(m.Role)
|
||||||
|
if role == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, PromptMessage{
|
||||||
|
Role: role,
|
||||||
|
Content: m.Content,
|
||||||
|
ToolCalls: m.ToolCalls,
|
||||||
|
ToolCallID: m.ToolCallID,
|
||||||
|
Name: m.Name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeRole(role string) string {
|
||||||
|
r := strings.ToLower(strings.TrimSpace(role))
|
||||||
|
if r != "system" && r != "user" && r != "assistant" && r != "tool" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func promptMessageLengths(messages []PromptMessage) (int, int) {
|
||||||
|
systemLen := 0
|
||||||
|
userLen := 0
|
||||||
|
for _, m := range messages {
|
||||||
|
switch normalizeRole(m.Role) {
|
||||||
|
case "system":
|
||||||
|
systemLen += len(m.Content)
|
||||||
|
case "user":
|
||||||
|
userLen += len(m.Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return systemLen, userLen
|
||||||
|
}
|
||||||
|
|
||||||
func (c *OpenAICompatibleClient) UploadFile(ctx context.Context, file InputFile, purpose string) (string, error) {
|
func (c *OpenAICompatibleClient) UploadFile(ctx context.Context, file InputFile, purpose string) (string, error) {
|
||||||
if strings.TrimSpace(file.FileName) == "" {
|
if strings.TrimSpace(file.FileName) == "" {
|
||||||
return "", fmt.Errorf("empty file name")
|
return "", fmt.Errorf("empty file name")
|
||||||
@@ -460,71 +392,3 @@ func appendIfMissing(items []string, value string) []string {
|
|||||||
}
|
}
|
||||||
return append(items, value)
|
return append(items, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
func nonEmptyIDs(ids []string) []string {
|
|
||||||
if len(ids) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
out := make([]string, 0, len(ids))
|
|
||||||
seen := map[string]struct{}{}
|
|
||||||
for _, id := range ids {
|
|
||||||
id = strings.TrimSpace(id)
|
|
||||||
if id == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, ok := seen[id]; ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[id] = struct{}{}
|
|
||||||
out = append(out, id)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizePromptMessages(messages []PromptMessage) []PromptMessage {
|
|
||||||
out := make([]PromptMessage, 0, len(messages))
|
|
||||||
for _, m := range messages {
|
|
||||||
role := normalizeRole(m.Role)
|
|
||||||
if role == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out = append(out, PromptMessage{
|
|
||||||
Role: role,
|
|
||||||
Content: m.Content,
|
|
||||||
ToolCalls: m.ToolCalls,
|
|
||||||
ToolCallID: m.ToolCallID,
|
|
||||||
Name: m.Name,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeRole(role string) string {
|
|
||||||
r := strings.ToLower(strings.TrimSpace(role))
|
|
||||||
if r != "system" && r != "user" && r != "assistant" && r != "tool" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
|
|
||||||
func promptMessageLengths(messages []PromptMessage) (int, int) {
|
|
||||||
systemLen := 0
|
|
||||||
userLen := 0
|
|
||||||
for _, m := range messages {
|
|
||||||
switch normalizeRole(m.Role) {
|
|
||||||
case "system":
|
|
||||||
systemLen += len(m.Content)
|
|
||||||
case "user":
|
|
||||||
userLen += len(m.Content)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return systemLen, userLen
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *OpenAICompatibleClient) normalizedFilePromptMode() string {
|
|
||||||
mode := strings.ToLower(strings.TrimSpace(c.filePromptMode))
|
|
||||||
if mode == "system_fileid" || mode == "system_fileid_url" || mode == "system_fileid_uri" {
|
|
||||||
return "system_fileid_uri"
|
|
||||||
}
|
|
||||||
return "user_content_file_parts"
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -8,8 +8,11 @@ import (
|
|||||||
"laodingbot/internal/config"
|
"laodingbot/internal/config"
|
||||||
"laodingbot/internal/logger"
|
"laodingbot/internal/logger"
|
||||||
"laodingbot/internal/tools"
|
"laodingbot/internal/tools"
|
||||||
|
"laodingbot/tools/filedoc"
|
||||||
"laodingbot/tools/fileoperation"
|
"laodingbot/tools/fileoperation"
|
||||||
"laodingbot/tools/git"
|
"laodingbot/tools/git"
|
||||||
|
"laodingbot/tools/giteaticket"
|
||||||
|
"laodingbot/tools/piplan"
|
||||||
"laodingbot/tools/shell"
|
"laodingbot/tools/shell"
|
||||||
"laodingbot/tools/websearch"
|
"laodingbot/tools/websearch"
|
||||||
)
|
)
|
||||||
@@ -20,6 +23,9 @@ func RunChild(ctx context.Context, cfg config.Config, log *logger.Logger) error
|
|||||||
var gitLog *logger.Logger
|
var gitLog *logger.Logger
|
||||||
var shellLog *logger.Logger
|
var shellLog *logger.Logger
|
||||||
var searchLog *logger.Logger
|
var searchLog *logger.Logger
|
||||||
|
var fileDocLog *logger.Logger
|
||||||
|
var piPlanLog *logger.Logger
|
||||||
|
var giteaTicketLog *logger.Logger
|
||||||
var serverLog *logger.Logger
|
var serverLog *logger.Logger
|
||||||
if log != nil {
|
if log != nil {
|
||||||
log.Infof("toolhost child starting")
|
log.Infof("toolhost child starting")
|
||||||
@@ -28,6 +34,9 @@ func RunChild(ctx context.Context, cfg config.Config, log *logger.Logger) error
|
|||||||
gitLog = log.WithComponent("toolhost.git")
|
gitLog = log.WithComponent("toolhost.git")
|
||||||
shellLog = log.WithComponent("toolhost.shell")
|
shellLog = log.WithComponent("toolhost.shell")
|
||||||
searchLog = log.WithComponent("toolhost.websearch")
|
searchLog = log.WithComponent("toolhost.websearch")
|
||||||
|
fileDocLog = log.WithComponent("toolhost.filedoc")
|
||||||
|
piPlanLog = log.WithComponent("toolhost.piplan")
|
||||||
|
giteaTicketLog = log.WithComponent("toolhost.giteaticket")
|
||||||
serverLog = log.WithComponent("toolhost.server")
|
serverLog = log.WithComponent("toolhost.server")
|
||||||
}
|
}
|
||||||
registry := tools.NewRegistry(registryLog)
|
registry := tools.NewRegistry(registryLog)
|
||||||
@@ -53,6 +62,27 @@ func RunChild(ctx context.Context, cfg config.Config, log *logger.Logger) error
|
|||||||
cfg.ToolOutputMaxChars,
|
cfg.ToolOutputMaxChars,
|
||||||
searchLog,
|
searchLog,
|
||||||
))
|
))
|
||||||
|
registry.Register(filedoc.New(
|
||||||
|
filedoc.Config{
|
||||||
|
APIKey: cfg.LLM.APIKey,
|
||||||
|
BaseURL: cfg.LLM.BaseURL,
|
||||||
|
Model: cfg.LLM.FileModel,
|
||||||
|
Timeout: time.Duration(cfg.ToolCallTimeoutSec) * time.Second,
|
||||||
|
},
|
||||||
|
cfg.ToolOutputMaxChars,
|
||||||
|
fileDocLog,
|
||||||
|
))
|
||||||
|
registry.Register(piplan.New(cfg.ToolOutputMaxChars, piPlanLog))
|
||||||
|
registry.Register(giteaticket.New(
|
||||||
|
giteaticket.Config{
|
||||||
|
BaseURL: cfg.Gitea.BaseURL,
|
||||||
|
Token: cfg.Gitea.Token,
|
||||||
|
Owner: cfg.Gitea.Owner,
|
||||||
|
Repo: cfg.Gitea.Repo,
|
||||||
|
Timeout: time.Duration(cfg.ToolCallTimeoutSec) * time.Second,
|
||||||
|
},
|
||||||
|
giteaTicketLog,
|
||||||
|
))
|
||||||
|
|
||||||
server := NewServer(registry, serverLog)
|
server := NewServer(registry, serverLog)
|
||||||
if err := server.Serve(ctx, stdin(), stdout()); err != nil && ctx.Err() == nil {
|
if err := server.Serve(ctx, stdin(), stdout()); err != nil && ctx.Err() == nil {
|
||||||
|
|||||||
@@ -21,10 +21,30 @@ type IncomingMessage struct {
|
|||||||
ChatID string
|
ChatID string
|
||||||
UserID string
|
UserID string
|
||||||
Text string
|
Text string
|
||||||
FileIDs []string
|
}
|
||||||
|
|
||||||
|
// StreamEventType 定义流式输出的事件类型
|
||||||
|
type StreamEventType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
StreamEventTypeThought StreamEventType = "thought" // LLM 思考过程
|
||||||
|
StreamEventTypeToolCall StreamEventType = "tool_call" // 工具调用请求
|
||||||
|
StreamEventTypeToolResult StreamEventType = "tool_result" // 工具执行结果
|
||||||
|
StreamEventTypeFinal StreamEventType = "final" // 最终答案
|
||||||
|
StreamEventTypeError StreamEventType = "error" // 错误信息
|
||||||
|
)
|
||||||
|
|
||||||
|
// StreamEvent 代表流式输出中的一个事件
|
||||||
|
type StreamEvent struct {
|
||||||
|
Type StreamEventType `json:"type"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Step int `json:"step,omitempty"`
|
||||||
|
ToolName string `json:"tool_name,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ChatHandler func(context.Context, IncomingMessage) (string, error)
|
type ChatHandler func(context.Context, IncomingMessage) (string, error)
|
||||||
|
type StreamChatHandler func(context.Context, IncomingMessage, StreamEventCallback) (string, error)
|
||||||
|
type StreamEventCallback func(event StreamEvent) error
|
||||||
type UploadHandler func(context.Context, string, string, []llm.InputFile) ([]string, error)
|
type UploadHandler func(context.Context, string, string, []llm.InputFile) ([]string, error)
|
||||||
|
|
||||||
type Bot struct {
|
type Bot struct {
|
||||||
@@ -33,6 +53,7 @@ type Bot struct {
|
|||||||
log *logger.Logger
|
log *logger.Logger
|
||||||
|
|
||||||
chatHandler ChatHandler
|
chatHandler ChatHandler
|
||||||
|
streamChatHandler StreamChatHandler
|
||||||
uploadHandler UploadHandler
|
uploadHandler UploadHandler
|
||||||
counter uint64
|
counter uint64
|
||||||
}
|
}
|
||||||
@@ -41,7 +62,6 @@ type chatRequest struct {
|
|||||||
Text string `json:"text"`
|
Text string `json:"text"`
|
||||||
SessionID string `json:"session_id"`
|
SessionID string `json:"session_id"`
|
||||||
UserID string `json:"user_id"`
|
UserID string `json:"user_id"`
|
||||||
FileIDs []string `json:"file_ids"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *chatRequest) UnmarshalJSON(data []byte) error {
|
func (r *chatRequest) UnmarshalJSON(data []byte) error {
|
||||||
@@ -51,10 +71,6 @@ func (r *chatRequest) UnmarshalJSON(data []byte) error {
|
|||||||
SessionIDCamel string `json:"sessionId"`
|
SessionIDCamel string `json:"sessionId"`
|
||||||
UserID string `json:"user_id"`
|
UserID string `json:"user_id"`
|
||||||
UserIDCamel string `json:"userId"`
|
UserIDCamel string `json:"userId"`
|
||||||
FileIDs json.RawMessage `json:"file_ids"`
|
|
||||||
FileIDsCamel json.RawMessage `json:"fileIds"`
|
|
||||||
FileIDsFlat json.RawMessage `json:"fileids"`
|
|
||||||
FileID json.RawMessage `json:"file_id"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var raw rawChatRequest
|
var raw rawChatRequest
|
||||||
@@ -65,13 +81,6 @@ func (r *chatRequest) UnmarshalJSON(data []byte) error {
|
|||||||
r.Text = raw.Text
|
r.Text = raw.Text
|
||||||
r.SessionID = firstNonEmpty(raw.SessionID, raw.SessionIDCamel)
|
r.SessionID = firstNonEmpty(raw.SessionID, raw.SessionIDCamel)
|
||||||
r.UserID = firstNonEmpty(raw.UserID, raw.UserIDCamel)
|
r.UserID = firstNonEmpty(raw.UserID, raw.UserIDCamel)
|
||||||
|
|
||||||
rawIDs := firstNonEmptyRaw(raw.FileIDs, raw.FileIDsCamel, raw.FileIDsFlat, raw.FileID)
|
|
||||||
ids, err := decodeStringList(rawIDs)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
r.FileIDs = ids
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,7 +118,7 @@ func NewBot(cfg config.WebUIConfig, log *logger.Logger) (*Bot, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *Bot) Run(ctx context.Context, chatHandler ChatHandler, uploadHandler UploadHandler) error {
|
func (b *Bot) Run(ctx context.Context, chatHandler ChatHandler, streamChatHandler StreamChatHandler, uploadHandler UploadHandler) error {
|
||||||
if chatHandler == nil {
|
if chatHandler == nil {
|
||||||
return fmt.Errorf("nil webui chat handler")
|
return fmt.Errorf("nil webui chat handler")
|
||||||
}
|
}
|
||||||
@@ -117,10 +126,12 @@ func (b *Bot) Run(ctx context.Context, chatHandler ChatHandler, uploadHandler Up
|
|||||||
return fmt.Errorf("nil webui upload handler")
|
return fmt.Errorf("nil webui upload handler")
|
||||||
}
|
}
|
||||||
b.chatHandler = chatHandler
|
b.chatHandler = chatHandler
|
||||||
|
b.streamChatHandler = streamChatHandler
|
||||||
b.uploadHandler = uploadHandler
|
b.uploadHandler = uploadHandler
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("/api/chat", b.handleChat)
|
mux.HandleFunc("/api/chat", b.handleChat)
|
||||||
|
mux.HandleFunc("/api/chat/stream", b.handleChatStream)
|
||||||
mux.HandleFunc("/api/upload", b.handleUpload)
|
mux.HandleFunc("/api/upload", b.handleUpload)
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
@@ -194,7 +205,6 @@ func (b *Bot) handleChat(w http.ResponseWriter, r *http.Request) {
|
|||||||
ChatID: sessionID,
|
ChatID: sessionID,
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
Text: req.Text,
|
Text: req.Text,
|
||||||
FileIDs: req.FileIDs,
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if b.log != nil {
|
if b.log != nil {
|
||||||
@@ -210,37 +220,8 @@ func (b *Bot) handleChat(w http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func decodeStringList(raw json.RawMessage) ([]string, error) {
|
|
||||||
if len(raw) == 0 {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var list []string
|
|
||||||
if err := json.Unmarshal(raw, &list); err == nil {
|
|
||||||
return nonEmptyIDs(list), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var single string
|
|
||||||
if err := json.Unmarshal(raw, &single); err == nil {
|
|
||||||
if strings.TrimSpace(single) == "" {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return nonEmptyIDs(strings.Split(single, ",")), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("invalid file ids format")
|
|
||||||
}
|
|
||||||
|
|
||||||
func firstNonEmptyRaw(vals ...json.RawMessage) json.RawMessage {
|
|
||||||
for _, v := range vals {
|
|
||||||
if len(v) > 0 {
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func firstNonEmpty(vals ...string) string {
|
func firstNonEmpty(vals ...string) string {
|
||||||
|
|
||||||
for _, v := range vals {
|
for _, v := range vals {
|
||||||
if strings.TrimSpace(v) != "" {
|
if strings.TrimSpace(v) != "" {
|
||||||
return v
|
return v
|
||||||
@@ -249,24 +230,82 @@ func firstNonEmpty(vals ...string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func nonEmptyIDs(ids []string) []string {
|
func (b *Bot) handleChatStream(w http.ResponseWriter, r *http.Request) {
|
||||||
if len(ids) == 0 {
|
if r.Method != http.MethodPost {
|
||||||
|
writeJSON(w, http.StatusMethodNotAllowed, errorResponse{Error: "method not allowed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !strings.Contains(strings.ToLower(strings.TrimSpace(r.Header.Get("Content-Type"))), "application/json") {
|
||||||
|
writeJSON(w, http.StatusBadRequest, errorResponse{Error: "content-type must be application/json"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if b.streamChatHandler == nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, errorResponse{Error: "stream chat handler not ready"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req chatRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, errorResponse{Error: "invalid json body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Text = strings.TrimSpace(req.Text)
|
||||||
|
if req.Text == "" {
|
||||||
|
writeJSON(w, http.StatusBadRequest, errorResponse{Error: "text is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sessionID := b.resolveID(req.SessionID, "sess")
|
||||||
|
userID := b.resolveID(req.UserID, "user")
|
||||||
|
|
||||||
|
// 设置 SSE 响应头
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
w.Header().Set("Connection", "keep-alive")
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
|
||||||
|
flusher, ok := w.(http.Flusher)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "streaming not supported", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建回调函数来推送 SSE 事件
|
||||||
|
callback := func(event StreamEvent) error {
|
||||||
|
data, err := json.Marshal(event)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "data: %s\n\n", string(data))
|
||||||
|
flusher.Flush()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
out := make([]string, 0, len(ids))
|
|
||||||
seen := map[string]struct{}{}
|
// 调用流式处理器
|
||||||
for _, id := range ids {
|
reply, err := b.streamChatHandler(r.Context(), IncomingMessage{
|
||||||
id = strings.TrimSpace(id)
|
ChatID: sessionID,
|
||||||
if id == "" {
|
UserID: userID,
|
||||||
continue
|
Text: req.Text,
|
||||||
|
}, callback)
|
||||||
|
if err != nil {
|
||||||
|
if b.log != nil {
|
||||||
|
b.log.Errorf("webui stream chat handler failed session_id=%s user_id=%s err=%v", sessionID, userID, err)
|
||||||
}
|
}
|
||||||
if _, ok := seen[id]; ok {
|
// 推送错误事件
|
||||||
continue
|
errEvent := StreamEvent{
|
||||||
|
Type: StreamEventTypeError,
|
||||||
|
Content: "stream error: " + err.Error(),
|
||||||
}
|
}
|
||||||
seen[id] = struct{}{}
|
data, _ := json.Marshal(errEvent)
|
||||||
out = append(out, id)
|
fmt.Fprintf(w, "data: %s\n\n", string(data))
|
||||||
|
flusher.Flush()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if b.log != nil {
|
||||||
|
b.log.Infof("webui stream chat completed session_id=%s user_id=%s reply_len=%d", sessionID, userID, len(reply))
|
||||||
}
|
}
|
||||||
return out
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *Bot) handleUpload(w http.ResponseWriter, r *http.Request) {
|
func (b *Bot) handleUpload(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -51,66 +52,6 @@ func TestHandleChatSuccess(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHandleChatWithFileIDs(t *testing.T) {
|
|
||||||
b := newTestBot(t, 1024*1024)
|
|
||||||
b.chatHandler = func(_ context.Context, msg IncomingMessage) (string, error) {
|
|
||||||
if msg.ChatID != "s1" || msg.UserID != "u1" || msg.Text != "hello" {
|
|
||||||
t.Fatalf("unexpected message: %+v", msg)
|
|
||||||
}
|
|
||||||
if len(msg.FileIDs) != 2 || msg.FileIDs[0] != "file_a" || msg.FileIDs[1] != "file_b" {
|
|
||||||
t.Fatalf("unexpected file ids: %+v", msg.FileIDs)
|
|
||||||
}
|
|
||||||
return "ok", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
body := strings.NewReader(`{"text":"hello","session_id":"s1","user_id":"u1","file_ids":["file_a","file_b"]}`)
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/chat", body)
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
|
|
||||||
b.handleChat(w, req)
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHandleChatWithFileIDsAliases(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
body string
|
|
||||||
}{
|
|
||||||
{name: "camel array", body: `{"text":"hello","sessionId":"s1","userId":"u1","fileIds":["file_a","file_b"]}`},
|
|
||||||
{name: "flat array", body: `{"text":"hello","session_id":"s1","user_id":"u1","fileids":["file_a","file_b"]}`},
|
|
||||||
{name: "single key", body: `{"text":"hello","session_id":"s1","user_id":"u1","file_id":"file_a"}`},
|
|
||||||
{name: "csv string", body: `{"text":"hello","session_id":"s1","user_id":"u1","file_ids":"file_a, file_b"}`},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
b := newTestBot(t, 1024*1024)
|
|
||||||
b.chatHandler = func(_ context.Context, msg IncomingMessage) (string, error) {
|
|
||||||
if msg.ChatID != "s1" || msg.UserID != "u1" || msg.Text != "hello" {
|
|
||||||
t.Fatalf("unexpected message: %+v", msg)
|
|
||||||
}
|
|
||||||
if len(msg.FileIDs) == 0 {
|
|
||||||
t.Fatalf("expected file ids from alias payload, got empty")
|
|
||||||
}
|
|
||||||
return "ok", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
body := strings.NewReader(tt.body)
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/chat", body)
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
|
|
||||||
b.handleChat(w, req)
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHandleChatMissingText(t *testing.T) {
|
func TestHandleChatMissingText(t *testing.T) {
|
||||||
b := newTestBot(t, 1024*1024)
|
b := newTestBot(t, 1024*1024)
|
||||||
b.chatHandler = func(_ context.Context, _ IncomingMessage) (string, error) { return "", nil }
|
b.chatHandler = func(_ context.Context, _ IncomingMessage) (string, error) { return "", nil }
|
||||||
@@ -215,3 +156,149 @@ func TestHandleUploadMissingFile(t *testing.T) {
|
|||||||
t.Fatalf("expected 400, got %d", w.Code)
|
t.Fatalf("expected 400, got %d", w.Code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHandleChatStreamSuccess(t *testing.T) {
|
||||||
|
b := newTestBot(t, 1024*1024)
|
||||||
|
b.streamChatHandler = func(_ context.Context, msg IncomingMessage, cb StreamEventCallback) (string, error) {
|
||||||
|
if msg.ChatID != "s1" || msg.UserID != "u1" || msg.Text != "hello" {
|
||||||
|
t.Fatalf("unexpected message: %+v", msg)
|
||||||
|
}
|
||||||
|
if err := cb(StreamEvent{Type: StreamEventTypeThought, Content: "thinking", Step: 1}); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := cb(StreamEvent{Type: StreamEventTypeToolCall, Content: "{\"input\":\"pwd\"}", Step: 1, ToolName: "shell"}); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := cb(StreamEvent{Type: StreamEventTypeToolResult, Content: "C:/Project", Step: 1, ToolName: "shell"}); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := cb(StreamEvent{Type: StreamEventTypeFinal, Content: "done", Step: 2}); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return "done", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
body := strings.NewReader(`{"text":"hello","session_id":"s1","user_id":"u1"}`)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/chat/stream", body)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
b.handleChatStream(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if got := w.Header().Get("Content-Type"); got != "text/event-stream" {
|
||||||
|
t.Fatalf("expected text/event-stream, got %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
var events []StreamEvent
|
||||||
|
chunks := strings.Split(strings.TrimSpace(w.Body.String()), "\n\n")
|
||||||
|
for _, chunk := range chunks {
|
||||||
|
line := strings.TrimSpace(chunk)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(line, "data: ") {
|
||||||
|
t.Fatalf("invalid sse line: %q", line)
|
||||||
|
}
|
||||||
|
payload := strings.TrimPrefix(line, "data: ")
|
||||||
|
var ev StreamEvent
|
||||||
|
if err := json.Unmarshal([]byte(payload), &ev); err != nil {
|
||||||
|
t.Fatalf("unmarshal stream event failed: %v payload=%s", err, payload)
|
||||||
|
}
|
||||||
|
events = append(events, ev)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(events) != 4 {
|
||||||
|
t.Fatalf("expected 4 events, got %d", len(events))
|
||||||
|
}
|
||||||
|
if events[0].Type != StreamEventTypeThought {
|
||||||
|
t.Fatalf("event[0] type mismatch: %s", events[0].Type)
|
||||||
|
}
|
||||||
|
if events[1].Type != StreamEventTypeToolCall || events[1].ToolName != "shell" {
|
||||||
|
t.Fatalf("event[1] mismatch: %+v", events[1])
|
||||||
|
}
|
||||||
|
if events[2].Type != StreamEventTypeToolResult || events[2].ToolName != "shell" {
|
||||||
|
t.Fatalf("event[2] mismatch: %+v", events[2])
|
||||||
|
}
|
||||||
|
if events[3].Type != StreamEventTypeFinal || events[3].Content != "done" {
|
||||||
|
t.Fatalf("event[3] mismatch: %+v", events[3])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleChatStreamHandlerError(t *testing.T) {
|
||||||
|
b := newTestBot(t, 1024*1024)
|
||||||
|
b.streamChatHandler = func(_ context.Context, _ IncomingMessage, _ StreamEventCallback) (string, error) {
|
||||||
|
return "", errors.New("boom")
|
||||||
|
}
|
||||||
|
|
||||||
|
body := strings.NewReader(`{"text":"hello"}`)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/chat/stream", body)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
b.handleChatStream(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
respBody := w.Body.String()
|
||||||
|
if !strings.Contains(respBody, `"type":"error"`) {
|
||||||
|
t.Fatalf("expected error event in stream, body=%q", respBody)
|
||||||
|
}
|
||||||
|
if !strings.Contains(respBody, "stream error: boom") {
|
||||||
|
t.Fatalf("expected error detail in stream, body=%q", respBody)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleChatStreamValidation(t *testing.T) {
|
||||||
|
t.Run("method not allowed", func(t *testing.T) {
|
||||||
|
b := newTestBot(t, 1024*1024)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/chat/stream", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
b.handleChatStream(w, req)
|
||||||
|
if w.Code != http.StatusMethodNotAllowed {
|
||||||
|
t.Fatalf("expected 405, got %d", w.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("content type must be json", func(t *testing.T) {
|
||||||
|
b := newTestBot(t, 1024*1024)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/chat/stream", strings.NewReader(`{"text":"hello"}`))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
b.handleChatStream(w, req)
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected 400, got %d", w.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("handler not ready", func(t *testing.T) {
|
||||||
|
b := newTestBot(t, 1024*1024)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/chat/stream", strings.NewReader(`{"text":"hello"}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
b.handleChatStream(w, req)
|
||||||
|
if w.Code != http.StatusInternalServerError {
|
||||||
|
t.Fatalf("expected 500, got %d", w.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("text required", func(t *testing.T) {
|
||||||
|
b := newTestBot(t, 1024*1024)
|
||||||
|
b.streamChatHandler = func(_ context.Context, _ IncomingMessage, _ StreamEventCallback) (string, error) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/chat/stream", strings.NewReader(`{"text":" "}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
b.handleChatStream(w, req)
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected 400, got %d", w.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
247
skills/safe_pi_planning/skill.md
Normal file
247
skills/safe_pi_planning/skill.md
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
---
|
||||||
|
name: SAFe 铁三角 PI 规划
|
||||||
|
description: 扮演 SAFe 铁三角(PM、架构师、RTE),将宏观 Epic 拆解为 PI 规划,输出标准化架构蓝图,并在 Gitea 创建可执行工单。支持从 PDF 等文档提取需求。
|
||||||
|
---
|
||||||
|
|
||||||
|
# Skill: SAFe 铁三角 PI 规划
|
||||||
|
|
||||||
|
## 1. 触发条件
|
||||||
|
|
||||||
|
当用户的意图匹配以下任意一种时,**必须启用本技能**:
|
||||||
|
|
||||||
|
- 提交了一份**宏观业务需求**(Epic),要求进行 PI 级别拆解
|
||||||
|
- 要求进行**下一个 PI (Program Increment) 的规划**
|
||||||
|
- 提到 SAFe、PI Planning、铁三角、Feature 拆解、架构跑道等关键词
|
||||||
|
- 上传了**需求文档**(PDF、Word、Markdown)并要求分析和拆解
|
||||||
|
- 要求将规划结果**同步到 Gitea / 创建工单**
|
||||||
|
|
||||||
|
**不适用场景**:单纯的代码编写、Bug 修复、文件查询等操作性任务。
|
||||||
|
|
||||||
|
## 2. 可用工具
|
||||||
|
|
||||||
|
| 工具名 | 用途 | 阶段 |
|
||||||
|
|--------|------|------|
|
||||||
|
| `extract_file_document` | 提取用户上传的 PDF/文档内容 | 输入准备 |
|
||||||
|
| `publish_pi_plan` | 将推演结果渲染为标准化架构蓝图 | 规划输出 |
|
||||||
|
| `create_gitea_ticket` | 在 Gitea 中创建 User Story 工单 | 任务下发 |
|
||||||
|
| `web_search` | 搜索技术方案、行业标准等参考信息 | 辅助决策 |
|
||||||
|
|
||||||
|
## 3. 执行流程
|
||||||
|
|
||||||
|
执行本技能时,严格遵循以下分阶段流程。每个阶段对应 ReAct 循环中的一个或多个 Thought → Action → Observation 步骤。
|
||||||
|
|
||||||
|
### 阶段 0:输入准备 — 文档提取(如有附件)
|
||||||
|
|
||||||
|
如果用户提交了文件(file_id),**必须先提取文档内容**,再进入正式推演。
|
||||||
|
|
||||||
|
**Action**: 调用 `extract_file_document`
|
||||||
|
```
|
||||||
|
输入: 用户提供的 file_id
|
||||||
|
```
|
||||||
|
**Observation**: 获取文档全文结构化摘要,包含标题、核心观点、关键数据。
|
||||||
|
|
||||||
|
将提取到的文档内容作为后续推演的输入素材。如果用户同时提交了多个文件,逐个提取后合并为统一的需求上下文。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 阶段 1:铁三角推演(核心 Thought 过程)
|
||||||
|
|
||||||
|
**这是本技能的核心。** 你必须在 Thought 中同时扮演三个角色,按以下顺序进行严格的自我推演和博弈。不可跳过任何视角。
|
||||||
|
|
||||||
|
#### 视角 1: 产品管理 (PM) — 决定"做什么"和"为什么做"
|
||||||
|
|
||||||
|
在 Thought 中以 `[PM]` 标记此视角的思考:
|
||||||
|
|
||||||
|
1. **需求拆解**: 将宏观 Epic 拆解为 2-4 个可在 8-12 周内交付的业务特性 (Feature)
|
||||||
|
2. **价值假设**: 为每个 Feature 写出清晰的 Benefit Hypothesis — 如果做了这个功能,会带来什么可量化的业务收益
|
||||||
|
3. **验收标准**: 从业务视角列出每个 Feature 的验收条件 (Acceptance Criteria),不涉及技术实现细节
|
||||||
|
4. **优先级排序**: 根据业务价值和紧迫性,给出 Feature 的建议交付顺序
|
||||||
|
|
||||||
|
**PM 视角的自检问题**:
|
||||||
|
- 每个 Feature 是否独立可交付,还是必须和其他 Feature 一起才有意义?
|
||||||
|
- 验收标准是否可以被测试团队直接转化为测试用例?
|
||||||
|
- 是否遗漏了用户(终端使用者)会关心的场景?
|
||||||
|
|
||||||
|
#### 视角 2: 系统架构师 (SA) — 决定"技术怎么接"和"底座跑道"
|
||||||
|
|
||||||
|
在 Thought 中以 `[SA]` 标记此视角的思考:
|
||||||
|
|
||||||
|
1. **架构跑道 (Architectural Runway)**: PM 提出的每个 Feature,当前系统能直接支撑吗?如果不能,需要提前铺设哪些底层基础设施?将这些转化为 Enabler
|
||||||
|
2. **瓶颈识别**: 当前系统架构的薄弱环节在哪?哪些 Enabler 是阻塞性的(不做就无法开始 Feature 开发)?
|
||||||
|
3. **NFRs 定义**: 强制规定非功能性需求:
|
||||||
|
- 性能指标(QPS、P99 延迟、吞吐量)
|
||||||
|
- 安全标准(加密协议、认证机制、数据脱敏)
|
||||||
|
4. **接口契约**: 如果涉及多个子系统交互,定义关键 API 契约
|
||||||
|
|
||||||
|
**SA 视角的自检问题**:
|
||||||
|
- 每个 Enabler 是否有明确的"完成标志"(而不是开放性的研究任务)?
|
||||||
|
- NFRs 的指标是否可量化、可自动化测试?
|
||||||
|
- 是否过度设计?Enabler 数量是否与 Feature 复杂度匹配?
|
||||||
|
|
||||||
|
#### 视角 3: 发布火车工程师 (RTE) — 决定"流程风险"与"依赖管理"
|
||||||
|
|
||||||
|
在 Thought 中以 `[RTE]` 标记此视角的思考:
|
||||||
|
|
||||||
|
1. **依赖分析**: 检查 PM 的 Feature 和 SA 的 Enabler 之间的时序依赖 — 哪些 Enabler 必须先完成才能开始哪些 Feature?
|
||||||
|
2. **风险评估**: 识别潜在的交付风险:
|
||||||
|
- 技术风险(新技术栈、外部 API 未就绪)
|
||||||
|
- 资源风险(关键人员依赖、跨团队协调)
|
||||||
|
- 集成风险(多个 Feature 的集成点)
|
||||||
|
3. **里程碑建议**: 基于依赖关系,给出关键里程碑的建议时间节点
|
||||||
|
|
||||||
|
**RTE 视角的自检问题**:
|
||||||
|
- 是否存在循环依赖?
|
||||||
|
- 关键路径上的任务是否有 buffer?
|
||||||
|
- 如果某个 Enabler 延期,哪些 Feature 会受影响?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 阶段 2:生成标准化 PI 规划
|
||||||
|
|
||||||
|
完成三个视角的推演后,**必须立即调用** `publish_pi_plan` 工具。
|
||||||
|
|
||||||
|
**Action**: 调用 `publish_pi_plan`,传入推演结果的 JSON:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"pi_vision": "本 PI 的核心业务愿景(一两句话概括)",
|
||||||
|
"features": [
|
||||||
|
{
|
||||||
|
"feature_id": "FEAT_XXX_001",
|
||||||
|
"title": "动宾结构的简洁标题",
|
||||||
|
"benefit_hypothesis": "如果实现此功能,将带来 XXX 业务收益",
|
||||||
|
"acceptance_criteria": ["AC1: ...", "AC2: ..."]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"enablers": [
|
||||||
|
{
|
||||||
|
"enabler_id": "ENAB_XXX_001",
|
||||||
|
"title": "技术任务名称",
|
||||||
|
"architectural_purpose": "为什么需要这个底层改造"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"nfrs": {
|
||||||
|
"performance": "具体的性能指标约束",
|
||||||
|
"security": "具体的安全与合规约束"
|
||||||
|
},
|
||||||
|
"dependencies": [
|
||||||
|
{
|
||||||
|
"source_id": "ENAB_XXX_001",
|
||||||
|
"target_id": "FEAT_XXX_001",
|
||||||
|
"reason": "依赖原因的一句话描述"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**命名规范**:
|
||||||
|
- Feature ID: `FEAT_<领域缩写>_<序号>`,如 `FEAT_OTA_001`
|
||||||
|
- Enabler ID: `ENAB_<技术栈缩写>_<序号>`,如 `ENAB_KAFKA_001`
|
||||||
|
|
||||||
|
**Observation**: 获取渲染后的 Markdown 架构蓝图,包含愿景、特性清单、Enabler 表、NFRs、依赖关系、执行顺序和质量门禁检查清单。
|
||||||
|
|
||||||
|
将此蓝图**完整展示给用户**,征求反馈。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 阶段 3:任务下发到 Gitea(用户确认后)
|
||||||
|
|
||||||
|
当用户确认规划方案后,将 Feature 和 Enabler **逐一拆解为 User Story**,通过 `create_gitea_ticket` 在 Gitea 创建工单。
|
||||||
|
|
||||||
|
#### 拆解原则
|
||||||
|
|
||||||
|
- 每个 Feature 拆解为 1-3 个 User Story(每个 Story 应在 1-3 天内可完成)
|
||||||
|
- 每个 Enabler 拆解为 1-2 个技术任务 Story
|
||||||
|
- Story 的标题采用**动宾结构**(如"实现固件版本解析 API","部署 Kafka 基础镜像")
|
||||||
|
|
||||||
|
#### 创建工单
|
||||||
|
|
||||||
|
对每个 Story,**Action**: 调用 `create_gitea_ticket`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "动宾结构的任务标题",
|
||||||
|
"body": "## 溯源\n- Parent: FEAT_XXX_001\n\n## 任务上下文\n<描述做什么、为什么>\n\n## 验收标准\n- [ ] AC-1: ...\n- [ ] AC-2: ...\n\n## NFRs\n- 性能: ...\n- 安全: ...\n\n## 技术实现思路\n<基于架构师视角补充>",
|
||||||
|
"labels": ["type/story", "domain/<领域>", "priority/<high|medium|low>", "status/todo"],
|
||||||
|
"parent_reference_id": "FEAT_XXX_001",
|
||||||
|
"estimated_hours": 8
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**标签约定**:
|
||||||
|
- `type/story` | `type/enabler` | `type/spike` — 任务类型
|
||||||
|
- `domain/<领域>` — 业务领域(如 `domain/cloud`、`domain/vehicle`、`domain/infra`)
|
||||||
|
- `priority/high` | `priority/medium` | `priority/low` — 优先级
|
||||||
|
- `status/todo` — 初始状态
|
||||||
|
|
||||||
|
**工时估算参考**:
|
||||||
|
- 简单 CRUD / 配置变更: 4-8 小时
|
||||||
|
- 标准 API 开发 + 单元测试: 8-16 小时
|
||||||
|
- 基础设施搭建 / 中间件部署: 16-24 小时
|
||||||
|
- 跨系统集成 + 联调: 16-32 小时
|
||||||
|
|
||||||
|
创建完成后,汇总所有工单链接,输出执行看板。
|
||||||
|
|
||||||
|
## 4. Thought 内部推演示范
|
||||||
|
|
||||||
|
以下示范 Thought 过程的结构(非固定内容,仅展示格式):
|
||||||
|
|
||||||
|
```
|
||||||
|
Thought:
|
||||||
|
用户提交了一个 Epic:"实现面向欧洲市场的车云 OTA 升级平台"。
|
||||||
|
我需要以铁三角三个视角依次推演。
|
||||||
|
|
||||||
|
[PM] 产品管理视角:
|
||||||
|
- 核心交付价值:让欧洲车主能安全、快速地接收 OTA 升级
|
||||||
|
- Feature 拆解:
|
||||||
|
1. FEAT_OTA_001: 云端固件版本依赖检查 — 防止下载不兼容固件
|
||||||
|
2. FEAT_OTA_002: 端侧断点续传 — 弱网环境下提升下载成功率
|
||||||
|
3. FEAT_OTA_003: 升级状态实时推送 — 车主可在 App 中看到升级进度
|
||||||
|
- 各 Feature 的 AC 已确定,均可测试化
|
||||||
|
|
||||||
|
[SA] 系统架构师视角:
|
||||||
|
- FEAT_OTA_001 需要异步版本校验,当前没有合适的消息中间件 → ENAB_KAFKA_001
|
||||||
|
- FEAT_OTA_002 需要支持 Range 请求的对象存储 → ENAB_S3_001
|
||||||
|
- NFRs:API P99 < 200ms, TLS 1.3, VIN 脱敏
|
||||||
|
- 当前架构可支撑 FEAT_OTA_003,无需新增 Enabler
|
||||||
|
|
||||||
|
[RTE] 发布火车工程师视角:
|
||||||
|
- ENAB_KAFKA_001 → FEAT_OTA_001(阻塞依赖)
|
||||||
|
- ENAB_S3_001 → FEAT_OTA_002(阻塞依赖)
|
||||||
|
- FEAT_OTA_003 无依赖,可并行开发
|
||||||
|
- 风险:Kafka 集群搭建预计 2 周,是关键路径
|
||||||
|
|
||||||
|
推演完成,下一步调用 publish_pi_plan 输出标准化蓝图。
|
||||||
|
|
||||||
|
Action: publish_pi_plan
|
||||||
|
Action Input: {"pi_vision": "...", "features": [...], ...}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. 输出规范
|
||||||
|
|
||||||
|
最终交付给用户的内容必须包含:
|
||||||
|
|
||||||
|
1. **PI 蓝图**:`publish_pi_plan` 生成的完整 Markdown 报告
|
||||||
|
2. **Gitea 工单汇总**(如已执行阶段 3):
|
||||||
|
- 工单编号与链接列表
|
||||||
|
- 按执行顺序排列
|
||||||
|
- 标注关键路径上的任务
|
||||||
|
3. **风险与建议**:RTE 视角识别的关键风险及缓解措施
|
||||||
|
|
||||||
|
## 6. 质量约束
|
||||||
|
|
||||||
|
- **三视角不可跳过**: 即使需求看起来简单,也必须走完 PM → SA → RTE 三步推演
|
||||||
|
- **ID 必须唯一**: Feature ID 和 Enabler ID 在同一个 PI 内不可重复
|
||||||
|
- **AC 必须可测试**: 每条验收标准都应能被转化为自动化测试用例
|
||||||
|
- **NFRs 必须可量化**: 不接受"性能要好"这样的模糊描述,必须有具体数字
|
||||||
|
- **依赖必须有理由**: 每条依赖关系都要说明为什么 A 必须先于 B
|
||||||
|
|
||||||
|
## 7. 失败回退策略
|
||||||
|
|
||||||
|
| 失败场景 | 处理方式 |
|
||||||
|
|----------|----------|
|
||||||
|
| 文档提取失败(file_id 无效) | 提示用户重新上传或提供文本版需求描述 |
|
||||||
|
| 需求信息不足以拆解 Feature | 列出缺失信息清单,请求用户补充后重新推演 |
|
||||||
|
| Gitea 配置缺失(token/repo 为空) | 仅输出 PI 蓝图,跳过工单创建,提示用户配置 Gitea 环境变量 |
|
||||||
|
| Gitea API 调用失败 | 输出 PI 蓝图,附上拟创建工单的 JSON 列表供用户手动创建 |
|
||||||
|
| 用户对规划有异议 | 记录反馈,调整对应视角的推演,重新生成蓝图 |
|
||||||
208
tools/filedoc/filedoc.go
Normal file
208
tools/filedoc/filedoc.go
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
package filedoc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"laodingbot/internal/logger"
|
||||||
|
|
||||||
|
openai "github.com/openai/openai-go"
|
||||||
|
"github.com/openai/openai-go/option"
|
||||||
|
"github.com/openai/openai-go/shared"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
APIKey string
|
||||||
|
BaseURL string
|
||||||
|
Model string
|
||||||
|
Timeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
type Tool struct {
|
||||||
|
client openai.Client
|
||||||
|
model string
|
||||||
|
maxOutputChars int
|
||||||
|
log *logger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(cfg Config, maxOutputChars int, log *logger.Logger) *Tool {
|
||||||
|
if strings.TrimSpace(cfg.Model) == "" {
|
||||||
|
cfg.Model = "gpt-4o-mini"
|
||||||
|
}
|
||||||
|
if cfg.Timeout <= 0 {
|
||||||
|
cfg.Timeout = 60 * time.Second
|
||||||
|
}
|
||||||
|
if maxOutputChars <= 0 {
|
||||||
|
maxOutputChars = 12000
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := []option.RequestOption{
|
||||||
|
option.WithAPIKey(strings.TrimSpace(cfg.APIKey)),
|
||||||
|
option.WithRequestTimeout(cfg.Timeout),
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cfg.BaseURL) != "" {
|
||||||
|
opts = append(opts, option.WithBaseURL(strings.TrimSpace(cfg.BaseURL)))
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Tool{
|
||||||
|
client: openai.NewClient(opts...),
|
||||||
|
model: cfg.Model,
|
||||||
|
maxOutputChars: maxOutputChars,
|
||||||
|
log: log,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Tool) Name() string { return "extract_file_document" }
|
||||||
|
|
||||||
|
func (t *Tool) Description() string {
|
||||||
|
return "Extract full document details from a file ID via OpenAI. Input: file_id (supports plain ID, fileid://ID, or JSON {\"file_id\":\"...\"})."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Tool) Call(ctx context.Context, input string) (string, error) {
|
||||||
|
fileID, userFocus, err := parseInput(input)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt := buildExtractionPrompt(fileID, userFocus)
|
||||||
|
messages := []openai.ChatCompletionMessageParamUnion{
|
||||||
|
openai.SystemMessage("fileid://" + fileID),
|
||||||
|
openai.UserMessage([]openai.ChatCompletionContentPartUnionParam{
|
||||||
|
openai.TextContentPart(prompt),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
params := openai.ChatCompletionNewParams{
|
||||||
|
Model: shared.ChatModel(t.model),
|
||||||
|
Messages: messages,
|
||||||
|
}
|
||||||
|
|
||||||
|
if t.log != nil {
|
||||||
|
t.log.Infof("filedoc tool request model=%s file_id=%s", t.model, fileID)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := t.client.Chat.Completions.New(ctx, params)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("filedoc request failed: %w", err)
|
||||||
|
}
|
||||||
|
if len(resp.Choices) == 0 {
|
||||||
|
return "", fmt.Errorf("filedoc returned empty choices")
|
||||||
|
}
|
||||||
|
|
||||||
|
out := strings.TrimSpace(resp.Choices[0].Message.Content)
|
||||||
|
if out == "" {
|
||||||
|
out = "未提取到可读的文档内容。请确认 file_id 是否有效以及模型是否支持文件解析。"
|
||||||
|
}
|
||||||
|
if len(out) > t.maxOutputChars {
|
||||||
|
out = out[:t.maxOutputChars]
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildExtractionPrompt(fileID, userFocus string) string {
|
||||||
|
focus := strings.TrimSpace(userFocus)
|
||||||
|
if focus == "" {
|
||||||
|
focus = "请输出完整文档信息,包括标题、主题、核心观点、结构大纲、关键术语、重要结论、风险点与后续建议。"
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join([]string{
|
||||||
|
"请基于所附文件输出完整文档信息。",
|
||||||
|
"file_id: " + fileID,
|
||||||
|
"",
|
||||||
|
"输出要求:",
|
||||||
|
"1) 文档基本信息:标题、文档类型、语言、可能作者/组织(若可判断)、时间线索(若可判断)。",
|
||||||
|
"2) 结构化摘要:按章节或逻辑段落给出要点,尽量保持原文顺序。",
|
||||||
|
"3) 关键数据与事实:列出关键数字、术语、专有名词、约束条件。",
|
||||||
|
"4) 风险与不确定性:明确哪些信息来源于文档,哪些是无法确认。",
|
||||||
|
"5) 面向执行的建议:给出可落地的后续行动项。",
|
||||||
|
"",
|
||||||
|
"补充关注点:",
|
||||||
|
focus,
|
||||||
|
}, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseInput(input string) (fileID string, userFocus string, err error) {
|
||||||
|
raw := strings.TrimSpace(input)
|
||||||
|
if raw == "" {
|
||||||
|
return "", "", fmt.Errorf("empty input: expected file_id")
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(raw, "{") {
|
||||||
|
var payload map[string]any
|
||||||
|
if jsonErr := json.Unmarshal([]byte(raw), &payload); jsonErr == nil {
|
||||||
|
if id := firstNonEmptyString(payload, "file_id", "fileid", "id", "fileID"); id != "" {
|
||||||
|
return normalizeFileID(id), firstNonEmptyString(payload, "focus", "query", "instruction", "prompt"), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := strings.Split(raw, "\n")
|
||||||
|
for _, line := range lines {
|
||||||
|
candidate := extractFileIDToken(line)
|
||||||
|
if candidate != "" {
|
||||||
|
focus := strings.TrimSpace(strings.ReplaceAll(raw, line, ""))
|
||||||
|
return normalizeFileID(candidate), focus, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
candidate := extractFileIDToken(raw)
|
||||||
|
if candidate == "" {
|
||||||
|
return "", "", fmt.Errorf("no file_id found in input")
|
||||||
|
}
|
||||||
|
return normalizeFileID(candidate), "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractFileIDToken(s string) string {
|
||||||
|
fields := strings.FieldsFunc(s, func(r rune) bool {
|
||||||
|
switch r {
|
||||||
|
case ' ', '\t', '\n', '\r', ',', ';', '|':
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, f := range fields {
|
||||||
|
tok := strings.TrimSpace(strings.Trim(f, "\"'()[]{}"))
|
||||||
|
if tok == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lower := strings.ToLower(tok)
|
||||||
|
if strings.HasPrefix(lower, "fileid://") {
|
||||||
|
return tok[len("fileid://"):]
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(lower, "file_id=") || strings.HasPrefix(lower, "fileid=") {
|
||||||
|
idx := strings.Index(tok, "=")
|
||||||
|
if idx >= 0 && idx+1 < len(tok) {
|
||||||
|
return tok[idx+1:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(lower, "file_") || strings.HasPrefix(lower, "file-") {
|
||||||
|
return tok
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeFileID(id string) string {
|
||||||
|
id = strings.TrimSpace(strings.Trim(id, "\"'"))
|
||||||
|
if strings.HasPrefix(strings.ToLower(id), "fileid://") {
|
||||||
|
return strings.TrimSpace(id[len("fileid://"):])
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmptyString(m map[string]any, keys ...string) string {
|
||||||
|
for _, k := range keys {
|
||||||
|
if v, ok := m[k]; ok {
|
||||||
|
if s, ok := v.(string); ok && strings.TrimSpace(s) != "" {
|
||||||
|
return strings.TrimSpace(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
74
tools/filedoc/filedoc_test.go
Normal file
74
tools/filedoc/filedoc_test.go
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
package filedoc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNameAndDescription(t *testing.T) {
|
||||||
|
tool := New(Config{APIKey: "k", Model: "gpt-4o-mini"}, 5000, nil)
|
||||||
|
if tool.Name() != "extract_file_document" {
|
||||||
|
t.Fatalf("unexpected tool name: %s", tool.Name())
|
||||||
|
}
|
||||||
|
if tool.Description() == "" {
|
||||||
|
t.Fatal("description should not be empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseInputPlainFileID(t *testing.T) {
|
||||||
|
id, focus, err := parseInput("file_ec_452e96aad38940229058f193f5c5b9c6_12553222")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseInput returned error: %v", err)
|
||||||
|
}
|
||||||
|
if id != "file_ec_452e96aad38940229058f193f5c5b9c6_12553222" {
|
||||||
|
t.Fatalf("unexpected id: %s", id)
|
||||||
|
}
|
||||||
|
if focus != "" {
|
||||||
|
t.Fatalf("expected empty focus, got: %q", focus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseInputFileIDSchemeWithFocus(t *testing.T) {
|
||||||
|
input := "fileid://file_ec_12345\n重点关注风险与建议"
|
||||||
|
id, focus, err := parseInput(input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseInput returned error: %v", err)
|
||||||
|
}
|
||||||
|
if id != "file_ec_12345" {
|
||||||
|
t.Fatalf("unexpected id: %s", id)
|
||||||
|
}
|
||||||
|
if focus == "" {
|
||||||
|
t.Fatal("expected non-empty focus")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseInputJSON(t *testing.T) {
|
||||||
|
input := `{"file_id":"file_ec_888", "focus":"提取关键结论"}`
|
||||||
|
id, focus, err := parseInput(input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseInput returned error: %v", err)
|
||||||
|
}
|
||||||
|
if id != "file_ec_888" {
|
||||||
|
t.Fatalf("unexpected id: %s", id)
|
||||||
|
}
|
||||||
|
if focus != "提取关键结论" {
|
||||||
|
t.Fatalf("unexpected focus: %s", focus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseInputInvalid(t *testing.T) {
|
||||||
|
_, _, err := parseInput("hello world")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected parse error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildExtractionPrompt(t *testing.T) {
|
||||||
|
p := buildExtractionPrompt("file_ec_abc", "关注测试条目")
|
||||||
|
if p == "" {
|
||||||
|
t.Fatal("prompt should not be empty")
|
||||||
|
}
|
||||||
|
if p != "" && !(strings.Contains(p, "file_ec_abc") && strings.Contains(p, "关注测试条目")) {
|
||||||
|
t.Fatalf("unexpected prompt content: %s", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
328
tools/giteaticket/giteaticket.go
Normal file
328
tools/giteaticket/giteaticket.go
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
package giteaticket
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"laodingbot/internal/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config Gitea 工单工具的配置。
|
||||||
|
type Config struct {
|
||||||
|
BaseURL string // Gitea 实例地址,例如 https://gitea.example.com
|
||||||
|
Token string // Gitea Personal Access Token
|
||||||
|
Owner string // 仓库所有者(用户名或组织名)
|
||||||
|
Repo string // 仓库名称
|
||||||
|
Timeout time.Duration // HTTP 请求超时
|
||||||
|
}
|
||||||
|
|
||||||
|
// TicketInput 对应 tool schema 定义的输入参数。
|
||||||
|
type TicketInput struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
Labels []string `json:"labels"`
|
||||||
|
ParentReferenceID string `json:"parent_reference_id"`
|
||||||
|
EstimatedHours int `json:"estimated_hours,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// giteaCreateIssueReq Gitea Create Issue API 请求体。
|
||||||
|
type giteaCreateIssueReq struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
Labels []int64 `json:"labels,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// giteaIssueResp Gitea Issue API 响应(仅用到的字段)。
|
||||||
|
type giteaIssueResp struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Number int64 `json:"number"`
|
||||||
|
HTMLURL string `json:"html_url"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
State string `json:"state"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// giteaLabelResp Gitea Label API 响应。
|
||||||
|
type giteaLabelResp struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tool 实现 create_gitea_ticket 工具。
|
||||||
|
type Tool struct {
|
||||||
|
baseURL string
|
||||||
|
token string
|
||||||
|
owner string
|
||||||
|
repo string
|
||||||
|
httpClient *http.Client
|
||||||
|
log *logger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// New 创建一个新的 create_gitea_ticket 工具实例。
|
||||||
|
func New(cfg Config, log *logger.Logger) *Tool {
|
||||||
|
baseURL := strings.TrimRight(strings.TrimSpace(cfg.BaseURL), "/")
|
||||||
|
if cfg.Timeout <= 0 {
|
||||||
|
cfg.Timeout = 30 * time.Second
|
||||||
|
}
|
||||||
|
return &Tool{
|
||||||
|
baseURL: baseURL,
|
||||||
|
token: strings.TrimSpace(cfg.Token),
|
||||||
|
owner: strings.TrimSpace(cfg.Owner),
|
||||||
|
repo: strings.TrimSpace(cfg.Repo),
|
||||||
|
httpClient: &http.Client{Timeout: cfg.Timeout},
|
||||||
|
log: log,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Tool) Name() string { return "create_gitea_ticket" }
|
||||||
|
|
||||||
|
func (t *Tool) Description() string {
|
||||||
|
return `DevOps Agent 使用此工具,将 PI Plan 中的 Feature/Enabler 拆解为可执行 User Story,并在 Gitea 创建 Issue。输入 JSON: {"title":"...","body":"...","labels":[...],"parent_reference_id":"...","estimated_hours":8}`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Tool) Call(ctx context.Context, input string) (string, error) {
|
||||||
|
ticket, err := parseInput(input)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("create_gitea_ticket: invalid input: %w", err)
|
||||||
|
}
|
||||||
|
if err := validate(ticket); err != nil {
|
||||||
|
return "", fmt.Errorf("create_gitea_ticket: validation failed: %w", err)
|
||||||
|
}
|
||||||
|
if t.baseURL == "" || t.token == "" || t.owner == "" || t.repo == "" {
|
||||||
|
return "", fmt.Errorf("create_gitea_ticket: missing Gitea configuration (base_url, token, owner, repo)")
|
||||||
|
}
|
||||||
|
|
||||||
|
body := buildIssueBody(ticket)
|
||||||
|
|
||||||
|
labelIDs, err := t.resolveLabels(ctx, ticket.Labels)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("create_gitea_ticket: label resolution failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if t.log != nil {
|
||||||
|
t.log.Infof("create_gitea_ticket: creating issue title=%q parent=%s labels=%v",
|
||||||
|
ticket.Title, ticket.ParentReferenceID, ticket.Labels)
|
||||||
|
}
|
||||||
|
|
||||||
|
issue, err := t.createIssue(ctx, ticket.Title, body, labelIDs)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("create_gitea_ticket: create issue failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := formatResult(issue, ticket)
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseInput(input string) (*TicketInput, error) {
|
||||||
|
raw := strings.TrimSpace(input)
|
||||||
|
if raw == "" {
|
||||||
|
return nil, fmt.Errorf("empty input")
|
||||||
|
}
|
||||||
|
var ticket TicketInput
|
||||||
|
if err := json.Unmarshal([]byte(raw), &ticket); err != nil {
|
||||||
|
return nil, fmt.Errorf("JSON parse error: %w", err)
|
||||||
|
}
|
||||||
|
return &ticket, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validate(t *TicketInput) error {
|
||||||
|
if strings.TrimSpace(t.Title) == "" {
|
||||||
|
return fmt.Errorf("title is required")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(t.Body) == "" {
|
||||||
|
return fmt.Errorf("body is required")
|
||||||
|
}
|
||||||
|
if len(t.Labels) == 0 {
|
||||||
|
return fmt.Errorf("labels is required and must contain at least one label")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(t.ParentReferenceID) == "" {
|
||||||
|
return fmt.Errorf("parent_reference_id is required")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildIssueBody 在原始 body 上追加溯源元数据和工时估算。
|
||||||
|
func buildIssueBody(ticket *TicketInput) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(ticket.Body)
|
||||||
|
b.WriteString("\n\n---\n\n")
|
||||||
|
b.WriteString("## 📋 SAFe 元数据\n\n")
|
||||||
|
b.WriteString(fmt.Sprintf("- **溯源 (Parent Reference)**: `%s`\n", ticket.ParentReferenceID))
|
||||||
|
if ticket.EstimatedHours > 0 {
|
||||||
|
b.WriteString(fmt.Sprintf("- **预估工时**: %d 小时\n", ticket.EstimatedHours))
|
||||||
|
}
|
||||||
|
b.WriteString(fmt.Sprintf("- **标签**: %s\n", strings.Join(ticket.Labels, ", ")))
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveLabels 通过 Gitea API 查询已有标签,将标签名映射为 ID。
|
||||||
|
// 如果标签不存在则自动创建。
|
||||||
|
func (t *Tool) resolveLabels(ctx context.Context, labelNames []string) ([]int64, error) {
|
||||||
|
existing, err := t.listLabels(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
nameToID := make(map[string]int64, len(existing))
|
||||||
|
for _, l := range existing {
|
||||||
|
nameToID[l.Name] = l.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
ids := make([]int64, 0, len(labelNames))
|
||||||
|
for _, name := range labelNames {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if id, ok := nameToID[name]; ok {
|
||||||
|
ids = append(ids, id)
|
||||||
|
} else {
|
||||||
|
id, err := t.createLabel(ctx, name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create label %q: %w", name, err)
|
||||||
|
}
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Tool) listLabels(ctx context.Context) ([]giteaLabelResp, error) {
|
||||||
|
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/labels?limit=50", t.baseURL, t.owner, t.repo)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
t.setAuth(req)
|
||||||
|
|
||||||
|
resp, err := t.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list labels request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return nil, fmt.Errorf("list labels returned %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var labels []giteaLabelResp
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&labels); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode labels response: %w", err)
|
||||||
|
}
|
||||||
|
return labels, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Tool) createLabel(ctx context.Context, name string) (int64, error) {
|
||||||
|
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/labels", t.baseURL, t.owner, t.repo)
|
||||||
|
payload := map[string]string{
|
||||||
|
"name": name,
|
||||||
|
"color": labelColor(name),
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(payload)
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
t.setAuth(req)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := t.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("create label request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusCreated {
|
||||||
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
|
return 0, fmt.Errorf("create label returned %d: %s", resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
var label giteaLabelResp
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&label); err != nil {
|
||||||
|
return 0, fmt.Errorf("decode create label response: %w", err)
|
||||||
|
}
|
||||||
|
return label.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Tool) createIssue(ctx context.Context, title, body string, labelIDs []int64) (*giteaIssueResp, error) {
|
||||||
|
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues", t.baseURL, t.owner, t.repo)
|
||||||
|
payload := giteaCreateIssueReq{
|
||||||
|
Title: title,
|
||||||
|
Body: body,
|
||||||
|
Labels: labelIDs,
|
||||||
|
}
|
||||||
|
jsonBody, _ := json.Marshal(payload)
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(jsonBody))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
t.setAuth(req)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := t.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create issue request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusCreated {
|
||||||
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
|
return nil, fmt.Errorf("create issue returned %d: %s", resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
var issue giteaIssueResp
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&issue); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode create issue response: %w", err)
|
||||||
|
}
|
||||||
|
return &issue, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Tool) setAuth(req *http.Request) {
|
||||||
|
if t.token != "" {
|
||||||
|
req.Header.Set("Authorization", "token "+t.token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatResult(issue *giteaIssueResp, ticket *TicketInput) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("✅ Gitea Issue 创建成功\n\n")
|
||||||
|
b.WriteString(fmt.Sprintf("- **Issue 编号**: #%d\n", issue.Number))
|
||||||
|
b.WriteString(fmt.Sprintf("- **标题**: %s\n", issue.Title))
|
||||||
|
b.WriteString(fmt.Sprintf("- **状态**: %s\n", issue.State))
|
||||||
|
b.WriteString(fmt.Sprintf("- **链接**: %s\n", issue.HTMLURL))
|
||||||
|
b.WriteString(fmt.Sprintf("- **溯源 (Parent)**: %s\n", ticket.ParentReferenceID))
|
||||||
|
if ticket.EstimatedHours > 0 {
|
||||||
|
b.WriteString(fmt.Sprintf("- **预估工时**: %d 小时\n", ticket.EstimatedHours))
|
||||||
|
}
|
||||||
|
b.WriteString(fmt.Sprintf("- **标签**: %s\n", strings.Join(ticket.Labels, ", ")))
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// labelColor 根据标签前缀返回一个辨识度高的颜色。
|
||||||
|
func labelColor(name string) string {
|
||||||
|
lower := strings.ToLower(name)
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(lower, "type/"):
|
||||||
|
return "#0075ca"
|
||||||
|
case strings.HasPrefix(lower, "domain/"):
|
||||||
|
return "#7057ff"
|
||||||
|
case strings.HasPrefix(lower, "priority/"):
|
||||||
|
if strings.Contains(lower, "high") || strings.Contains(lower, "critical") {
|
||||||
|
return "#d73a4a"
|
||||||
|
}
|
||||||
|
return "#e4e669"
|
||||||
|
case strings.HasPrefix(lower, "status/"):
|
||||||
|
return "#0e8a16"
|
||||||
|
default:
|
||||||
|
return "#ededed"
|
||||||
|
}
|
||||||
|
}
|
||||||
403
tools/giteaticket/giteaticket_test.go
Normal file
403
tools/giteaticket/giteaticket_test.go
Normal file
@@ -0,0 +1,403 @@
|
|||||||
|
package giteaticket
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func validTicketInput() TicketInput {
|
||||||
|
return TicketInput{
|
||||||
|
Title: "实现云端固件版本解析 API",
|
||||||
|
Body: `## 溯源
|
||||||
|
- Parent: FEAT_OTA_001
|
||||||
|
|
||||||
|
## 任务上下文
|
||||||
|
云端需要能够解析上传的固件包,提取版本号与依赖关系。
|
||||||
|
|
||||||
|
## 验收标准
|
||||||
|
- [ ] 上传 .bin 固件文件后返回解析结果 JSON
|
||||||
|
- [ ] 解析结果包含 version, dependencies 字段
|
||||||
|
|
||||||
|
## NFRs
|
||||||
|
- 响应时间 P99 < 500ms
|
||||||
|
|
||||||
|
## 技术实现思路
|
||||||
|
使用 Go 读取固件文件头部 metadata。`,
|
||||||
|
Labels: []string{"type/story", "domain/cloud", "priority/high", "status/todo"},
|
||||||
|
ParentReferenceID: "FEAT_OTA_001",
|
||||||
|
EstimatedHours: 8,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── parseInput tests ──
|
||||||
|
|
||||||
|
func TestParseInputValid(t *testing.T) {
|
||||||
|
in := validTicketInput()
|
||||||
|
data, _ := json.Marshal(in)
|
||||||
|
ticket, err := parseInput(string(data))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if ticket.Title != in.Title {
|
||||||
|
t.Errorf("title mismatch: got %q", ticket.Title)
|
||||||
|
}
|
||||||
|
if len(ticket.Labels) != 4 {
|
||||||
|
t.Errorf("expected 4 labels, got %d", len(ticket.Labels))
|
||||||
|
}
|
||||||
|
if ticket.ParentReferenceID != "FEAT_OTA_001" {
|
||||||
|
t.Errorf("parent_reference_id mismatch: got %q", ticket.ParentReferenceID)
|
||||||
|
}
|
||||||
|
if ticket.EstimatedHours != 8 {
|
||||||
|
t.Errorf("estimated_hours mismatch: got %d", ticket.EstimatedHours)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseInputEmpty(t *testing.T) {
|
||||||
|
_, err := parseInput("")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for empty input")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseInputInvalidJSON(t *testing.T) {
|
||||||
|
_, err := parseInput("{bad json}")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for invalid JSON")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── validate tests ──
|
||||||
|
|
||||||
|
func TestValidateMissingTitle(t *testing.T) {
|
||||||
|
in := validTicketInput()
|
||||||
|
in.Title = ""
|
||||||
|
err := validate(&in)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "title") {
|
||||||
|
t.Fatalf("expected title error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateMissingBody(t *testing.T) {
|
||||||
|
in := validTicketInput()
|
||||||
|
in.Body = ""
|
||||||
|
err := validate(&in)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "body") {
|
||||||
|
t.Fatalf("expected body error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateMissingLabels(t *testing.T) {
|
||||||
|
in := validTicketInput()
|
||||||
|
in.Labels = nil
|
||||||
|
err := validate(&in)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "labels") {
|
||||||
|
t.Fatalf("expected labels error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateMissingParentRef(t *testing.T) {
|
||||||
|
in := validTicketInput()
|
||||||
|
in.ParentReferenceID = ""
|
||||||
|
err := validate(&in)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "parent_reference_id") {
|
||||||
|
t.Fatalf("expected parent_reference_id error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateOptionalEstimatedHours(t *testing.T) {
|
||||||
|
in := validTicketInput()
|
||||||
|
in.EstimatedHours = 0
|
||||||
|
err := validate(&in)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("estimated_hours should be optional, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── buildIssueBody tests ──
|
||||||
|
|
||||||
|
func TestBuildIssueBodyContainsMetadata(t *testing.T) {
|
||||||
|
in := validTicketInput()
|
||||||
|
body := buildIssueBody(&in)
|
||||||
|
|
||||||
|
if !strings.Contains(body, "FEAT_OTA_001") {
|
||||||
|
t.Error("body missing parent_reference_id")
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "8 小时") {
|
||||||
|
t.Error("body missing estimated_hours")
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "type/story") {
|
||||||
|
t.Error("body missing labels")
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "SAFe 元数据") {
|
||||||
|
t.Error("body missing metadata section header")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildIssueBodyNoHours(t *testing.T) {
|
||||||
|
in := validTicketInput()
|
||||||
|
in.EstimatedHours = 0
|
||||||
|
body := buildIssueBody(&in)
|
||||||
|
if strings.Contains(body, "预估工时") {
|
||||||
|
t.Error("body should not contain estimated hours when 0")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── labelColor tests ──
|
||||||
|
|
||||||
|
func TestLabelColor(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{"type/story", "#0075ca"},
|
||||||
|
{"domain/cloud", "#7057ff"},
|
||||||
|
{"priority/high", "#d73a4a"},
|
||||||
|
{"priority/low", "#e4e669"},
|
||||||
|
{"status/todo", "#0e8a16"},
|
||||||
|
{"custom-label", "#ededed"},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
got := labelColor(tc.name)
|
||||||
|
if got != tc.expected {
|
||||||
|
t.Errorf("labelColor(%q) = %q, want %q", tc.name, got, tc.expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Name / Description tests ──
|
||||||
|
|
||||||
|
func TestNameAndDescription(t *testing.T) {
|
||||||
|
tool := New(Config{}, nil)
|
||||||
|
if tool.Name() != "create_gitea_ticket" {
|
||||||
|
t.Errorf("unexpected name: %s", tool.Name())
|
||||||
|
}
|
||||||
|
if tool.Description() == "" {
|
||||||
|
t.Error("description should not be empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Integration test with mock Gitea server ──
|
||||||
|
|
||||||
|
func TestCallWithMockGitea(t *testing.T) {
|
||||||
|
var mu sync.Mutex
|
||||||
|
var createdIssue map[string]interface{}
|
||||||
|
labelIDCounter := int64(100)
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Verify auth header
|
||||||
|
auth := r.Header.Get("Authorization")
|
||||||
|
if auth != "token test-token" {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
// GET /api/v1/repos/owner/repo/labels
|
||||||
|
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/labels"):
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
// Return one existing label
|
||||||
|
json.NewEncoder(w).Encode([]giteaLabelResp{
|
||||||
|
{ID: 1, Name: "type/story"},
|
||||||
|
})
|
||||||
|
|
||||||
|
// POST /api/v1/repos/owner/repo/labels
|
||||||
|
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/labels"):
|
||||||
|
var payload map[string]string
|
||||||
|
json.NewDecoder(r.Body).Decode(&payload)
|
||||||
|
mu.Lock()
|
||||||
|
labelIDCounter++
|
||||||
|
id := labelIDCounter
|
||||||
|
mu.Unlock()
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
json.NewEncoder(w).Encode(giteaLabelResp{
|
||||||
|
ID: id,
|
||||||
|
Name: payload["name"],
|
||||||
|
})
|
||||||
|
|
||||||
|
// POST /api/v1/repos/owner/repo/issues
|
||||||
|
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/issues"):
|
||||||
|
json.NewDecoder(r.Body).Decode(&createdIssue)
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
json.NewEncoder(w).Encode(giteaIssueResp{
|
||||||
|
ID: 42,
|
||||||
|
Number: 42,
|
||||||
|
HTMLURL: "https://gitea.example.com/owner/repo/issues/42",
|
||||||
|
Title: createdIssue["title"].(string),
|
||||||
|
State: "open",
|
||||||
|
CreatedAt: "2026-03-11T10:00:00Z",
|
||||||
|
})
|
||||||
|
|
||||||
|
default:
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
tool := New(Config{
|
||||||
|
BaseURL: server.URL,
|
||||||
|
Token: "test-token",
|
||||||
|
Owner: "owner",
|
||||||
|
Repo: "repo",
|
||||||
|
}, nil)
|
||||||
|
|
||||||
|
in := validTicketInput()
|
||||||
|
data, _ := json.Marshal(in)
|
||||||
|
|
||||||
|
result, err := tool.Call(context.Background(), string(data))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(result, "#42") {
|
||||||
|
t.Error("result missing issue number")
|
||||||
|
}
|
||||||
|
if !strings.Contains(result, "创建成功") {
|
||||||
|
t.Error("result missing success message")
|
||||||
|
}
|
||||||
|
if !strings.Contains(result, "FEAT_OTA_001") {
|
||||||
|
t.Error("result missing parent reference")
|
||||||
|
}
|
||||||
|
if !strings.Contains(result, "8 小时") {
|
||||||
|
t.Error("result missing estimated hours")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the issue body sent to Gitea contains SAFe metadata
|
||||||
|
if body, ok := createdIssue["body"].(string); ok {
|
||||||
|
if !strings.Contains(body, "SAFe 元数据") {
|
||||||
|
t.Error("issue body missing SAFe metadata")
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "FEAT_OTA_001") {
|
||||||
|
t.Error("issue body missing parent reference")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
t.Error("createdIssue body not captured")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCallMissingConfig(t *testing.T) {
|
||||||
|
tool := New(Config{}, nil)
|
||||||
|
in := validTicketInput()
|
||||||
|
data, _ := json.Marshal(in)
|
||||||
|
|
||||||
|
_, err := tool.Call(context.Background(), string(data))
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "missing Gitea configuration") {
|
||||||
|
t.Fatalf("expected config error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCallInvalidInput(t *testing.T) {
|
||||||
|
tool := New(Config{
|
||||||
|
BaseURL: "http://localhost",
|
||||||
|
Token: "t",
|
||||||
|
Owner: "o",
|
||||||
|
Repo: "r",
|
||||||
|
}, nil)
|
||||||
|
_, err := tool.Call(context.Background(), "not json")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for invalid input")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCallValidationError(t *testing.T) {
|
||||||
|
tool := New(Config{
|
||||||
|
BaseURL: "http://localhost",
|
||||||
|
Token: "t",
|
||||||
|
Owner: "o",
|
||||||
|
Repo: "r",
|
||||||
|
}, nil)
|
||||||
|
in := validTicketInput()
|
||||||
|
in.Title = ""
|
||||||
|
data, _ := json.Marshal(in)
|
||||||
|
|
||||||
|
_, err := tool.Call(context.Background(), string(data))
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "title") {
|
||||||
|
t.Fatalf("expected title validation error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test that labels resolution creates missing labels
|
||||||
|
func TestCallCreatesNewLabels(t *testing.T) {
|
||||||
|
createdLabels := []string{}
|
||||||
|
var mu sync.Mutex
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/labels"):
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
// No existing labels
|
||||||
|
json.NewEncoder(w).Encode([]giteaLabelResp{})
|
||||||
|
|
||||||
|
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/labels"):
|
||||||
|
var payload map[string]string
|
||||||
|
json.NewDecoder(r.Body).Decode(&payload)
|
||||||
|
mu.Lock()
|
||||||
|
createdLabels = append(createdLabels, payload["name"])
|
||||||
|
mu.Unlock()
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
json.NewEncoder(w).Encode(giteaLabelResp{
|
||||||
|
ID: int64(len(createdLabels)),
|
||||||
|
Name: payload["name"],
|
||||||
|
})
|
||||||
|
|
||||||
|
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/issues"):
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
json.NewEncoder(w).Encode(giteaIssueResp{
|
||||||
|
ID: 1, Number: 1, HTMLURL: "http://test/1", Title: "t", State: "open",
|
||||||
|
})
|
||||||
|
|
||||||
|
default:
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
tool := New(Config{
|
||||||
|
BaseURL: server.URL,
|
||||||
|
Token: "tok",
|
||||||
|
Owner: "o",
|
||||||
|
Repo: "r",
|
||||||
|
}, nil)
|
||||||
|
|
||||||
|
in := validTicketInput()
|
||||||
|
data, _ := json.Marshal(in)
|
||||||
|
_, err := tool.Call(context.Background(), string(data))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if len(createdLabels) != 4 {
|
||||||
|
t.Errorf("expected 4 labels created, got %d: %v", len(createdLabels), createdLabels)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatResult(t *testing.T) {
|
||||||
|
issue := &giteaIssueResp{
|
||||||
|
Number: 99,
|
||||||
|
Title: "测试任务",
|
||||||
|
State: "open",
|
||||||
|
HTMLURL: "https://gitea.example.com/issues/99",
|
||||||
|
}
|
||||||
|
ticket := &TicketInput{
|
||||||
|
ParentReferenceID: "ENAB_KAFKA_001",
|
||||||
|
Labels: []string{"type/enabler"},
|
||||||
|
EstimatedHours: 4,
|
||||||
|
}
|
||||||
|
result := formatResult(issue, ticket)
|
||||||
|
|
||||||
|
if !strings.Contains(result, "#99") {
|
||||||
|
t.Error("missing issue number")
|
||||||
|
}
|
||||||
|
if !strings.Contains(result, "ENAB_KAFKA_001") {
|
||||||
|
t.Error("missing parent reference")
|
||||||
|
}
|
||||||
|
if !strings.Contains(result, "4 小时") {
|
||||||
|
t.Error("missing estimated hours")
|
||||||
|
}
|
||||||
|
}
|
||||||
309
tools/piplan/piplan.go
Normal file
309
tools/piplan/piplan.go
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
package piplan
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"laodingbot/internal/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Feature 产品经理视角输出的业务特性。
|
||||||
|
type Feature struct {
|
||||||
|
FeatureID string `json:"feature_id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
BenefitHypothesis string `json:"benefit_hypothesis"`
|
||||||
|
AcceptanceCriteria []string `json:"acceptance_criteria"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enabler 系统架构师视角输出的技术赋能特性(架构跑道)。
|
||||||
|
type Enabler struct {
|
||||||
|
EnablerID string `json:"enabler_id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
ArchitecturalPurpose string `json:"architectural_purpose"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NFRs 非功能性需求。
|
||||||
|
type NFRs struct {
|
||||||
|
Performance string `json:"performance"`
|
||||||
|
Security string `json:"security"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dependency RTE 梳理的任务依赖关系。
|
||||||
|
type Dependency struct {
|
||||||
|
SourceID string `json:"source_id"`
|
||||||
|
TargetID string `json:"target_id"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PIPlanInput publish_pi_plan 工具的完整输入结构。
|
||||||
|
type PIPlanInput struct {
|
||||||
|
PIVision string `json:"pi_vision"`
|
||||||
|
Features []Feature `json:"features"`
|
||||||
|
Enablers []Enabler `json:"enablers"`
|
||||||
|
NFRs NFRs `json:"nfrs"`
|
||||||
|
Dependencies []Dependency `json:"dependencies"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tool 实现 SAFe PI 规划发布工具。
|
||||||
|
type Tool struct {
|
||||||
|
maxOutputChars int
|
||||||
|
log *logger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// New 创建一个新的 publish_pi_plan 工具实例。
|
||||||
|
func New(maxOutputChars int, log *logger.Logger) *Tool {
|
||||||
|
if maxOutputChars <= 0 {
|
||||||
|
maxOutputChars = 20000
|
||||||
|
}
|
||||||
|
return &Tool{
|
||||||
|
maxOutputChars: maxOutputChars,
|
||||||
|
log: log,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Tool) Name() string { return "publish_pi_plan" }
|
||||||
|
|
||||||
|
func (t *Tool) Description() string {
|
||||||
|
return `当铁三角(PM, 架构师, RTE)完成 PI 规划推演后,调用此工具输出标准化的架构蓝图与任务清单。输入为 JSON,包含 pi_vision, features, enablers, nfrs, dependencies 字段。`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Tool) Call(ctx context.Context, input string) (string, error) {
|
||||||
|
plan, err := parseInput(input)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("publish_pi_plan: invalid input: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validate(plan); err != nil {
|
||||||
|
return "", fmt.Errorf("publish_pi_plan: validation failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if t.log != nil {
|
||||||
|
t.log.Infof("publish_pi_plan: features=%d enablers=%d deps=%d",
|
||||||
|
len(plan.Features), len(plan.Enablers), len(plan.Dependencies))
|
||||||
|
}
|
||||||
|
|
||||||
|
output := render(plan)
|
||||||
|
|
||||||
|
if len(output) > t.maxOutputChars {
|
||||||
|
output = output[:t.maxOutputChars]
|
||||||
|
}
|
||||||
|
return output, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseInput(input string) (*PIPlanInput, error) {
|
||||||
|
raw := strings.TrimSpace(input)
|
||||||
|
if raw == "" {
|
||||||
|
return nil, fmt.Errorf("empty input")
|
||||||
|
}
|
||||||
|
|
||||||
|
var plan PIPlanInput
|
||||||
|
if err := json.Unmarshal([]byte(raw), &plan); err != nil {
|
||||||
|
return nil, fmt.Errorf("JSON parse error: %w", err)
|
||||||
|
}
|
||||||
|
return &plan, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validate(p *PIPlanInput) error {
|
||||||
|
if strings.TrimSpace(p.PIVision) == "" {
|
||||||
|
return fmt.Errorf("pi_vision is required")
|
||||||
|
}
|
||||||
|
if len(p.Features) == 0 {
|
||||||
|
return fmt.Errorf("at least one feature is required")
|
||||||
|
}
|
||||||
|
for i, f := range p.Features {
|
||||||
|
if strings.TrimSpace(f.FeatureID) == "" {
|
||||||
|
return fmt.Errorf("features[%d].feature_id is required", i)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(f.Title) == "" {
|
||||||
|
return fmt.Errorf("features[%d].title is required", i)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(f.BenefitHypothesis) == "" {
|
||||||
|
return fmt.Errorf("features[%d].benefit_hypothesis is required", i)
|
||||||
|
}
|
||||||
|
if len(f.AcceptanceCriteria) == 0 {
|
||||||
|
return fmt.Errorf("features[%d].acceptance_criteria requires at least one item", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i, e := range p.Enablers {
|
||||||
|
if strings.TrimSpace(e.EnablerID) == "" {
|
||||||
|
return fmt.Errorf("enablers[%d].enabler_id is required", i)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(e.Title) == "" {
|
||||||
|
return fmt.Errorf("enablers[%d].title is required", i)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(e.ArchitecturalPurpose) == "" {
|
||||||
|
return fmt.Errorf("enablers[%d].architectural_purpose is required", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(p.NFRs.Performance) == "" {
|
||||||
|
return fmt.Errorf("nfrs.performance is required")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(p.NFRs.Security) == "" {
|
||||||
|
return fmt.Errorf("nfrs.security is required")
|
||||||
|
}
|
||||||
|
for i, d := range p.Dependencies {
|
||||||
|
if strings.TrimSpace(d.SourceID) == "" {
|
||||||
|
return fmt.Errorf("dependencies[%d].source_id is required", i)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(d.TargetID) == "" {
|
||||||
|
return fmt.Errorf("dependencies[%d].target_id is required", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// render 将 PI 规划输入渲染为标准化的 Markdown 架构蓝图与任务清单。
|
||||||
|
func render(p *PIPlanInput) string {
|
||||||
|
var b strings.Builder
|
||||||
|
|
||||||
|
// ── 标题 ──
|
||||||
|
b.WriteString("# PI 规划架构蓝图与任务清单\n\n")
|
||||||
|
|
||||||
|
// ── 1. PI 愿景 ──
|
||||||
|
b.WriteString("## 1. PI 愿景\n\n")
|
||||||
|
b.WriteString(strings.TrimSpace(p.PIVision))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
|
||||||
|
// ── 2. 业务特性清单 (Features) ──
|
||||||
|
b.WriteString("## 2. 业务特性清单 (Features)\n\n")
|
||||||
|
for _, f := range p.Features {
|
||||||
|
b.WriteString(fmt.Sprintf("### %s — %s\n\n", f.FeatureID, f.Title))
|
||||||
|
b.WriteString(fmt.Sprintf("**业务价值假设**: %s\n\n", f.BenefitHypothesis))
|
||||||
|
b.WriteString("**验收标准 (AC)**:\n\n")
|
||||||
|
for j, ac := range f.AcceptanceCriteria {
|
||||||
|
b.WriteString(fmt.Sprintf("- [ ] AC-%d: %s\n", j+1, ac))
|
||||||
|
}
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 3. 技术赋能特性 (Enablers / 架构跑道) ──
|
||||||
|
b.WriteString("## 3. 技术赋能特性 (Enablers / 架构跑道)\n\n")
|
||||||
|
if len(p.Enablers) == 0 {
|
||||||
|
b.WriteString("_无技术赋能特性。_\n\n")
|
||||||
|
} else {
|
||||||
|
b.WriteString("| Enabler ID | 名称 | 架构意图 |\n")
|
||||||
|
b.WriteString("|------------|------|----------|\n")
|
||||||
|
for _, e := range p.Enablers {
|
||||||
|
b.WriteString(fmt.Sprintf("| %s | %s | %s |\n",
|
||||||
|
e.EnablerID, e.Title, e.ArchitecturalPurpose))
|
||||||
|
}
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 4. 非功能性需求 (NFRs) ──
|
||||||
|
b.WriteString("## 4. 非功能性需求 (NFRs)\n\n")
|
||||||
|
b.WriteString(fmt.Sprintf("- **性能**: %s\n", p.NFRs.Performance))
|
||||||
|
b.WriteString(fmt.Sprintf("- **安全与合规**: %s\n", p.NFRs.Security))
|
||||||
|
b.WriteString("\n")
|
||||||
|
|
||||||
|
// ── 5. 依赖关系图 ──
|
||||||
|
b.WriteString("## 5. 依赖关系\n\n")
|
||||||
|
if len(p.Dependencies) == 0 {
|
||||||
|
b.WriteString("_无跨任务依赖。_\n\n")
|
||||||
|
} else {
|
||||||
|
b.WriteString("| 前置任务 (Source) | 后续任务 (Target) | 依赖原因 |\n")
|
||||||
|
b.WriteString("|-------------------|-------------------|----------|\n")
|
||||||
|
for _, d := range p.Dependencies {
|
||||||
|
reason := d.Reason
|
||||||
|
if reason == "" {
|
||||||
|
reason = "—"
|
||||||
|
}
|
||||||
|
b.WriteString(fmt.Sprintf("| %s | %s | %s |\n",
|
||||||
|
d.SourceID, d.TargetID, reason))
|
||||||
|
}
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 6. 建议执行顺序 ──
|
||||||
|
b.WriteString("## 6. 建议执行顺序\n\n")
|
||||||
|
order := computeExecutionOrder(p)
|
||||||
|
for i, id := range order {
|
||||||
|
b.WriteString(fmt.Sprintf("%d. %s\n", i+1, id))
|
||||||
|
}
|
||||||
|
b.WriteString("\n")
|
||||||
|
|
||||||
|
// ── 7. 质量门禁检查清单 ──
|
||||||
|
b.WriteString("## 7. 质量门禁检查清单\n\n")
|
||||||
|
b.WriteString("### 业务验收测试用例\n\n")
|
||||||
|
for _, f := range p.Features {
|
||||||
|
for j, ac := range f.AcceptanceCriteria {
|
||||||
|
b.WriteString(fmt.Sprintf("- [ ] [%s] AC-%d: %s\n", f.FeatureID, j+1, ac))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.WriteString("\n### 非功能性验证\n\n")
|
||||||
|
b.WriteString(fmt.Sprintf("- [ ] 性能压测: %s\n", p.NFRs.Performance))
|
||||||
|
b.WriteString(fmt.Sprintf("- [ ] 安全扫描: %s\n", p.NFRs.Security))
|
||||||
|
b.WriteString("\n")
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// computeExecutionOrder 根据依赖关系计算拓扑排序的执行顺序。
|
||||||
|
// 先排 Enabler,再排 Feature;无依赖的排在前面。
|
||||||
|
func computeExecutionOrder(p *PIPlanInput) []string {
|
||||||
|
// 收集所有 ID
|
||||||
|
allIDs := make([]string, 0, len(p.Enablers)+len(p.Features))
|
||||||
|
idSet := make(map[string]bool)
|
||||||
|
for _, e := range p.Enablers {
|
||||||
|
allIDs = append(allIDs, e.EnablerID)
|
||||||
|
idSet[e.EnablerID] = true
|
||||||
|
}
|
||||||
|
for _, f := range p.Features {
|
||||||
|
allIDs = append(allIDs, f.FeatureID)
|
||||||
|
idSet[f.FeatureID] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建入度表和邻接表
|
||||||
|
inDegree := make(map[string]int)
|
||||||
|
adj := make(map[string][]string)
|
||||||
|
for _, id := range allIDs {
|
||||||
|
inDegree[id] = 0
|
||||||
|
}
|
||||||
|
for _, d := range p.Dependencies {
|
||||||
|
if !idSet[d.SourceID] || !idSet[d.TargetID] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
adj[d.SourceID] = append(adj[d.SourceID], d.TargetID)
|
||||||
|
inDegree[d.TargetID]++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kahn 拓扑排序
|
||||||
|
queue := make([]string, 0)
|
||||||
|
// 先加入度为 0 的 Enabler,再加入度为 0 的 Feature,保持稳定顺序
|
||||||
|
for _, e := range p.Enablers {
|
||||||
|
if inDegree[e.EnablerID] == 0 {
|
||||||
|
queue = append(queue, e.EnablerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, f := range p.Features {
|
||||||
|
if inDegree[f.FeatureID] == 0 {
|
||||||
|
queue = append(queue, f.FeatureID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var result []string
|
||||||
|
for len(queue) > 0 {
|
||||||
|
curr := queue[0]
|
||||||
|
queue = queue[1:]
|
||||||
|
result = append(result, curr)
|
||||||
|
for _, next := range adj[curr] {
|
||||||
|
inDegree[next]--
|
||||||
|
if inDegree[next] == 0 {
|
||||||
|
queue = append(queue, next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果存在环,将未排序的节点追加到末尾并标记
|
||||||
|
if len(result) < len(allIDs) {
|
||||||
|
for _, id := range allIDs {
|
||||||
|
if inDegree[id] > 0 {
|
||||||
|
result = append(result, id+" ⚠️(循环依赖)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
334
tools/piplan/piplan_test.go
Normal file
334
tools/piplan/piplan_test.go
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
package piplan
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func validInput() PIPlanInput {
|
||||||
|
return PIPlanInput{
|
||||||
|
PIVision: "实现车云一体化 OTA 系统,支撑百万级终端设备的安全固件升级",
|
||||||
|
Features: []Feature{
|
||||||
|
{
|
||||||
|
FeatureID: "FEAT_OTA_001",
|
||||||
|
Title: "云端固件版本依赖检查",
|
||||||
|
BenefitHypothesis: "上线后减少 30% 的固件回退率",
|
||||||
|
AcceptanceCriteria: []string{
|
||||||
|
"上传固件时自动解析并记录版本依赖关系",
|
||||||
|
"下发升级任务时自动校验设备当前版本是否满足依赖",
|
||||||
|
"不满足依赖时返回明确的错误提示及所需前置版本",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
FeatureID: "FEAT_OTA_002",
|
||||||
|
Title: "端侧断点续传",
|
||||||
|
BenefitHypothesis: "弱网环境下固件下载成功率提升至 99.5%",
|
||||||
|
AcceptanceCriteria: []string{
|
||||||
|
"支持分片下载与本地缓存校验",
|
||||||
|
"网络恢复后自动续传,无需用户干预",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Enablers: []Enabler{
|
||||||
|
{
|
||||||
|
EnablerID: "ENAB_KAFKA_001",
|
||||||
|
Title: "搭建跨可用区的高可用 Kafka 集群",
|
||||||
|
ArchitecturalPurpose: "为高并发 OTA 状态机提供可靠消息管道",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
EnablerID: "ENAB_S3_001",
|
||||||
|
Title: "对象存储多区域同步",
|
||||||
|
ArchitecturalPurpose: "保证固件文件在多区域的低延迟分发",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
NFRs: NFRs{
|
||||||
|
Performance: "API 响应时间 P99 < 200ms,吞吐量 > 10000 QPS",
|
||||||
|
Security: "车云通信必须使用 TLS 1.3,敏感数据必须脱敏",
|
||||||
|
},
|
||||||
|
Dependencies: []Dependency{
|
||||||
|
{
|
||||||
|
SourceID: "ENAB_KAFKA_001",
|
||||||
|
TargetID: "FEAT_OTA_001",
|
||||||
|
Reason: "版本检查服务依赖 Kafka 进行异步事件通知",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
SourceID: "ENAB_S3_001",
|
||||||
|
TargetID: "FEAT_OTA_002",
|
||||||
|
Reason: "断点续传需要对象存储支持 Range 请求",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseInputValid(t *testing.T) {
|
||||||
|
in := validInput()
|
||||||
|
data, _ := json.Marshal(in)
|
||||||
|
plan, err := parseInput(string(data))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if plan.PIVision != in.PIVision {
|
||||||
|
t.Errorf("pi_vision mismatch: got %q", plan.PIVision)
|
||||||
|
}
|
||||||
|
if len(plan.Features) != 2 {
|
||||||
|
t.Errorf("expected 2 features, got %d", len(plan.Features))
|
||||||
|
}
|
||||||
|
if len(plan.Enablers) != 2 {
|
||||||
|
t.Errorf("expected 2 enablers, got %d", len(plan.Enablers))
|
||||||
|
}
|
||||||
|
if len(plan.Dependencies) != 2 {
|
||||||
|
t.Errorf("expected 2 dependencies, got %d", len(plan.Dependencies))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseInputEmpty(t *testing.T) {
|
||||||
|
_, err := parseInput("")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for empty input")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseInputInvalidJSON(t *testing.T) {
|
||||||
|
_, err := parseInput("{not json}")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for invalid JSON")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateMissingVision(t *testing.T) {
|
||||||
|
in := validInput()
|
||||||
|
in.PIVision = ""
|
||||||
|
err := validate(&in)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "pi_vision") {
|
||||||
|
t.Fatalf("expected pi_vision error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateNoFeatures(t *testing.T) {
|
||||||
|
in := validInput()
|
||||||
|
in.Features = nil
|
||||||
|
err := validate(&in)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "feature") {
|
||||||
|
t.Fatalf("expected feature error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateFeatureMissingID(t *testing.T) {
|
||||||
|
in := validInput()
|
||||||
|
in.Features[0].FeatureID = ""
|
||||||
|
err := validate(&in)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "feature_id") {
|
||||||
|
t.Fatalf("expected feature_id error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateFeatureMissingAC(t *testing.T) {
|
||||||
|
in := validInput()
|
||||||
|
in.Features[0].AcceptanceCriteria = nil
|
||||||
|
err := validate(&in)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "acceptance_criteria") {
|
||||||
|
t.Fatalf("expected acceptance_criteria error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateEnablerMissingPurpose(t *testing.T) {
|
||||||
|
in := validInput()
|
||||||
|
in.Enablers[0].ArchitecturalPurpose = ""
|
||||||
|
err := validate(&in)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "architectural_purpose") {
|
||||||
|
t.Fatalf("expected architectural_purpose error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateNFRsMissingPerformance(t *testing.T) {
|
||||||
|
in := validInput()
|
||||||
|
in.NFRs.Performance = ""
|
||||||
|
err := validate(&in)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "performance") {
|
||||||
|
t.Fatalf("expected performance error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateNFRsMissingSecurity(t *testing.T) {
|
||||||
|
in := validInput()
|
||||||
|
in.NFRs.Security = ""
|
||||||
|
err := validate(&in)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "security") {
|
||||||
|
t.Fatalf("expected security error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateDepMissingSourceID(t *testing.T) {
|
||||||
|
in := validInput()
|
||||||
|
in.Dependencies[0].SourceID = ""
|
||||||
|
err := validate(&in)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "source_id") {
|
||||||
|
t.Fatalf("expected source_id error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderContainsSections(t *testing.T) {
|
||||||
|
in := validInput()
|
||||||
|
out := render(&in)
|
||||||
|
|
||||||
|
sections := []string{
|
||||||
|
"# PI 规划架构蓝图与任务清单",
|
||||||
|
"## 1. PI 愿景",
|
||||||
|
"## 2. 业务特性清单 (Features)",
|
||||||
|
"## 3. 技术赋能特性 (Enablers / 架构跑道)",
|
||||||
|
"## 4. 非功能性需求 (NFRs)",
|
||||||
|
"## 5. 依赖关系",
|
||||||
|
"## 6. 建议执行顺序",
|
||||||
|
"## 7. 质量门禁检查清单",
|
||||||
|
}
|
||||||
|
for _, s := range sections {
|
||||||
|
if !strings.Contains(out, s) {
|
||||||
|
t.Errorf("output missing section: %s", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderContainsFeatureDetails(t *testing.T) {
|
||||||
|
in := validInput()
|
||||||
|
out := render(&in)
|
||||||
|
|
||||||
|
if !strings.Contains(out, "FEAT_OTA_001") {
|
||||||
|
t.Error("output missing FEAT_OTA_001")
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "云端固件版本依赖检查") {
|
||||||
|
t.Error("output missing feature title")
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "AC-1") {
|
||||||
|
t.Error("output missing acceptance criteria numbering")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderContainsEnablerTable(t *testing.T) {
|
||||||
|
in := validInput()
|
||||||
|
out := render(&in)
|
||||||
|
|
||||||
|
if !strings.Contains(out, "ENAB_KAFKA_001") {
|
||||||
|
t.Error("output missing ENAB_KAFKA_001")
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "ENAB_S3_001") {
|
||||||
|
t.Error("output missing ENAB_S3_001")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderContainsNFRs(t *testing.T) {
|
||||||
|
in := validInput()
|
||||||
|
out := render(&in)
|
||||||
|
|
||||||
|
if !strings.Contains(out, "P99 < 200ms") {
|
||||||
|
t.Error("output missing performance NFR")
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "TLS 1.3") {
|
||||||
|
t.Error("output missing security NFR")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderContainsDependencies(t *testing.T) {
|
||||||
|
in := validInput()
|
||||||
|
out := render(&in)
|
||||||
|
|
||||||
|
if !strings.Contains(out, "ENAB_KAFKA_001") || !strings.Contains(out, "FEAT_OTA_001") {
|
||||||
|
t.Error("output missing dependency pair")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeExecutionOrder(t *testing.T) {
|
||||||
|
in := validInput()
|
||||||
|
order := computeExecutionOrder(&in)
|
||||||
|
|
||||||
|
// Enablers should come before their dependent Features
|
||||||
|
enablerIdx := map[string]int{}
|
||||||
|
featureIdx := map[string]int{}
|
||||||
|
for i, id := range order {
|
||||||
|
if strings.HasPrefix(id, "ENAB_") {
|
||||||
|
enablerIdx[id] = i
|
||||||
|
} else if strings.HasPrefix(id, "FEAT_") {
|
||||||
|
featureIdx[id] = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if enablerIdx["ENAB_KAFKA_001"] >= featureIdx["FEAT_OTA_001"] {
|
||||||
|
t.Error("ENAB_KAFKA_001 should come before FEAT_OTA_001")
|
||||||
|
}
|
||||||
|
if enablerIdx["ENAB_S3_001"] >= featureIdx["FEAT_OTA_002"] {
|
||||||
|
t.Error("ENAB_S3_001 should come before FEAT_OTA_002")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeExecutionOrderNoDeps(t *testing.T) {
|
||||||
|
in := validInput()
|
||||||
|
in.Dependencies = nil
|
||||||
|
order := computeExecutionOrder(&in)
|
||||||
|
|
||||||
|
if len(order) != 4 {
|
||||||
|
t.Errorf("expected 4 items, got %d", len(order))
|
||||||
|
}
|
||||||
|
// Enablers first, then Features (stable order)
|
||||||
|
if order[0] != "ENAB_KAFKA_001" || order[1] != "ENAB_S3_001" {
|
||||||
|
t.Errorf("enablers should come first, got: %v", order)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCallEndToEnd(t *testing.T) {
|
||||||
|
tool := New(0, nil)
|
||||||
|
in := validInput()
|
||||||
|
data, _ := json.Marshal(in)
|
||||||
|
|
||||||
|
result, err := tool.Call(context.Background(), string(data))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result, "PI 规划架构蓝图") {
|
||||||
|
t.Error("output missing title")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCallInvalidInput(t *testing.T) {
|
||||||
|
tool := New(0, nil)
|
||||||
|
_, err := tool.Call(context.Background(), "not json")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for invalid input")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCallMissingRequiredField(t *testing.T) {
|
||||||
|
tool := New(0, nil)
|
||||||
|
in := validInput()
|
||||||
|
in.PIVision = ""
|
||||||
|
data, _ := json.Marshal(in)
|
||||||
|
|
||||||
|
_, err := tool.Call(context.Background(), string(data))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected validation error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNameAndDescription(t *testing.T) {
|
||||||
|
tool := New(0, nil)
|
||||||
|
if tool.Name() != "publish_pi_plan" {
|
||||||
|
t.Errorf("unexpected name: %s", tool.Name())
|
||||||
|
}
|
||||||
|
if tool.Description() == "" {
|
||||||
|
t.Error("description should not be empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMaxOutputTruncation(t *testing.T) {
|
||||||
|
tool := New(100, nil)
|
||||||
|
in := validInput()
|
||||||
|
data, _ := json.Marshal(in)
|
||||||
|
|
||||||
|
result, err := tool.Call(context.Background(), string(data))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(result) > 100 {
|
||||||
|
t.Errorf("output should be truncated to 100 chars, got %d", len(result))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user