haixunMaster/haixun-backend/internal/worker/job/generate_copy_matrix.go

246 lines
7.3 KiB
Go
Raw Normal View History

2026-06-25 08:20:03 +00:00
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
}
}