diff --git a/backend/internal/library/viral/mission_research_map.go b/backend/internal/library/viral/mission_research_map.go index 0826115..d03ebb2 100644 --- a/backend/internal/library/viral/mission_research_map.go +++ b/backend/internal/library/viral/mission_research_map.go @@ -75,6 +75,14 @@ func BuildMissionResearchMapUserPrompt(in CopyResearchMapInput) string { return b.String() } +func MissionResearchMapJSONOnlyRetryPrompt() string { + return strings.TrimSpace(`上次回覆沒有包含可解析的 JSON。請**只**輸出一個 JSON 物件: +- 直接以 { 開頭、以 } 結尾,不要任何思考過程、說明文字或 markdown 圍欄 +- 需含 audienceSummary、contentGoal、questions、pillars、exclusions、suggestedTags、benchmarkNotes 欄位 +- suggestedTags 至少 4 項,每項含 tag/reason/searchIntent/searchType +- 不要使用 ... 省略,也不要加註解或尾端逗號`) +} + func ParseMissionResearchMapOutput(raw string) (MissionResearchMap, error) { payload, err := extractCopyMapJSON(raw) if err != nil { diff --git a/backend/internal/worker/job/analyze_copy_mission.go b/backend/internal/worker/job/analyze_copy_mission.go index a264901..cf94d4b 100644 --- a/backend/internal/worker/job/analyze_copy_mission.go +++ b/backend/internal/worker/job/analyze_copy_mission.go @@ -78,37 +78,59 @@ func runAnalyzeCopyMission(ctx context.Context, step StepContext, deps AnalyzeCo return err } - result, err := deps.AI.GenerateText(ctx, domai.GenerateRequest{ + systemPrompt := libviral.BuildMissionResearchMapSystemPrompt() + userPrompt := libviral.BuildMissionResearchMapUserPrompt(libviral.CopyResearchMapInput{ + Label: mission.Label, + SeedQuery: mission.SeedQuery, + Brief: mission.Brief, + Persona: persona.Persona, + StyleBenchmark: persona.StyleBenchmark, + PersonaAudienceSummary: persona.CopyResearchMap.AudienceSummary, + PersonaContentGoal: persona.CopyResearchMap.ContentGoal, + PersonaQuestions: append([]string(nil), persona.CopyResearchMap.Questions...), + PersonaPillars: append([]string(nil), persona.CopyResearchMap.Pillars...), + }) + // ResearchGenerateRequest raises max_tokens so the full research map JSON is + // not truncated mid-object (truncation is the main cause of parse failures). + result, err := deps.AI.GenerateText(ctx, placement.ResearchGenerateRequest(domai.GenerateRequest{ Provider: providerID, Model: credential.Model, Credential: domai.Credential{ APIKey: credential.APIKey, }, - System: libviral.BuildMissionResearchMapSystemPrompt(), + System: systemPrompt, Messages: []domai.Message{ - { - Role: "user", - Content: libviral.BuildMissionResearchMapUserPrompt(libviral.CopyResearchMapInput{ - Label: mission.Label, - SeedQuery: mission.SeedQuery, - Brief: mission.Brief, - Persona: persona.Persona, - StyleBenchmark: persona.StyleBenchmark, - PersonaAudienceSummary: persona.CopyResearchMap.AudienceSummary, - PersonaContentGoal: persona.CopyResearchMap.ContentGoal, - PersonaQuestions: append([]string(nil), persona.CopyResearchMap.Questions...), - PersonaPillars: append([]string(nil), persona.CopyResearchMap.Pillars...), - }), - }, + {Role: "user", Content: userPrompt}, }, - }) + })) if err != nil { return err } parsed, err := libviral.ParseMissionResearchMapOutput(result.Text) if err != nil { - return app.For(code.AI).SvcThirdParty("拷貝任務研究地圖 LLM 回傳無法解析:" + 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: systemPrompt, + Messages: []domai.Message{ + {Role: "user", Content: userPrompt}, + {Role: "assistant", Content: result.Text}, + {Role: "user", Content: libviral.MissionResearchMapJSONOnlyRetryPrompt()}, + }, + })) + if retryErr != nil { + return app.For(code.AI).SvcThirdParty("拷貝任務研究地圖 LLM 回傳無法解析:" + err.Error()) + } + parsed, err = libviral.ParseMissionResearchMapOutput(jsonOnlyResult.Text) + if err != nil { + return app.For(code.AI).SvcThirdParty("拷貝任務研究地圖 LLM 回傳無法解析:" + err.Error()) + } } entityTags := make([]missionentity.SuggestedTag, 0, len(parsed.SuggestedTags)) diff --git a/backend/internal/worker/job/expand_copy_mission_graph.go b/backend/internal/worker/job/expand_copy_mission_graph.go index 3470b6e..1da4840 100644 --- a/backend/internal/worker/job/expand_copy_mission_graph.go +++ b/backend/internal/worker/job/expand_copy_mission_graph.go @@ -135,11 +135,11 @@ func runExpandCopyMissionGraph(ctx context.Context, step StepContext, deps Expan return app.For(code.AI).SysInternal("knowledge graph llm prompt load failed") } kgVars := map[string]string{ - "seed": seed, - "product_brief_line": libkg.OptionalPromptLine("任務補充", mission.Brief), - "target_audience_line": libkg.OptionalPromptLine("目標受眾", mission.ResearchMap.AudienceSummary), - "persona_line": libkg.OptionalPromptLine("人設", persona.Persona), - "research_pillars_line": libkg.BulletPromptLine("內容支柱", mission.ResearchMap.Pillars), + "seed": seed, + "product_brief_line": libkg.OptionalPromptLine("任務補充", mission.Brief), + "target_audience_line": libkg.OptionalPromptLine("目標受眾", mission.ResearchMap.AudienceSummary), + "persona_line": libkg.OptionalPromptLine("人設", persona.Persona), + "research_pillars_line": libkg.BulletPromptLine("內容支柱", mission.ResearchMap.Pillars), "research_questions_line": libkg.BulletPromptLine("受眾提問", mission.ResearchMap.Questions), } userPrompt, err = libprompt.KnowledgeGraphLLMUser(kgVars) @@ -195,12 +195,37 @@ func runExpandCopyMissionGraph(ctx context.Context, step StepContext, deps Expan return err } - graph, err := libkg.ParseSynthOutput(result.Text, libkg.SynthInput{ + synthIn := libkg.SynthInput{ Seed: seed, TargetAudience: mission.ResearchMap.AudienceSummary, - }, braveSources) + } + graph, err := libkg.ParseSynthOutput(result.Text, synthIn, braveSources) if err != nil { - return app.For(code.AI).SvcThirdParty("延伸知識產生失敗,請重試:" + err.Error()) + // 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{ @@ -217,10 +242,7 @@ func runExpandCopyMissionGraph(ctx context.Context, step StepContext, deps Expan }, }) if retryResult, retryErr := deps.AI.GenerateText(ctx, retryReq); retryErr == nil { - if retryGraph, parseErr := libkg.ParseSynthOutput(retryResult.Text, libkg.SynthInput{ - Seed: seed, - TargetAudience: mission.ResearchMap.AudienceSummary, - }, braveSources); parseErr == nil && !libkg.GraphTooThin(retryGraph) { + if retryGraph, parseErr := libkg.ParseSynthOutput(retryResult.Text, synthIn, braveSources); parseErr == nil && !libkg.GraphTooThin(retryGraph) { graph = retryGraph } } @@ -362,4 +384,4 @@ func syncCopyMissionKnowledgeFromGraph( }, }) return err -} \ No newline at end of file +}