package job import ( "context" "fmt" "strings" app "haixun-backend/internal/library/errors" "haixun-backend/internal/library/errors/code" "haixun-backend/internal/library/style8d" libviral "haixun-backend/internal/library/viral" domai "haixun-backend/internal/model/ai/domain/usecase" aiusecase "haixun-backend/internal/model/ai/usecase" copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase" missiondomain "haixun-backend/internal/model/copy_mission/domain/usecase" jobdom "haixun-backend/internal/model/job/domain/usecase" personadomain "haixun-backend/internal/model/persona/domain/usecase" scanpostdomain "haixun-backend/internal/model/scan_post/domain/usecase" threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase" ) type GenerateCopyDraftDeps struct { Jobs jobdom.UseCase CopyMission missiondomain.UseCase Persona personadomain.UseCase ScanPost scanpostdomain.UseCase CopyDraft copydraftdomain.UseCase ThreadsAccount threadsaccountdomain.UseCase AI aiusecase.UseCase } func RegisterGenerateCopyDraftHandler(runner *Runner, deps GenerateCopyDraftDeps) { if runner == nil { return } runner.RegisterStepHandler("copy_draft_generate", func(ctx context.Context, step StepContext) error { return runGenerateCopyDraft(ctx, step, deps) }) } func runGenerateCopyDraft(ctx context.Context, step StepContext, deps GenerateCopyDraftDeps) error { payload := step.Run.Payload tenantID, ownerUID := runActorFromPayload(payload, step.Run) personaID := stringField(payload, "persona_id") missionID := copyMissionIDFromPayload(payload) scanPostID := strings.TrimSpace(stringField(payload, "scan_post_id")) if tenantID == "" || ownerUID == "" || personaID == "" || scanPostID == "" { return fmt.Errorf("generate-copy-draft payload missing tenant_id, owner_uid, persona_id, or scan_post_id") } updateProgress := func(summary string, percentage int) { _ = step.Heartbeat(ctx) _, _ = deps.Jobs.UpdateProgress(ctx, jobdom.UpdateProgressRequest{ JobID: step.JobID, WorkerID: step.WorkerID, Phase: "copy_draft_generate", Summary: summary, Percentage: percentage, }) } updateProgress("讀取爆款原文…", 12) persona, err := deps.Persona.Get(ctx, tenantID, ownerUID, personaID) if err != nil { return err } post, err := deps.ScanPost.GetForPersona(ctx, tenantID, ownerUID, personaID, scanPostID) if err != nil { return err } if missionID == "" { missionID = strings.TrimSpace(post.CopyMissionID) } topicLabel := strings.TrimSpace(post.SearchTag) topicBrief := strings.TrimSpace(persona.Brief) if missionID != "" { if mission, missionErr := deps.CopyMission.Get(ctx, tenantID, ownerUID, personaID, missionID); missionErr == nil { if label := strings.TrimSpace(mission.Label); label != "" { topicLabel = label } if brief := strings.TrimSpace(mission.Brief); brief != "" { topicBrief = brief } } } if !style8d.HasReady8D(persona.Persona, persona.StyleProfile) { return app.For(code.Persona).InputMissingRequired("請先完成人設 8D 對標分析") } personaBlock := style8d.ResolvePersonaBlock(persona.Persona, persona.StyleProfile, persona.Brief) credential, err := deps.ThreadsAccount.ResolveMemberAiCredential(ctx, tenantID, ownerUID) if err != nil { return err } providerID, err := aiusecase.MapWorkerProvider(credential.Provider) if err != nil { return err } updateProgress("分析爆款結構…", 35) analysisText := "" analyzeResult, analyzeErr := deps.AI.GenerateText(ctx, domai.GenerateRequest{ Provider: providerID, Model: credential.Model, Credential: domai.Credential{ APIKey: credential.APIKey, }, System: libviral.BuildAnalyzeViralSystemPrompt(), Messages: []domai.Message{ { Role: "user", Content: libviral.BuildAnalyzeViralUserPrompt(libviral.AnalyzeViralInput{ PostText: post.Text, AuthorName: post.Author, LikeCount: post.LikeCount, ReplyCount: post.ReplyCount, SearchTag: post.SearchTag, TopicLabel: topicLabel, TopicBrief: topicBrief, Persona: personaBlock, }), }, }, }) if analyzeErr == nil { if parsed, parseErr := libviral.ParseAnalyzeViralOutput(analyzeResult.Text); parseErr == nil { analysisText = libviral.FormatAnalysisForReplicate(parsed) } } updateProgress("深仿寫產文中…", 65) result, err := deps.AI.GenerateText(ctx, domai.GenerateRequest{ Provider: providerID, Model: credential.Model, Credential: domai.Credential{ APIKey: credential.APIKey, }, System: libviral.BuildSystemPrompt(), Messages: []domai.Message{ { Role: "user", Content: libviral.BuildUserPrompt(libviral.ReplicateInput{ TopicLabel: topicLabel, TopicBrief: topicBrief, Persona: personaBlock, StyleProfile: "", OriginalText: post.Text, AuthorName: post.Author, StructureAnalysis: analysisText, }), }, }, }) if err != nil { return err } parsed, err := libviral.ParseReplicateOutput(result.Text) if err != nil { return app.For(code.AI).SvcThirdParty("仿寫 LLM 回傳無法解析:" + err.Error()) } updateProgress("儲存仿寫草稿…", 90) saved, err := deps.CopyDraft.Create(ctx, copydraftdomain.CreateRequest{ TenantID: tenantID, OwnerUID: ownerUID, PersonaID: personaID, CopyMissionID: post.CopyMissionID, ScanPostID: scanPostID, DraftType: "replicate", Text: parsed.Text, Angle: parsed.Angle, Hook: parsed.Hook, Rationale: parsed.Rationale, ReferenceNotes: parsed.StructureNotes, Sources: []string{post.Permalink}, }) if err != nil { return err } nextRoute := "/matrix" if missionID != "" { nextRoute = fmt.Sprintf("/matrix/missions/%s#copy-output", missionID) } handoff := map[string]any{ "flow": "copy", "persona_id": personaID, "copy_mission_id": missionID, "scan_post_id": scanPostID, "summary": "已產出深仿寫草稿", "next_route": nextRoute, } _, err = deps.Jobs.CompleteRun(ctx, jobdom.CompleteRunRequest{ JobID: step.JobID, WorkerID: step.WorkerID, Result: map[string]any{ "draft_id": saved.ID, "handoff": handoff, }, }) return err }