diff --git a/backend/internal/library/knowledge/quality.go b/backend/internal/library/knowledge/quality.go index 23a6150..e028a82 100644 --- a/backend/internal/library/knowledge/quality.go +++ b/backend/internal/library/knowledge/quality.go @@ -45,6 +45,13 @@ func MinBreadthGraphNodes() int { return minBreadthNodes } +func KnowledgeGraphJSONOnlyRetryPrompt() string { + return strings.TrimSpace(`上次回覆沒有包含可解析的 JSON。請**只**輸出一個 JSON 物件: +- 直接以 { 開頭、以 } 結尾,不要任何思考過程、說明文字或 markdown 圍欄 +- 結構需含 nodes 與 edges 陣列,欄位齊全 +- 不要使用 ... 省略,也不要加註解或尾端逗號`) +} + func KnowledgeGraphRetryUserPrompt() string { return strings.TrimSpace(`上次產出過於簡略或節點不足。請重新產出完整 TKG JSON: - 節點總數 **24~32 個**,L1≥8、L2≥12(廣度優先,多方向觸及) diff --git a/backend/internal/library/placement/research_map.go b/backend/internal/library/placement/research_map.go index c2cff1d..fbc258b 100644 --- a/backend/internal/library/placement/research_map.go +++ b/backend/internal/library/placement/research_map.go @@ -206,6 +206,13 @@ func BuildResearchMapUserPrompt(in ResearchMapInput) string { return BuildResearchMapFinalizeUserPrompt(in, "") } +func ResearchMapJSONOnlyRetryPrompt() string { + return strings.TrimSpace(`上次回覆沒有包含可解析的 JSON。請**只**輸出一個 JSON 物件: +- 直接以 { 開頭、以 } 結尾,不要任何思考過程、說明文字或 markdown 圍欄 +- 需含 audienceSummary、contentGoal、questions、pillars、exclusions、patrolKeywords 欄位 +- 不要使用 ... 省略,也不要加註解或尾端逗號`) +} + func ResearchMapRetryUserPrompt() string { return strings.TrimSpace(`上次產出過於簡略或項目不足。請重新產出完整 JSON,密度對齊系統 prompt 優秀範例: - 必須緊扣【輸入資料】中的品牌、產品與主題目標,不可照搬範例用字 diff --git a/backend/internal/worker/job/expand_graph.go b/backend/internal/worker/job/expand_graph.go index 4ea3d4b..d6ec2da 100644 --- a/backend/internal/worker/job/expand_graph.go +++ b/backend/internal/worker/job/expand_graph.go @@ -265,13 +265,38 @@ func runExpandGraph(ctx context.Context, step StepContext, deps ExpandGraphDeps) return err } - graph, err := libkg.ParseSynthOutput(result.Text, libkg.SynthInput{ + synthIn := libkg.SynthInput{ Seed: seed, ProductBrief: productBrief, TargetAudience: brand.TargetAudience, - }, braveSources) + } + graph, err := libkg.ParseSynthOutput(result.Text, synthIn, braveSources) if err != nil { - return app.For(code.AI).SvcThirdParty("延伸知識產生失敗,請重試:" + err.Error()) + // The model occasionally replies with reasoning/prose and no JSON + // object. Retry once, explicitly demanding a raw JSON object. + jsonOnlyReq := placement.ResearchGenerateRequest(domai.GenerateRequest{ + Provider: providerID, + Model: credential.Model, + Credential: domai.Credential{ + APIKey: credential.APIKey, + }, + System: systemPrompt, + Messages: []domai.Message{ + {Role: "user", Content: userPrompt}, + {Role: "assistant", Content: result.Text}, + {Role: "user", Content: libkg.KnowledgeGraphJSONOnlyRetryPrompt()}, + }, + }) + retryResult, retryErr := deps.AI.GenerateText(ctx, jsonOnlyReq) + if retryErr != nil { + return app.For(code.AI).SvcThirdParty("延伸知識產生失敗,請重試:" + err.Error()) + } + retryGraph, retryParseErr := libkg.ParseSynthOutput(retryResult.Text, synthIn, braveSources) + if retryParseErr != nil { + return app.For(code.AI).SvcThirdParty("延伸知識產生失敗,請重試:" + retryParseErr.Error()) + } + graph = retryGraph + result = retryResult } if libkg.GraphTooThin(graph) { retryReq := placement.ResearchGenerateRequest(domai.GenerateRequest{ @@ -288,11 +313,7 @@ func runExpandGraph(ctx context.Context, step StepContext, deps ExpandGraphDeps) }, }) if retryResult, retryErr := deps.AI.GenerateText(ctx, retryReq); retryErr == nil { - if retryGraph, parseErr := libkg.ParseSynthOutput(retryResult.Text, libkg.SynthInput{ - Seed: seed, - ProductBrief: productBrief, - TargetAudience: brand.TargetAudience, - }, braveSources); parseErr == nil && !libkg.GraphTooThin(retryGraph) { + if retryGraph, parseErr := libkg.ParseSynthOutput(retryResult.Text, synthIn, braveSources); parseErr == nil && !libkg.GraphTooThin(retryGraph) { graph = retryGraph } } @@ -724,7 +745,29 @@ func ensureResearchMap( parsed, err := placement.ParseResearchMapOutput(result.Text) if err != nil { - return app.For(code.AI).SvcThirdParty("研究地圖產生失敗,請重試:" + err.Error()) + // Model sometimes replies with reasoning/prose and no JSON object. + // Retry once, explicitly demanding a raw JSON object. + jsonOnlyResult, retryErr := deps.AI.GenerateText(ctx, placement.ResearchGenerateRequest(domai.GenerateRequest{ + Provider: providerID, + Model: credential.Model, + Credential: domai.Credential{ + APIKey: credential.APIKey, + }, + System: placement.BuildResearchMapSystemPrompt(), + Messages: []domai.Message{ + {Role: "user", Content: finalPrompt}, + {Role: "assistant", Content: result.Text}, + {Role: "user", Content: placement.ResearchMapJSONOnlyRetryPrompt()}, + }, + })) + if retryErr != nil { + return app.For(code.AI).SvcThirdParty("研究地圖產生失敗,請重試:" + err.Error()) + } + parsed, err = placement.ParseResearchMapOutput(jsonOnlyResult.Text) + if err != nil { + return app.For(code.AI).SvcThirdParty("研究地圖產生失敗,請重試:" + err.Error()) + } + result = jsonOnlyResult } if placement.ResearchMapTooThin(parsed) { if retryResult, retryErr := deps.AI.GenerateText(ctx, placement.ResearchGenerateRequest(domai.GenerateRequest{