From 8dc5354fa430ad7cf08bfe796b033fe8e290da89 Mon Sep 17 00:00:00 2001 From: "Ding, Shuo" Date: Wed, 11 Mar 2026 17:58:19 +0800 Subject: [PATCH] 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 --- cmd/bot/main.go | 23 +- configs/env.sample | 12 +- doc/WebUI_Stream_API_前端对接说明.md | 187 +++++ internal/agent/orchestrator.go | 753 +++++++++++++----- .../orchestrator_skill_selection_test.go | 47 ++ internal/config/config.go | 48 +- internal/llm/client.go | 252 ++---- internal/toolhost/runtime.go | 30 + internal/transport/webui/bot.go | 189 +++-- internal/transport/webui/bot_test.go | 207 +++-- skills/safe_pi_planning/skill.md | 247 ++++++ tools/filedoc/filedoc.go | 208 +++++ tools/filedoc/filedoc_test.go | 74 ++ tools/giteaticket/giteaticket.go | 328 ++++++++ tools/giteaticket/giteaticket_test.go | 403 ++++++++++ tools/piplan/piplan.go | 309 +++++++ tools/piplan/piplan_test.go | 334 ++++++++ 17 files changed, 3086 insertions(+), 565 deletions(-) create mode 100644 doc/WebUI_Stream_API_前端对接说明.md create mode 100644 skills/safe_pi_planning/skill.md create mode 100644 tools/filedoc/filedoc.go create mode 100644 tools/filedoc/filedoc_test.go create mode 100644 tools/giteaticket/giteaticket.go create mode 100644 tools/giteaticket/giteaticket_test.go create mode 100644 tools/piplan/piplan.go create mode 100644 tools/piplan/piplan_test.go diff --git a/cmd/bot/main.go b/cmd/bot/main.go index 8d4f72f..8cbf11c 100644 --- a/cmd/bot/main.go +++ b/cmd/bot/main.go @@ -126,9 +126,19 @@ func main() { // 实例化 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、记忆系统、知识技能库与各种工具 engine := agent.NewOrchestrator( llmClient, + routerLLMClient, store, toolRegistry, soul, @@ -207,11 +217,18 @@ func runMessageChannel(ctx context.Context, cfg config.Config, engine *agent.Orc return wb.Run( ctx, 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) }, + 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) { return engine.UploadAndCacheFiles(ctx, chatID, userID, files) }, diff --git a/configs/env.sample b/configs/env.sample index e8f24ce..2b84bc5 100644 --- a/configs/env.sample +++ b/configs/env.sample @@ -17,6 +17,8 @@ TELEGRAM_POLL_TIMEOUT_SECONDS=30 FEISHU_APP_ID= FEISHU_APP_SECRET= FEISHU_VERIFY_TOKEN= +FEISHU_LISTEN_ADDR=:8080 +FEISHU_EVENT_PATH=/feishu/events WEBUI_LISTEN_ADDR=:8090 WEBUI_MAX_UPLOAD_MB=20 @@ -24,7 +26,15 @@ LLM_BASE_URL=https://api.openai.com/v1 LLM_API_KEY= LLM_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 ALLOWED_DIRS=./workspace,./data,./skills diff --git a/doc/WebUI_Stream_API_前端对接说明.md b/doc/WebUI_Stream_API_前端对接说明.md new file mode 100644 index 0000000..9a852e6 --- /dev/null +++ b/doc/WebUI_Stream_API_前端对接说明.md @@ -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: \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 { + 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: \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` 都能正确结束当前轮次 +- 验证弱网下不会丢失已收到的事件 +- 验证用户快速连续提问时,旧流可被取消 diff --git a/internal/agent/orchestrator.go b/internal/agent/orchestrator.go index 1bccfd1..322c280 100644 --- a/internal/agent/orchestrator.go +++ b/internal/agent/orchestrator.go @@ -18,9 +18,32 @@ import ( "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 计算、上下文管理、技能匹配计算和工具调用。 type Orchestrator struct { llm llm.Client + routerLLM llm.Client // 可选:轻量路由模型,用于技能意图路由;为 nil 则仅用关键词匹配 store *memory.SQLiteStore tools *tools.Registry soul string @@ -44,16 +67,10 @@ type pendingFileRef struct { MimeType string } -type filePromptContext struct { - Summary string - FatalReason string - FileIDs []string - Uploaded []pendingFileRef -} - // NewOrchestrator 创建一个新的编排器对象,初始化关键路径和超时控制等。 func NewOrchestrator( llmClient llm.Client, + routerLLM llm.Client, store *memory.SQLiteStore, registry *tools.Registry, soul string, @@ -81,6 +98,7 @@ func NewOrchestrator( } return &Orchestrator{ llm: llmClient, + routerLLM: routerLLM, store: store, tools: registry, soul: soul, @@ -103,52 +121,88 @@ func NewOrchestrator( // - 是否需要调用工具(action + action_input) // 循环持续进行,直到 LLM 返回 is_final_answer=true。 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) { - 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 链路。 -// 该方法会先把 file_id 注入当前会话上下文,然后调用常规 HandleMessage 流程。 -func (o *Orchestrator) HandleMessageWithFileIDs(ctx context.Context, chatID, userID, text string, fileIDs []string) (string, error) { - ids := nonEmptyIDs(fileIDs) - if len(ids) > 0 { - refs := make([]pendingFileRef, 0, len(ids)) - for _, id := range ids { - refs = append(refs, pendingFileRef{ID: id}) - } - o.appendPendingFiles(chatID, userID, refs) +// HandleMessageStream 接收用户消息并通过流式方式返回回复。 +// 通过 callback 推送实时事件,包括思考过程、工具调用、工具结果和最终答案。 +func (o *Orchestrator) HandleMessageStream(ctx context.Context, chatID, userID, text string, callback StreamEventCallback) (string, error) { + if callback == nil { + return "", fmt.Errorf("stream callback is required") } - return o.handleMessageInternal(ctx, chatID, userID, text, nil, true) + 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") + } + 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,供后续同会话文本问答复用。 -// 该方法不会写入 messages 表,仅更新内存中的 pending file 上下文。 func (o *Orchestrator) UploadAndCacheFiles(ctx context.Context, chatID, userID string, files []llm.InputFile) ([]string, error) { if len(files) == 0 { return nil, fmt.Errorf("no files provided") } - uploadCtx := o.prepareFilePromptContext(ctx, files, nil) - if strings.TrimSpace(uploadCtx.FatalReason) != "" { - return nil, fmt.Errorf(uploadCtx.FatalReason) + uploader, ok := o.llm.(llm.FileUploader) + if !ok { + return nil, fmt.Errorf("当前 LLM 客户端不支持文件上传接口") } - ids := nonEmptyIDs(uploadCtx.FileIDs) - if len(ids) == 0 { - return nil, fmt.Errorf("file upload completed but no valid file_id returned") + var ids []string + var refs []pendingFileRef + for i, f := range files { + if strings.TrimSpace(f.FileName) == "" || len(f.Content) == 0 { + return nil, fmt.Errorf("file[%d] 缺少文件名或内容", i+1) + } + 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, uploadCtx.toPendingRefs()) + o.appendPendingFiles(chatID, userID, refs) 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 := logger.NewTraceID() ctx = logger.WithTraceID(ctx, traceID) traceLogPrefix := "trace_id=" + traceID 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) } @@ -169,38 +223,6 @@ func (o *Orchestrator) handleMessageInternal(ctx context.Context, chatID, userID 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 中 if err := o.store.SaveMessage(chatID, userID, "user", text); err != nil { if o.log != nil { @@ -223,28 +245,13 @@ func (o *Orchestrator) handleMessageInternal(ctx context.Context, chatID, userID } // 进入统一 ReAct 循环 - pendingRefs := o.getPendingFiles(chatID, userID) - 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) + response, err := o.runUnifiedReAct(ctx, chatID, userID, compressed, text) if err != nil { if o.log != nil { o.log.Errorf("%s message generation failed chat_id=%s err=%v", traceLogPrefix, chatID, err) } return "", err } - if len(pendingRefs) > 0 { - o.clearPendingFiles(chatID, userID) - } // 最终将机器人的回复也加入记忆缓存 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 } +// 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。 // 工具定义通过 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() - relevantSkillsDoc := o.formatSelectedSkillsForPrompt(userInput, nil) + var relevantSkillsDoc string + if routedSkills != nil { + relevantSkillsDoc = o.formatSelectedSkillsForPrompt(userInput, routedSkills) + } else { + relevantSkillsDoc = o.formatSelectedSkillsForPrompt(userInput, nil) + } runtimeDoc := formatRuntimeContextForPrompt() return strings.Join([]string{ @@ -292,20 +377,151 @@ func (o *Orchestrator) buildUnifiedSystemPrompt(userInput string) string { "", "===== 本轮相关技能(按用户问题筛选) =====", relevantSkillsDoc, + "", + "===== 关键约束 =====", }, "\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。 // messages 数组随交互动态增长:system → history → user → assistant(tool_calls) → tool → ... // 循环持续到 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) 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 { o.log.Infof("%s unified react start", traceLogPrefix) + o.log.Debugf("%s system_prompt_len=%d", traceLogPrefix, len(systemPrompt)) } // 检查 LLM 客户端是否支持原生 tool_calls @@ -314,7 +530,7 @@ func (o *Orchestrator) runUnifiedReAct(ctx context.Context, chatID, userID, comp if o.log != nil { 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 数组 @@ -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, parseCompressedHistoryMessages(compressedContext)...) + messages = append(messages, parseCompressedHistoryMessages(compressedContext)...) // 加入当前用户消息 messages = append(messages, llm.PromptMessage{Role: "user", Content: userInput}) // 构建工具定义列表(通过 API tools 字段传递) 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++ { @@ -337,7 +560,7 @@ func (o *Orchestrator) runUnifiedReAct(ctx context.Context, chatID, userID, comp } // 调用 LLM(传入完整 messages + tools 定义) - completion, err := toolCallClient.GenerateWithTools(ctx, messages, toolDefs, fileCtx.FileIDs, appendFileIDText) + completion, err := toolCallClient.GenerateWithTools(ctx, messages, toolDefs) if err != nil { return "", err } @@ -391,7 +614,8 @@ func (o *Orchestrator) runUnifiedReAct(ctx context.Context, chatID, userID, comp } 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) @@ -410,6 +634,7 @@ func (o *Orchestrator) runUnifiedReAct(ctx context.Context, chatID, userID, comp if o.log != nil { 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{ @@ -426,8 +651,217 @@ func (o *Orchestrator) runUnifiedReAct(ctx context.Context, chatID, userID, comp 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 的降级方案。 -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) 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) } - messages := buildReActMessages(systemPrompt, compressedContext, userInput, fileCtx.Summary, scratchpad) - raw, err := o.generateWithOptionalFilesMessages(ctx, messages, fileCtx.FileIDs, appendFileIDText) + messages := buildReActMessages(systemPrompt, compressedContext, userInput, scratchpad) + raw, err := o.generateMessages(ctx, messages) if err != nil { return "", err } @@ -584,105 +1018,19 @@ func extractToolInput(arguments string) string { return arguments } -func (o *Orchestrator) prepareFilePromptContext(ctx context.Context, files []llm.InputFile, pending []pendingFileRef) filePromptContext { - 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 { - return client.GenerateMessages(ctx, messages) - } - systemPrompt, userPrompt := fallbackPromptsFromMessages(messages) - 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) +func (o *Orchestrator) generateMessages(ctx context.Context, messages []llm.PromptMessage) (string, error) { + if client, ok := o.llm.(llm.MessageChatClient); ok { + return client.GenerateMessages(ctx, messages) } systemPrompt, userPrompt := fallbackPromptsFromMessages(messages) - return client.GenerateWithFiles(ctx, systemPrompt, userPrompt, ids, appendFileIDText) + return o.llm.Generate(ctx, systemPrompt, userPrompt) } -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 = append(msgs, llm.PromptMessage{Role: "system", Content: systemPrompt}) 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) != "" { 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") } -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 { if len(ids) == 0 { return nil @@ -769,20 +1103,6 @@ func nonEmptyIDs(ids []string) []string { 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) { refs = sanitizePendingRefs(refs) if len(refs) == 0 { @@ -890,6 +1210,9 @@ func (o *Orchestrator) selectRelevantSkills(userInput string, maxCount int) []kn continue } 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 { @@ -910,6 +1233,13 @@ func (o *Orchestrator) selectRelevantSkills(userInput string, maxCount int) []kn for _, r := range ranked { 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 } @@ -1181,3 +1511,10 @@ func (o *Orchestrator) formatToolDoc() string { } return strings.TrimSpace(b.String()) } + +func truncateForLog(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + return s[:maxLen] + "...(truncated)" +} diff --git a/internal/agent/orchestrator_skill_selection_test.go b/internal/agent/orchestrator_skill_selection_test.go index 29cf1ec..6024dfa 100644 --- a/internal/agent/orchestrator_skill_selection_test.go +++ b/internal/agent/orchestrator_skill_selection_test.go @@ -46,3 +46,50 @@ func TestFormatRuntimeContextForPromptIncludesGOOS(t *testing.T) { 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)) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 25b1768..9f834c8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -29,6 +29,7 @@ type Config struct { LLM LLMConfig Security SecurityConfig WebSearch WebSearchConfig + Gitea GiteaConfig SQLitePath string } @@ -52,11 +53,11 @@ type WebUIConfig struct { } type LLMConfig struct { - BaseURL string - APIKey string - Model string - FileModel string - FilePromptMode string + BaseURL string + APIKey string + Model string + FileModel string + RouterModel string // 轻量路由模型,用于技能意图路由;为空则仅用关键词匹配 } type SecurityConfig struct { @@ -70,6 +71,13 @@ type WebSearchConfig struct { APIKey string } +type GiteaConfig struct { + BaseURL string // Gitea 实例地址 + Token string // Personal Access Token + Owner string // 仓库所有者 + Repo string // 仓库名称 +} + func Load() (Config, error) { agentWorkspaceDir := resolveAgentWorkspaceDir() if err := preloadEnvFiles(); err != nil { @@ -106,11 +114,11 @@ func Load() (Config, error) { MaxUploadBytes: int64(intFromEnv("WEBUI_MAX_UPLOAD_MB", 20)) * 1024 * 1024, }, LLM: LLMConfig{ - BaseURL: strings.TrimRight(defaultIfEmpty(os.Getenv("LLM_BASE_URL"), "https://api.openai.com/v1"), "/"), - APIKey: strings.TrimSpace(os.Getenv("LLM_API_KEY")), - 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")), + BaseURL: strings.TrimRight(defaultIfEmpty(os.Getenv("LLM_BASE_URL"), "https://api.openai.com/v1"), "/"), + APIKey: strings.TrimSpace(os.Getenv("LLM_API_KEY")), + Model: defaultIfEmpty(os.Getenv("LLM_MODEL"), "gpt-4o-mini"), + FileModel: defaultIfEmpty(os.Getenv("LLM_FILE_MODEL"), defaultIfEmpty(os.Getenv("LLM_MODEL"), "gpt-4o-mini")), + RouterModel: strings.TrimSpace(os.Getenv("LLM_ROUTER_MODEL")), }, SQLitePath: defaultIfEmpty(os.Getenv("SQLITE_PATH"), filepath.Join(defaultDataDir, "laodingbot.db")), 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")), 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)) @@ -178,9 +192,6 @@ func Load() (Config, error) { if cfg.LLM.APIKey == "" { 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.SkillsDir = resolvePathInWorkspace(cfg.SkillsDir, agentWorkspaceDir) @@ -417,14 +428,3 @@ func splitCSV(raw string) []string { } 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 -} diff --git a/internal/llm/client.go b/internal/llm/client.go index 63fb3df..4942bdb 100644 --- a/internal/llm/client.go +++ b/internal/llm/client.go @@ -33,21 +33,13 @@ type MessageChatClient interface { 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 { UploadFile(ctx context.Context, file InputFile, purpose string) (string, error) } // ToolCallChatClient 支持原生 function calling 的 LLM 客户端接口。 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 调用的工具函数定义。 @@ -89,11 +81,9 @@ type InputFile struct { } type OpenAICompatibleClient struct { - client openai.Client - model string - fileModel string - filePromptMode string - log *logger.Logger + client openai.Client + model string + log *logger.Logger } func NewOpenAICompatibleClient(cfg config.LLMConfig, log *logger.Logger) *OpenAICompatibleClient { @@ -105,11 +95,9 @@ func NewOpenAICompatibleClient(cfg config.LLMConfig, log *logger.Logger) *OpenAI opts = append(opts, option.WithBaseURL(cfg.BaseURL)) } return &OpenAICompatibleClient{ - client: openai.NewClient(opts...), - model: cfg.Model, - fileModel: cfg.FileModel, - filePromptMode: cfg.FilePromptMode, - log: log, + client: openai.NewClient(opts...), + model: cfg.Model, + log: log, } } @@ -118,38 +106,22 @@ func (c *OpenAICompatibleClient) Generate(ctx context.Context, systemPrompt, use {Role: "system", Content: systemPrompt}, {Role: "user", Content: userPrompt}, } - return c.generateWithMessagesInternal(ctx, messages, nil, false) -} - -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) + return c.generateWithMessagesInternal(ctx, messages) } func (c *OpenAICompatibleClient) GenerateMessages(ctx context.Context, messages []PromptMessage) (string, error) { - return c.generateWithMessagesInternal(ctx, messages, nil, false) -} - -func (c *OpenAICompatibleClient) GenerateMessagesWithFiles(ctx context.Context, messages []PromptMessage, fileIDs []string, appendFileIDText bool) (string, error) { - return c.generateWithMessagesInternal(ctx, messages, fileIDs, appendFileIDText) + return c.generateWithMessagesInternal(ctx, messages) } // 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 - 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) 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{ @@ -188,12 +160,8 @@ func (c *OpenAICompatibleClient) GenerateWithTools(ctx context.Context, messages }, 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 - ids := nonEmptyIDs(fileIDs) - if len(ids) > 0 && strings.TrimSpace(c.fileModel) != "" { - model = c.fileModel - } baseMessages := normalizePromptMessages(messages) if len(baseMessages) == 0 { @@ -202,10 +170,10 @@ func (c *OpenAICompatibleClient) generateWithMessagesInternal(ctx context.Contex systemLen, userLen := promptMessageLengths(baseMessages) 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{ Model: shared.ChatModel(model), @@ -234,10 +202,9 @@ func (c *OpenAICompatibleClient) generateWithMessagesInternal(ctx context.Contex return content, nil } -// buildSDKMessages 将 PromptMessage 列表转换为 openai SDK 的消息格式,并注入 file_id(如需要)。 -func buildSDKMessages(base []PromptMessage, fileIDs []string, mode string, appendFileIDText bool) []openai.ChatCompletionMessageParamUnion { - mode = strings.ToLower(strings.TrimSpace(mode)) - out := make([]openai.ChatCompletionMessageParamUnion, 0, len(base)+2) +// buildSDKMessages 将 PromptMessage 列表转换为 openai SDK 的消息格式。 +func buildSDKMessages(base []PromptMessage) []openai.ChatCompletionMessageParamUnion { + out := make([]openai.ChatCompletionMessageParamUnion, 0, len(base)) for _, m := range base { role := normalizeRole(m.Role) @@ -247,34 +214,6 @@ func buildSDKMessages(base []PromptMessage, fileIDs []string, mode string, appen 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 } @@ -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 列表。 func toSDKTools(tools []ToolDefinition) []openai.ChatCompletionToolParam { if len(tools) == 0 { @@ -397,6 +289,46 @@ func fromSDKToolCalls(sdkCalls []openai.ChatCompletionMessageToolCall) []ToolCal 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) { if strings.TrimSpace(file.FileName) == "" { return "", fmt.Errorf("empty file name") @@ -460,71 +392,3 @@ func appendIfMissing(items []string, value string) []string { } 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" -} diff --git a/internal/toolhost/runtime.go b/internal/toolhost/runtime.go index 4cac07b..d82d67a 100644 --- a/internal/toolhost/runtime.go +++ b/internal/toolhost/runtime.go @@ -8,8 +8,11 @@ import ( "laodingbot/internal/config" "laodingbot/internal/logger" "laodingbot/internal/tools" + "laodingbot/tools/filedoc" "laodingbot/tools/fileoperation" "laodingbot/tools/git" + "laodingbot/tools/giteaticket" + "laodingbot/tools/piplan" "laodingbot/tools/shell" "laodingbot/tools/websearch" ) @@ -20,6 +23,9 @@ func RunChild(ctx context.Context, cfg config.Config, log *logger.Logger) error var gitLog *logger.Logger var shellLog *logger.Logger var searchLog *logger.Logger + var fileDocLog *logger.Logger + var piPlanLog *logger.Logger + var giteaTicketLog *logger.Logger var serverLog *logger.Logger if log != nil { 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") shellLog = log.WithComponent("toolhost.shell") 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") } registry := tools.NewRegistry(registryLog) @@ -53,6 +62,27 @@ func RunChild(ctx context.Context, cfg config.Config, log *logger.Logger) error cfg.ToolOutputMaxChars, 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) if err := server.Serve(ctx, stdin(), stdout()); err != nil && ctx.Err() == nil { diff --git a/internal/transport/webui/bot.go b/internal/transport/webui/bot.go index c85aed7..39ea726 100644 --- a/internal/transport/webui/bot.go +++ b/internal/transport/webui/bot.go @@ -18,13 +18,33 @@ import ( ) type IncomingMessage struct { - ChatID string - UserID string - Text string - FileIDs []string + ChatID string + UserID string + Text 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 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 Bot struct { @@ -32,29 +52,25 @@ type Bot struct { maxUploadBytes int64 log *logger.Logger - chatHandler ChatHandler - uploadHandler UploadHandler - counter uint64 + chatHandler ChatHandler + streamChatHandler StreamChatHandler + uploadHandler UploadHandler + counter uint64 } type chatRequest struct { - Text string `json:"text"` - SessionID string `json:"session_id"` - UserID string `json:"user_id"` - FileIDs []string `json:"file_ids"` + Text string `json:"text"` + SessionID string `json:"session_id"` + UserID string `json:"user_id"` } func (r *chatRequest) UnmarshalJSON(data []byte) error { type rawChatRequest struct { - Text string `json:"text"` - SessionID string `json:"session_id"` - SessionIDCamel string `json:"sessionId"` - UserID string `json:"user_id"` - 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"` + Text string `json:"text"` + SessionID string `json:"session_id"` + SessionIDCamel string `json:"sessionId"` + UserID string `json:"user_id"` + UserIDCamel string `json:"userId"` } var raw rawChatRequest @@ -65,13 +81,6 @@ func (r *chatRequest) UnmarshalJSON(data []byte) error { r.Text = raw.Text r.SessionID = firstNonEmpty(raw.SessionID, raw.SessionIDCamel) 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 } @@ -109,7 +118,7 @@ func NewBot(cfg config.WebUIConfig, log *logger.Logger) (*Bot, error) { }, 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 { 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") } b.chatHandler = chatHandler + b.streamChatHandler = streamChatHandler b.uploadHandler = uploadHandler mux := http.NewServeMux() mux.HandleFunc("/api/chat", b.handleChat) + mux.HandleFunc("/api/chat/stream", b.handleChatStream) mux.HandleFunc("/api/upload", b.handleUpload) srv := &http.Server{ @@ -191,10 +202,9 @@ func (b *Bot) handleChat(w http.ResponseWriter, r *http.Request) { userID := b.resolveID(req.UserID, "user") reply, err := b.chatHandler(r.Context(), IncomingMessage{ - ChatID: sessionID, - UserID: userID, - Text: req.Text, - FileIDs: req.FileIDs, + ChatID: sessionID, + UserID: userID, + Text: req.Text, }) if err != 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 { + for _, v := range vals { if strings.TrimSpace(v) != "" { return v @@ -249,24 +230,82 @@ func firstNonEmpty(vals ...string) string { return "" } -func nonEmptyIDs(ids []string) []string { - if len(ids) == 0 { +func (b *Bot) handleChatStream(w http.ResponseWriter, r *http.Request) { + 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 } - out := make([]string, 0, len(ids)) - seen := map[string]struct{}{} - for _, id := range ids { - id = strings.TrimSpace(id) - if id == "" { - continue + + // 调用流式处理器 + reply, err := b.streamChatHandler(r.Context(), IncomingMessage{ + ChatID: sessionID, + UserID: userID, + 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{}{} - out = append(out, id) + data, _ := json.Marshal(errEvent) + 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) { diff --git a/internal/transport/webui/bot_test.go b/internal/transport/webui/bot_test.go index 42c5378..d70b2b2 100644 --- a/internal/transport/webui/bot_test.go +++ b/internal/transport/webui/bot_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "mime/multipart" "net/http" "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) { b := newTestBot(t, 1024*1024) 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) } } + +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) + } + }) +} diff --git a/skills/safe_pi_planning/skill.md b/skills/safe_pi_planning/skill.md new file mode 100644 index 0000000..6f44df0 --- /dev/null +++ b/skills/safe_pi_planning/skill.md @@ -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/", "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 列表供用户手动创建 | +| 用户对规划有异议 | 记录反馈,调整对应视角的推演,重新生成蓝图 | diff --git a/tools/filedoc/filedoc.go b/tools/filedoc/filedoc.go new file mode 100644 index 0000000..5df7602 --- /dev/null +++ b/tools/filedoc/filedoc.go @@ -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 "" +} diff --git a/tools/filedoc/filedoc_test.go b/tools/filedoc/filedoc_test.go new file mode 100644 index 0000000..cb85bbe --- /dev/null +++ b/tools/filedoc/filedoc_test.go @@ -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) + } +} diff --git a/tools/giteaticket/giteaticket.go b/tools/giteaticket/giteaticket.go new file mode 100644 index 0000000..cc88f8c --- /dev/null +++ b/tools/giteaticket/giteaticket.go @@ -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" + } +} diff --git a/tools/giteaticket/giteaticket_test.go b/tools/giteaticket/giteaticket_test.go new file mode 100644 index 0000000..7f3bccd --- /dev/null +++ b/tools/giteaticket/giteaticket_test.go @@ -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") + } +} diff --git a/tools/piplan/piplan.go b/tools/piplan/piplan.go new file mode 100644 index 0000000..030c0b2 --- /dev/null +++ b/tools/piplan/piplan.go @@ -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 +} diff --git a/tools/piplan/piplan_test.go b/tools/piplan/piplan_test.go new file mode 100644 index 0000000..7b1ff1b --- /dev/null +++ b/tools/piplan/piplan_test.go @@ -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)) + } +}