291 lines
9.1 KiB
Go
291 lines
9.1 KiB
Go
package job
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
|
|
app "haixun-backend/internal/library/errors"
|
|
"haixun-backend/internal/library/errors/code"
|
|
libcopy "haixun-backend/internal/library/copymission"
|
|
libkg "haixun-backend/internal/library/knowledge"
|
|
libmatrix "haixun-backend/internal/library/matrix"
|
|
libweb "haixun-backend/internal/library/webpage"
|
|
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"
|
|
kgusecase "haixun-backend/internal/model/knowledge_graph/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
|
|
KnowledgeGraph kgusecase.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.StatusMapped) &&
|
|
mission.Status != string(missionentity.StatusScanned) &&
|
|
mission.Status != string(missionentity.StatusDrafted) {
|
|
return app.For(code.Persona).ResInvalidState("請先完成研究地圖再產出內容矩陣")
|
|
}
|
|
if strings.TrimSpace(mission.ResearchMap.AudienceSummary) == "" {
|
|
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)
|
|
graphNodes, graphSources := loadCopyMissionGraph(ctx, deps, tenantID, ownerUID, missionID)
|
|
knowledgeItems := libcopy.MatrixKnowledgeItems(
|
|
mission.ResearchMap.SelectedKnowledgeItems,
|
|
mission.ResearchMap.KnowledgeItems,
|
|
graphNodes,
|
|
mission.SelectedTags,
|
|
)
|
|
researchSources := libcopy.ResearchSourcesFromSummaries(mission.ResearchMap.ResearchItems)
|
|
refURLs := libcopy.MergeReferenceURLs(
|
|
libcopy.ReferenceURLsFromSelection(graphNodes, graphSources),
|
|
researchSources,
|
|
)
|
|
researchItemsBlock := libcopy.FormatResearchItemsForMatrix(researchSources)
|
|
var referenceMarkdown string
|
|
if len(refURLs) > 0 {
|
|
updateProgress(fmt.Sprintf("抓取 %d 個參考網頁並轉 Markdown…", len(refURLs)), 32)
|
|
digests := libweb.FetchDigests(ctx, refURLs, libweb.FetchOptions{})
|
|
referenceMarkdown = libmatrix.BuildReferenceMarkdown(digests)
|
|
}
|
|
researchBlock := libmatrix.FormatCopyResearchMapBlockWithReferences(
|
|
mission.ResearchMap.AudienceSummary,
|
|
mission.ResearchMap.ContentGoal,
|
|
mission.ResearchMap.Questions,
|
|
mission.ResearchMap.Pillars,
|
|
mission.ResearchMap.Exclusions,
|
|
knowledgeItems,
|
|
researchItemsBlock,
|
|
referenceMarkdown,
|
|
)
|
|
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")
|
|
}
|
|
|
|
matrixSummary := "產出內容矩陣草稿…"
|
|
if len(knowledgeItems) > 0 {
|
|
matrixSummary = fmt.Sprintf("依 %d 項延伸知識產出矩陣…", len(knowledgeItems))
|
|
}
|
|
updateProgress(matrixSummary, 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
|
|
}
|
|
parsed, err := libmatrix.GenerateCopyOutput(ctx, deps.AI, domai.GenerateRequest{
|
|
Provider: providerID,
|
|
Model: credential.Model,
|
|
Credential: domai.Credential{
|
|
APIKey: credential.APIKey,
|
|
},
|
|
System: systemPrompt,
|
|
Messages: []domai.Message{
|
|
{Role: "user", Content: userPrompt},
|
|
},
|
|
}, count)
|
|
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 loadCopyMissionGraph(
|
|
ctx context.Context,
|
|
deps GenerateCopyMatrixDeps,
|
|
tenantID, ownerUID, missionID string,
|
|
) ([]libkg.Node, []libkg.BraveSource) {
|
|
if deps.KnowledgeGraph == nil {
|
|
return nil, nil
|
|
}
|
|
graph, err := deps.KnowledgeGraph.GetByCopyMission(ctx, tenantID, ownerUID, missionID)
|
|
if err != nil || graph == nil {
|
|
return nil, nil
|
|
}
|
|
return graph.Nodes, graph.BraveSources
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|