211 lines
6.2 KiB
Go
211 lines
6.2 KiB
Go
|
|
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
|
||
|
|
}
|