Fix orchestrator logic and workspace push for planning confirmation and artifact handling
This commit is contained in:
@@ -381,9 +381,10 @@ func (o *Orchestrator) buildUnifiedSystemPrompt(userInput string, recentMessages
|
|||||||
if planningMode {
|
if planningMode {
|
||||||
planningModeDoc = strings.Join([]string{
|
planningModeDoc = strings.Join([]string{
|
||||||
"当前处于 PI 规划编辑模式。",
|
"当前处于 PI 规划编辑模式。",
|
||||||
"- 用户的修订意见必须基于现有 Artifact 继续迭代。",
|
"- 如果用户表达了确认意图(如 确认、开始创建工单、批量创建、没问题 等),你必须立即调用 create_gitea_ticket 工具,将 PI 蓝图中的 Feature 和 Enabler 逐一拆解为 User Story 并创建工单。严禁重复输出蓝图或再次征求确认。",
|
||||||
"- 需要继续调用 safe_pi_planning / publish_pi_plan 相关流程生成更新版本。",
|
"- 如果用户提出了修改意见,则基于现有 Artifact 继续迭代,调用 publish_pi_plan 生成更新版本。",
|
||||||
"- 不要仅给普通文本答复替代蓝图更新。",
|
"- 不要仅给普通文本答复替代蓝图更新或工单创建。必须通过工具调用来执行实际操作。",
|
||||||
|
"- 不要在文本中虚构工具调用结果。如果需要创建工单,必须实际调用 create_gitea_ticket 工具。",
|
||||||
}, "\n")
|
}, "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -905,38 +906,40 @@ func (o *Orchestrator) runUnifiedReActStream(ctx context.Context, chatID, userID
|
|||||||
if finalText == "" {
|
if finalText == "" {
|
||||||
finalText = "已完成处理。"
|
finalText = "已完成处理。"
|
||||||
}
|
}
|
||||||
if planningMode && !workspaceSentThisTurn {
|
if planningMode && !workspaceSentThisTurn && !isPlanningConfirmation(userInput) {
|
||||||
// 提取 artifact 标签内的文档内容作为工作区内容
|
// 仅当 LLM 输出包含 <artifact> 标签时,才提取其内容推送到工作区分屏
|
||||||
|
// 如果不包含 artifact 标签,说明是 LLM 推理中间步骤,不推送到工作区
|
||||||
workspaceContent := extractArtifactContent(finalText)
|
workspaceContent := extractArtifactContent(finalText)
|
||||||
if workspaceContent == "" {
|
if workspaceContent != "" {
|
||||||
workspaceContent = stripArtifactTags(finalText)
|
o.activatePlanningSession(chatID, userID, workspaceContent, true)
|
||||||
}
|
if err := o.store.SaveMessage(chatID, userID, "assistant", wrapPIArtifact(workspaceContent)); err != nil {
|
||||||
if workspaceContent == "" {
|
if o.log != nil {
|
||||||
workspaceContent = finalText
|
o.log.Warnf("%s save planning artifact failed chat_id=%s err=%v", traceLogPrefix, chatID, err)
|
||||||
}
|
}
|
||||||
o.activatePlanningSession(chatID, userID, workspaceContent, true)
|
|
||||||
if err := o.store.SaveMessage(chatID, userID, "assistant", wrapPIArtifact(workspaceContent)); err != nil {
|
|
||||||
if o.log != nil {
|
|
||||||
o.log.Warnf("%s save planning artifact failed chat_id=%s err=%v", traceLogPrefix, chatID, err)
|
|
||||||
}
|
}
|
||||||
|
if err := callback(StreamEvent{
|
||||||
|
Type: StreamEventTypeWorkspaceStart,
|
||||||
|
WorkspaceTitle: "PI Planning Document",
|
||||||
|
}); err != nil {
|
||||||
|
return "", fmt.Errorf("callback error: %w", err)
|
||||||
|
}
|
||||||
|
if err := callback(StreamEvent{
|
||||||
|
Type: StreamEventTypeWorkspaceDelta,
|
||||||
|
Content: workspaceContent,
|
||||||
|
}); err != nil {
|
||||||
|
return "", fmt.Errorf("callback error: %w", err)
|
||||||
|
}
|
||||||
|
if err := callback(StreamEvent{Type: StreamEventTypeWorkspaceEnd}); err != nil {
|
||||||
|
return "", fmt.Errorf("callback error: %w", err)
|
||||||
|
}
|
||||||
|
workspaceSentThisTurn = true
|
||||||
|
finalText = stripArtifactTags(finalText)
|
||||||
|
if finalText == "" {
|
||||||
|
finalText = "PI 规划已生成,请查看右侧工作区。如确认无误,请回复确认进入工单创建。"
|
||||||
|
}
|
||||||
|
} else if o.log != nil {
|
||||||
|
o.log.Infof("%s planning mode final answer has no artifact tags, skip workspace push", traceLogPrefix)
|
||||||
}
|
}
|
||||||
if err := callback(StreamEvent{
|
|
||||||
Type: StreamEventTypeWorkspaceStart,
|
|
||||||
WorkspaceTitle: "PI Planning Document",
|
|
||||||
}); err != nil {
|
|
||||||
return "", fmt.Errorf("callback error: %w", err)
|
|
||||||
}
|
|
||||||
if err := callback(StreamEvent{
|
|
||||||
Type: StreamEventTypeWorkspaceDelta,
|
|
||||||
Content: workspaceContent,
|
|
||||||
}); err != nil {
|
|
||||||
return "", fmt.Errorf("callback error: %w", err)
|
|
||||||
}
|
|
||||||
if err := callback(StreamEvent{Type: StreamEventTypeWorkspaceEnd}); err != nil {
|
|
||||||
return "", fmt.Errorf("callback error: %w", err)
|
|
||||||
}
|
|
||||||
workspaceSentThisTurn = true
|
|
||||||
finalText = "我已根据你的意见更新 PI 规划,请查看右侧工作区;如确认无误,请回复“确认”进入工单创建。"
|
|
||||||
} else if planningMode {
|
} else if planningMode {
|
||||||
// 工作区已在本轮通过工具调用发送,剥离 final 内容中的 artifact 标签
|
// 工作区已在本轮通过工具调用发送,剥离 final 内容中的 artifact 标签
|
||||||
finalText = stripArtifactTags(finalText)
|
finalText = stripArtifactTags(finalText)
|
||||||
@@ -1441,7 +1444,9 @@ func isPlanningConfirmation(userInput string) bool {
|
|||||||
}
|
}
|
||||||
markers := []string{
|
markers := []string{
|
||||||
"确认", "同意", "可以创建", "开始创建", "继续创建", "执行下一步", "没问题,继续",
|
"确认", "同意", "可以创建", "开始创建", "继续创建", "执行下一步", "没问题,继续",
|
||||||
"confirm", "approved", "go ahead", "proceed",
|
"创建工单", "批量创建", "下发工单", "同步到gitea", "同步到 gitea",
|
||||||
|
"没问题", "可以了", "没有问题", "没有异议", "无异议",
|
||||||
|
"confirm", "approved", "go ahead", "proceed", "create ticket", "create issue",
|
||||||
}
|
}
|
||||||
for _, marker := range markers {
|
for _, marker := range markers {
|
||||||
if strings.Contains(text, marker) {
|
if strings.Contains(text, marker) {
|
||||||
|
|||||||
@@ -152,8 +152,12 @@ func (c *OpenAICompatibleClient) GenerateWithTools(ctx context.Context, messages
|
|||||||
choice := resp.Choices[0]
|
choice := resp.Choices[0]
|
||||||
resultToolCalls := fromSDKToolCalls(choice.Message.ToolCalls)
|
resultToolCalls := fromSDKToolCalls(choice.Message.ToolCalls)
|
||||||
if c.log != nil {
|
if c.log != nil {
|
||||||
c.log.Infof("llm tool-call response success model=%s content_len=%d tool_calls=%d finish=%s",
|
toolNames := make([]string, 0, len(resultToolCalls))
|
||||||
model, len(choice.Message.Content), len(resultToolCalls), choice.FinishReason)
|
for _, tc := range resultToolCalls {
|
||||||
|
toolNames = append(toolNames, tc.Function.Name)
|
||||||
|
}
|
||||||
|
c.log.Infof("llm tool-call response success model=%s content_len=%d tool_calls=%d names=%v finish=%s",
|
||||||
|
model, len(choice.Message.Content), len(resultToolCalls), toolNames, choice.FinishReason)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &ChatCompletion{
|
return &ChatCompletion{
|
||||||
|
|||||||
@@ -144,6 +144,8 @@ description: 扮演 SAFe 铁三角(PM、架构师、RTE),将宏观 Epic
|
|||||||
|
|
||||||
输出蓝图后,征求用户反馈。如果用户没有异议,直接继续执行阶段 3。
|
输出蓝图后,征求用户反馈。如果用户没有异议,直接继续执行阶段 3。
|
||||||
|
|
||||||
|
**⚠️ 用户确认规则**:当用户回复表示确认(如"确认"、"开始创建工单"、"批量创建"、"没问题"、"可以了"等肯定性表达),**必须立即调用** `create_gitea_ticket` 工具开始创建工单,**严禁**重复输出蓝图或再次征求确认。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 阶段 3:任务下发到 Gitea
|
### 阶段 3:任务下发到 Gitea
|
||||||
|
|||||||
@@ -70,6 +70,10 @@ func New(cfg Config, log *logger.Logger) *Tool {
|
|||||||
if cfg.Timeout <= 0 {
|
if cfg.Timeout <= 0 {
|
||||||
cfg.Timeout = 30 * time.Second
|
cfg.Timeout = 30 * time.Second
|
||||||
}
|
}
|
||||||
|
if log != nil {
|
||||||
|
log.Infof("giteaticket tool initialized base_url=%q owner=%q repo=%q token_set=%v",
|
||||||
|
baseURL, strings.TrimSpace(cfg.Owner), strings.TrimSpace(cfg.Repo), strings.TrimSpace(cfg.Token) != "")
|
||||||
|
}
|
||||||
return &Tool{
|
return &Tool{
|
||||||
baseURL: baseURL,
|
baseURL: baseURL,
|
||||||
token: strings.TrimSpace(cfg.Token),
|
token: strings.TrimSpace(cfg.Token),
|
||||||
@@ -95,6 +99,10 @@ func (t *Tool) Call(ctx context.Context, input string) (string, error) {
|
|||||||
return "", fmt.Errorf("create_gitea_ticket: validation failed: %w", err)
|
return "", fmt.Errorf("create_gitea_ticket: validation failed: %w", err)
|
||||||
}
|
}
|
||||||
if t.baseURL == "" || t.token == "" || t.owner == "" || t.repo == "" {
|
if t.baseURL == "" || t.token == "" || t.owner == "" || t.repo == "" {
|
||||||
|
if t.log != nil {
|
||||||
|
t.log.Errorf("giteaticket config missing: base_url=%q token_set=%v owner=%q repo=%q",
|
||||||
|
t.baseURL, t.token != "", t.owner, t.repo)
|
||||||
|
}
|
||||||
return "", fmt.Errorf("create_gitea_ticket: missing Gitea configuration (base_url, token, owner, repo)")
|
return "", fmt.Errorf("create_gitea_ticket: missing Gitea configuration (base_url, token, owner, repo)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ import (
|
|||||||
|
|
||||||
// Config 定义了网络搜索工具所需的配置参数。
|
// Config 定义了网络搜索工具所需的配置参数。
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Engine string // 搜索引擎类型,支持 "duckduckgo" 或 "brave"
|
Engine string // 搜索引擎类型,支持 "duckduckgo"、"brave" 或 "tavily"
|
||||||
APIKey string // 搜索引擎的 API Key(Brave 搜索必填)
|
APIKey string // 搜索引擎的 API Key(Brave 或 Tavily 搜索必填)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tool represents a web search tool.
|
// Tool represents a web search tool.
|
||||||
@@ -85,6 +85,8 @@ func (t *Tool) Call(ctx context.Context, input string) (string, error) {
|
|||||||
switch t.engine {
|
switch t.engine {
|
||||||
case "brave":
|
case "brave":
|
||||||
result, err = t.searchBrave(ctx, query)
|
result, err = t.searchBrave(ctx, query)
|
||||||
|
case "tavily":
|
||||||
|
result, err = t.searchTavily(ctx, query)
|
||||||
default:
|
default:
|
||||||
result, err = t.searchDuckDuckGo(ctx, query)
|
result, err = t.searchDuckDuckGo(ctx, query)
|
||||||
}
|
}
|
||||||
@@ -126,12 +128,20 @@ func (t *Tool) searchDuckDuckGo(ctx context.Context, query string) (string, erro
|
|||||||
return "", fmt.Errorf("read response body failed: %w", err)
|
return "", fmt.Errorf("read response body failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if t.log != nil {
|
||||||
|
t.log.Debugf("duckduckgo raw response: %s", string(body))
|
||||||
|
}
|
||||||
|
|
||||||
var ddg duckDuckGoResponse
|
var ddg duckDuckGoResponse
|
||||||
if err := json.Unmarshal(body, &ddg); err != nil {
|
if err := json.Unmarshal(body, &ddg); err != nil {
|
||||||
return "", fmt.Errorf("parse duckduckgo response failed: %w", err)
|
return "", fmt.Errorf("parse duckduckgo response failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return t.formatDuckDuckGoResult(query, ddg), nil
|
result := t.formatDuckDuckGoResult(query, ddg)
|
||||||
|
if t.log != nil {
|
||||||
|
t.log.Infof("duckduckgo search finished, content_found=%v", (ddg.Answer != "" || ddg.AbstractText != "" || len(ddg.RelatedTopics) > 0))
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// duckDuckGoResponse 从 DuckDuckGo 获取的即时结果 JSON 映射结构。
|
// duckDuckGoResponse 从 DuckDuckGo 获取的即时结果 JSON 映射结构。
|
||||||
@@ -239,11 +249,19 @@ func (t *Tool) searchBrave(ctx context.Context, query string) (string, error) {
|
|||||||
return "", fmt.Errorf("read response body failed: %w", err)
|
return "", fmt.Errorf("read response body failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if t.log != nil {
|
||||||
|
t.log.Debugf("brave search raw response: %s", string(body))
|
||||||
|
}
|
||||||
|
|
||||||
var braveResp braveSearchResponse
|
var braveResp braveSearchResponse
|
||||||
if err := json.Unmarshal(body, &braveResp); err != nil {
|
if err := json.Unmarshal(body, &braveResp); err != nil {
|
||||||
return "", fmt.Errorf("parse brave response failed: %w", err)
|
return "", fmt.Errorf("parse brave response failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if t.log != nil {
|
||||||
|
t.log.Infof("brave search finished, results_count=%d", len(braveResp.Web.Results))
|
||||||
|
}
|
||||||
|
|
||||||
return t.formatBraveResult(query, braveResp), nil
|
return t.formatBraveResult(query, braveResp), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,3 +304,92 @@ func (t *Tool) formatBraveResult(query string, resp braveSearchResponse) string
|
|||||||
|
|
||||||
return strings.TrimSpace(b.String())
|
return strings.TrimSpace(b.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// searchTavily uses the Tavily Search API (requires API key).
|
||||||
|
func (t *Tool) searchTavily(ctx context.Context, query string) (string, error) {
|
||||||
|
if t.apiKey == "" {
|
||||||
|
return "", fmt.Errorf("WEB_SEARCH_API_KEY is required for Tavily engine")
|
||||||
|
}
|
||||||
|
|
||||||
|
apiURL := "https://api.tavily.com/search"
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"api_key": t.apiKey,
|
||||||
|
"query": query,
|
||||||
|
"search_depth": "basic",
|
||||||
|
"include_answer": true,
|
||||||
|
"include_images": false,
|
||||||
|
"include_raw_content": false,
|
||||||
|
"max_results": 5,
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonData, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("marshal tavily payload failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, strings.NewReader(string(jsonData)))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("create request failed: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := t.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("http request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
bodySnippet, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
||||||
|
return "", fmt.Errorf("tavily search returned status %d: %s", resp.StatusCode, string(bodySnippet))
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 512*1024))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("read response body failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if t.log != nil {
|
||||||
|
t.log.Debugf("tavily search raw response: %s", string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var tavilyResp tavilyResponse
|
||||||
|
if err := json.Unmarshal(body, &tavilyResp); err != nil {
|
||||||
|
return "", fmt.Errorf("parse tavily response failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return t.formatTavilyResult(query, tavilyResp), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type tavilyResponse struct {
|
||||||
|
Answer string `json:"answer"`
|
||||||
|
Results []tavilyResult `json:"results"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type tavilyResult struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Score float64 `json:"score"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Tool) formatTavilyResult(query string, resp tavilyResponse) string {
|
||||||
|
b := strings.Builder{}
|
||||||
|
b.WriteString("Search: " + query + "\n")
|
||||||
|
b.WriteString("Engine: Tavily\n\n")
|
||||||
|
|
||||||
|
if resp.Answer != "" {
|
||||||
|
b.WriteString("Answer: " + resp.Answer + "\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(resp.Results) == 0 && resp.Answer == "" {
|
||||||
|
b.WriteString("No results found.\n")
|
||||||
|
return strings.TrimSpace(b.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, r := range resp.Results {
|
||||||
|
b.WriteString(fmt.Sprintf("%d. %s\n %s\n %s\n\n", i+1, r.Title, r.URL, r.Content))
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.TrimSpace(b.String())
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,62 @@
|
|||||||
package websearch
|
package websearch
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestTavilyIntegration(t *testing.T) {
|
||||||
|
// 这是一个集成测试,会实际请求网络。
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("skipping integration test in short mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 这里的 API Key 从环境变量中获取或手动填写的测试环境变量中读取。
|
||||||
|
apiKey := "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"
|
||||||
|
if apiKey == "" {
|
||||||
|
t.Skip("TAVILY_API_KEY is not set")
|
||||||
|
}
|
||||||
|
|
||||||
|
tool := New(Config{Engine: "tavily", APIKey: apiKey}, 4000, nil)
|
||||||
|
ctx := context.Background()
|
||||||
|
query := "wuhan weather today"
|
||||||
|
|
||||||
|
result, err := tool.Call(ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Tavily search failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Tavily search result for '%s':\n%s\n", query, result)
|
||||||
|
|
||||||
|
if result == "" {
|
||||||
|
t.Fatal("expected non-empty result from Tavily")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDuckDuckGoIntegration(t *testing.T) {
|
||||||
|
// 这是一个集成测试,会实际请求网络。
|
||||||
|
// 在 CI 环境中可能需要跳过。
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("skipping integration test in short mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
tool := New(Config{Engine: "duckduckgo"}, 4000, nil)
|
||||||
|
ctx := context.Background()
|
||||||
|
query := "wuhan weather"
|
||||||
|
|
||||||
|
result, err := tool.Call(ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DuckDuckGo search failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("DuckDuckGo search result for '%s':\n%s\n", query, result)
|
||||||
|
|
||||||
|
if result == "" {
|
||||||
|
t.Fatal("expected non-empty result from DuckDuckGo")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNewDefaultEngine(t *testing.T) {
|
func TestNewDefaultEngine(t *testing.T) {
|
||||||
tool := New(Config{}, 4000, nil)
|
tool := New(Config{}, 4000, nil)
|
||||||
if tool.Name() != "web_search" {
|
if tool.Name() != "web_search" {
|
||||||
|
|||||||
Reference in New Issue
Block a user