package job import ( "context" "fmt" app "haixun-backend/internal/library/errors" "haixun-backend/internal/library/errors/code" libmatrix "haixun-backend/internal/library/matrix" libprompt "haixun-backend/internal/library/prompt" "haixun-backend/internal/library/style8d" domai "haixun-backend/internal/model/ai/domain/usecase" aiusecase "haixun-backend/internal/model/ai/usecase" copydraftentity "haixun-backend/internal/model/copy_draft/domain/entity" copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase" missionentity "haixun-backend/internal/model/copy_mission/domain/entity" 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 GenerateCopyMatrixDeps struct { Jobs jobdom.UseCase CopyMission missiondomain.UseCase Persona personadomain.UseCase ScanPost scanpostdomain.UseCase CopyDraft copydraftdomain.UseCase ThreadsAccount threadsaccountdomain.UseCase AI aiusecase.UseCase } func RegisterGenerateCopyMatrixHandler(runner *Runner, deps GenerateCopyMatrixDeps) { if runner == nil { return } runner.RegisterStepHandler("copy_matrix_generate", func(ctx context.Context, step StepContext) error { return runGenerateCopyMatrix(ctx, step, deps) }) } func runGenerateCopyMatrix(ctx context.Context, step StepContext, deps GenerateCopyMatrixDeps) error { payload := step.Run.Payload tenantID, ownerUID := runActorFromPayload(payload, step.Run) personaID := stringField(payload, "persona_id") missionID := copyMissionIDFromPayload(payload) if tenantID == "" || ownerUID == "" || personaID == "" || missionID == "" { return fmt.Errorf("generate-copy-matrix payload missing tenant_id, owner_uid, persona_id, or copy_mission_id") } mission, err := deps.CopyMission.Get(ctx, tenantID, ownerUID, personaID, missionID) if err != nil { return err } if mission.Status != string(missionentity.StatusScanned) && mission.Status != string(missionentity.StatusDrafted) { return app.For(code.Persona).ResInvalidState("請先完成海巡再產出內容矩陣") } if len(mission.SelectedTags) == 0 { return app.For(code.Persona).InputMissingRequired("請先選擇海巡標籤") } persona, err := deps.Persona.Get(ctx, tenantID, ownerUID, personaID) if err != nil { return err } if !style8d.HasReady8D(persona.Persona, persona.StyleProfile) { return app.For(code.Persona).InputMissingRequired("請先完成人設 8D 對標分析") } personaBlock := style8d.ResolvePersonaBlock(persona.Persona, persona.StyleProfile, persona.Brief) count := intField(payload, "count") if count <= 0 { count = 5 } if count > 12 { count = 12 } updateProgress := func(summary string, percentage int) { _ = step.Heartbeat(ctx) _, _ = deps.Jobs.UpdateProgress(ctx, jobdom.UpdateProgressRequest{ JobID: step.JobID, WorkerID: step.WorkerID, Phase: "copy_matrix_generate", Summary: summary, Percentage: percentage, }) } updateProgress("讀取爆款樣本…", 15) posts, err := deps.ScanPost.ListForPersona(ctx, scanpostdomain.PersonaListRequest{ TenantID: tenantID, OwnerUID: ownerUID, PersonaID: personaID, CopyMissionID: missionID, Limit: 12, }) if err != nil { return err } samples := matrixSamplesFromPosts(posts) researchBlock := libmatrix.FormatCopyResearchMapBlock( mission.ResearchMap.AudienceSummary, mission.ResearchMap.ContentGoal, mission.ResearchMap.Questions, mission.ResearchMap.Pillars, mission.ResearchMap.Exclusions, ) userPrompt, err := libmatrix.BuildCopyUserPrompt(libmatrix.CopyGenerateInput{ Count: count, TopicLabel: mission.Label, TopicBrief: mission.Brief, ResearchMap: researchBlock, SelectedTags: mission.SelectedTags, ViralSamples: samples, PersonaBlock: personaBlock, }) if err != nil { return app.For(code.AI).SysInternal("matrix user prompt load failed") } systemPrompt, err := libprompt.MatrixCopySystem() if err != nil { return app.For(code.AI).SysInternal("matrix system prompt load failed") } updateProgress("產出內容矩陣草稿…", 45) credential, err := deps.ThreadsAccount.ResolveMemberAiCredential(ctx, tenantID, ownerUID) if err != nil { return err } providerID, err := aiusecase.MapWorkerProvider(credential.Provider) if err != nil { return err } result, err := deps.AI.GenerateText(ctx, domai.GenerateRequest{ Provider: providerID, Model: credential.Model, Credential: domai.Credential{ APIKey: credential.APIKey, }, System: systemPrompt, Messages: []domai.Message{ {Role: "user", Content: userPrompt}, }, }) if err != nil { return err } parsed, err := libmatrix.ParseGenerateOutput(result.Text) if err != nil { return app.For(code.AI).SvcThirdParty("內容矩陣 LLM 回傳無法解析:" + err.Error()) } updateProgress("儲存矩陣草稿…", 88) createReqs := make([]copydraftdomain.CreateRequest, 0, len(parsed.Rows)) for _, row := range parsed.Rows { createReqs = append(createReqs, copydraftdomain.CreateRequest{ CopyMissionID: missionID, DraftType: copydraftentity.DraftTypeMatrix, SortOrder: row.SortOrder, Text: row.Text, Angle: row.Angle, Hook: row.Hook, Rationale: row.Rationale, ReferenceNotes: row.ReferenceNotes, Sources: row.SourcePermalinks, }) } saved, err := deps.CopyDraft.ReplaceMissionMatrix(ctx, tenantID, ownerUID, personaID, missionID, createReqs) if err != nil { return err } drafted := missionentity.StatusDrafted _, _ = deps.CopyMission.Update(ctx, missiondomain.UpdateRequest{ TenantID: tenantID, OwnerUID: ownerUID, PersonaID: personaID, MissionID: missionID, Patch: missiondomain.MissionPatch{ Status: &drafted, }, }) handoff := map[string]any{ "flow": "copy", "persona_id": personaID, "copy_mission_id": missionID, "summary": fmt.Sprintf("已產出 %d 篇矩陣草稿", len(saved)), "next_route": fmt.Sprintf("/matrix/missions/%s#copy-output", missionID), } _, err = deps.Jobs.CompleteRun(ctx, jobdom.CompleteRunRequest{ JobID: step.JobID, WorkerID: step.WorkerID, Result: map[string]any{ "draft_count": len(saved), "handoff": handoff, }, }) return err } func matrixSamplesFromPosts(posts []scanpostdomain.ScanPostSummary) string { samples := make([]libmatrix.ViralPostSample, 0, len(posts)) for _, post := range posts { replies := make([]libmatrix.ViralReplySample, 0, len(post.Replies)) for _, reply := range post.Replies { replies = append(replies, libmatrix.ViralReplySample{ Author: reply.Author, Text: reply.Text, }) } samples = append(samples, libmatrix.ViralPostSample{ Author: post.Author, LikeCount: post.LikeCount, SearchTag: post.SearchTag, Text: post.Text, Replies: replies, }) } return libmatrix.FormatViralSamples(samples) } func intField(payload map[string]any, key string) int { if payload == nil { return 0 } switch v := payload[key].(type) { case int: return v case int32: return int(v) case int64: return int(v) case float64: return int(v) default: return 0 } }