thread-master/backend/internal/model/content_ops/usecase/usecase.go

416 lines
17 KiB
Go
Raw Permalink Normal View History

package usecase
import (
"archive/zip"
"bytes"
"context"
"encoding/base64"
"encoding/xml"
"math"
"path/filepath"
"regexp"
"strings"
"haixun-backend/internal/library/clock"
app "haixun-backend/internal/library/errors"
"haixun-backend/internal/library/errors/code"
"haixun-backend/internal/model/content_ops/domain/entity"
domrepo "haixun-backend/internal/model/content_ops/domain/repository"
domusecase "haixun-backend/internal/model/content_ops/domain/usecase"
"github.com/google/uuid"
)
type useCase struct{ repo domrepo.Repository }
func NewUseCase(repo domrepo.Repository) domusecase.UseCase { return &useCase{repo: repo} }
func requireActor(tenantID, ownerUID, personaID string) error {
if strings.TrimSpace(tenantID) == "" || strings.TrimSpace(ownerUID) == "" || strings.TrimSpace(personaID) == "" {
return app.For(code.Persona).InputMissingRequired("tenant_id, owner_uid, and persona_id are required")
}
return nil
}
func clampScore(n int) int {
if n < 0 {
return 0
}
if n > 100 {
return 100
}
return n
}
func finalScore(heat, fit, interaction, extend, risk int) float64 {
score := float64(clampScore(heat))*0.25 + float64(clampScore(fit))*0.30 + float64(clampScore(interaction))*0.15 + float64(clampScore(extend))*0.15 + 70*0.10 - float64(clampScore(risk))*0.20
return math.Round(score*10) / 10
}
func (u *useCase) CreateTopicCandidate(ctx context.Context, req domusecase.TopicCandidateInput) (*domusecase.TopicCandidateSummary, error) {
if err := requireActor(req.TenantID, req.OwnerUID, req.PersonaID); err != nil {
return nil, err
}
name := strings.TrimSpace(req.Name)
if name == "" {
return nil, app.For(code.Persona).InputMissingRequired("topic name is required")
}
now := clock.NowUnixNano()
item := &entity.TopicCandidate{ID: uuid.NewString(), TenantID: req.TenantID, OwnerUID: req.OwnerUID, PersonaID: req.PersonaID, Name: name, Source: strings.TrimSpace(req.Source), Category: strings.TrimSpace(req.Category), SeedQuery: strings.TrimSpace(req.SeedQuery), TrendReason: strings.TrimSpace(req.TrendReason), TrendKeywords: req.TrendKeywords, HeatScore: clampScore(req.HeatScore), FitScore: clampScore(req.FitScore), InteractionScore: clampScore(req.InteractionScore), ExtendScore: clampScore(req.ExtendScore), RiskScore: clampScore(req.RiskScore), RecommendedMissions: req.RecommendedMissions, Status: "candidate", CreateAt: now, UpdateAt: now}
item.FinalScore = finalScore(item.HeatScore, item.FitScore, item.InteractionScore, item.ExtendScore, item.RiskScore)
if err := u.repo.CreateTopicCandidate(ctx, item); err != nil {
return nil, err
}
out := topicSummary(*item)
return &out, nil
}
func (u *useCase) ListTopicCandidates(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]domusecase.TopicCandidateSummary, error) {
items, err := u.repo.ListTopicCandidates(ctx, tenantID, ownerUID, personaID, limit)
if err != nil {
return nil, err
}
out := make([]domusecase.TopicCandidateSummary, 0, len(items))
for _, item := range items {
out = append(out, topicSummary(item))
}
return out, nil
}
func (u *useCase) CreateContentPlan(ctx context.Context, req domusecase.ContentPlanInput) (*domusecase.ContentPlanSummary, error) {
if err := requireActor(req.TenantID, req.OwnerUID, req.PersonaID); err != nil {
return nil, err
}
if strings.TrimSpace(req.Topic) == "" || strings.TrimSpace(req.Mission) == "" {
return nil, app.For(code.Persona).InputMissingRequired("topic and mission are required")
}
now := clock.NowUnixNano()
item := &entity.ContentPlan{ID: uuid.NewString(), TenantID: req.TenantID, OwnerUID: req.OwnerUID, PersonaID: req.PersonaID, TopicCandidateID: strings.TrimSpace(req.TopicCandidateID), Topic: strings.TrimSpace(req.Topic), Mission: strings.TrimSpace(req.Mission), TargetAudience: strings.TrimSpace(req.TargetAudience), Angle: strings.TrimSpace(req.Angle), OpeningType: strings.TrimSpace(req.OpeningType), BodyType: strings.TrimSpace(req.BodyType), Emotion: strings.TrimSpace(req.Emotion), EndingType: strings.TrimSpace(req.EndingType), CtaType: strings.TrimSpace(req.CtaType), RiskLevel: normalizeRisk(req.RiskLevel), RequiresHumanReview: req.RequiresHumanReview || normalizeRisk(req.RiskLevel) != "low", Avoid: req.Avoid, SelectedKnowledge: req.SelectedKnowledge, Status: "planned", CreateAt: now, UpdateAt: now}
if err := u.repo.CreateContentPlan(ctx, item); err != nil {
return nil, err
}
out := planSummary(*item)
return &out, nil
}
func (u *useCase) UpdateContentPlan(ctx context.Context, tenantID, ownerUID, personaID, id string, patch domusecase.ContentPlanPatch) (*domusecase.ContentPlanSummary, error) {
m := map[string]any{"update_at": clock.NowUnixNano()}
setString := func(key string, value *string) {
if value != nil {
m[key] = strings.TrimSpace(*value)
}
}
setString("topic", patch.Topic)
setString("mission", patch.Mission)
setString("target_audience", patch.TargetAudience)
setString("angle", patch.Angle)
setString("opening_type", patch.OpeningType)
setString("body_type", patch.BodyType)
setString("emotion", patch.Emotion)
setString("ending_type", patch.EndingType)
setString("cta_type", patch.CtaType)
setString("status", patch.Status)
if patch.RiskLevel != nil {
m["risk_level"] = normalizeRisk(*patch.RiskLevel)
}
if patch.RequiresHumanReview != nil {
m["requires_human_review"] = *patch.RequiresHumanReview
}
if patch.Avoid != nil {
m["avoid"] = patch.Avoid
}
if patch.SelectedKnowledge != nil {
m["selected_knowledge"] = patch.SelectedKnowledge
}
item, err := u.repo.UpdateContentPlan(ctx, tenantID, ownerUID, personaID, id, m)
if err != nil {
return nil, err
}
out := planSummary(*item)
return &out, nil
}
func (u *useCase) GetContentPlan(ctx context.Context, tenantID, ownerUID, personaID, id string) (*domusecase.ContentPlanSummary, error) {
item, err := u.repo.GetContentPlan(ctx, tenantID, ownerUID, personaID, id)
if err != nil {
return nil, err
}
out := planSummary(*item)
return &out, nil
}
func (u *useCase) ListContentPlans(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]domusecase.ContentPlanSummary, error) {
items, err := u.repo.ListContentPlans(ctx, tenantID, ownerUID, personaID, limit)
if err != nil {
return nil, err
}
out := make([]domusecase.ContentPlanSummary, 0, len(items))
for _, item := range items {
out = append(out, planSummary(item))
}
return out, nil
}
func (u *useCase) CreateFeedbackEvent(ctx context.Context, req domusecase.FeedbackEventInput) (*domusecase.FeedbackEventSummary, error) {
if err := requireActor(req.TenantID, req.OwnerUID, req.PersonaID); err != nil {
return nil, err
}
decision := strings.TrimSpace(req.Decision)
if decision == "" {
return nil, app.For(code.Persona).InputMissingRequired("decision is required")
}
item := &entity.FeedbackEvent{ID: uuid.NewString(), TenantID: req.TenantID, OwnerUID: req.OwnerUID, PersonaID: req.PersonaID, ContentPlanID: strings.TrimSpace(req.ContentPlanID), DraftID: strings.TrimSpace(req.DraftID), Decision: decision, Note: strings.TrimSpace(req.Note), Snapshot: strings.TrimSpace(req.Snapshot), CreateAt: clock.NowUnixNano()}
if err := u.repo.CreateFeedbackEvent(ctx, item); err != nil {
return nil, err
}
out := feedbackSummary(*item)
return &out, nil
}
func (u *useCase) ListFeedbackEvents(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]domusecase.FeedbackEventSummary, error) {
items, err := u.repo.ListFeedbackEvents(ctx, tenantID, ownerUID, personaID, limit)
if err != nil {
return nil, err
}
out := make([]domusecase.FeedbackEventSummary, 0, len(items))
for _, item := range items {
out = append(out, feedbackSummary(item))
}
return out, nil
}
func (u *useCase) CreateKnowledgeSource(ctx context.Context, req domusecase.KnowledgeSourceInput) (*domusecase.KnowledgeSourceSummary, error) {
if err := requireActor(req.TenantID, req.OwnerUID, req.PersonaID); err != nil {
return nil, err
}
raw, err := decodeSourceContent(req)
if err != nil {
return nil, app.For(code.Persona).InputInvalidFormat(err.Error())
}
chunksText := splitChunks(raw, 900)
now := clock.NowUnixNano()
sourceID := uuid.NewString()
item := &entity.KnowledgeSource{ID: sourceID, TenantID: req.TenantID, OwnerUID: req.OwnerUID, PersonaID: req.PersonaID, SourceType: strings.TrimSpace(req.SourceType), Title: strings.TrimSpace(req.Title), Filename: strings.TrimSpace(req.Filename), RawContent: raw, ParsedStatus: "parsed", ChunkCount: len(chunksText), CreateAt: now}
chunks := make([]*entity.KnowledgeChunk, 0, len(chunksText))
for _, text := range chunksText {
chunks = append(chunks, &entity.KnowledgeChunk{ID: uuid.NewString(), TenantID: req.TenantID, OwnerUID: req.OwnerUID, PersonaID: req.PersonaID, SourceID: sourceID, Content: text, Topics: inferTopics(text), StyleTags: inferStyleTags(text), RiskLevel: inferRisk(text), CreateAt: now})
}
if err := u.repo.CreateKnowledgeSource(ctx, item, chunks); err != nil {
return nil, err
}
out := sourceSummary(*item)
return &out, nil
}
func (u *useCase) ListKnowledgeSources(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]domusecase.KnowledgeSourceSummary, error) {
items, err := u.repo.ListKnowledgeSources(ctx, tenantID, ownerUID, personaID, limit)
if err != nil {
return nil, err
}
out := make([]domusecase.KnowledgeSourceSummary, 0, len(items))
for _, item := range items {
out = append(out, sourceSummary(item))
}
return out, nil
}
func (u *useCase) ListKnowledgeChunks(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]domusecase.KnowledgeChunkSummary, error) {
items, err := u.repo.ListKnowledgeChunks(ctx, tenantID, ownerUID, personaID, limit)
if err != nil {
return nil, err
}
out := make([]domusecase.KnowledgeChunkSummary, 0, len(items))
for _, item := range items {
out = append(out, chunkSummary(item))
}
return out, nil
}
func (u *useCase) CreateFormulaPool(ctx context.Context, req domusecase.FormulaPoolInput) (*domusecase.FormulaPoolSummary, error) {
if err := requireActor(req.TenantID, req.OwnerUID, req.PersonaID); err != nil {
return nil, err
}
if strings.TrimSpace(req.Type) == "" || strings.TrimSpace(req.Name) == "" {
return nil, app.For(code.Persona).InputMissingRequired("type and name are required")
}
now := clock.NowUnixNano()
weight := req.Weight
if weight <= 0 {
weight = 50
}
item := &entity.FormulaPool{ID: uuid.NewString(), TenantID: req.TenantID, OwnerUID: req.OwnerUID, PersonaID: req.PersonaID, Type: strings.TrimSpace(req.Type), Name: strings.TrimSpace(req.Name), Pattern: strings.TrimSpace(req.Pattern), UseCases: req.UseCases, Avoid: req.Avoid, Weight: weight, CreateAt: now, UpdateAt: now}
if err := u.repo.CreateFormulaPool(ctx, item); err != nil {
return nil, err
}
out := formulaSummary(*item)
return &out, nil
}
func (u *useCase) ListFormulaPools(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]domusecase.FormulaPoolSummary, error) {
items, err := u.repo.ListFormulaPools(ctx, tenantID, ownerUID, personaID, limit)
if err != nil {
return nil, err
}
if len(items) == 0 {
_ = u.seedDefaultFormulas(ctx, tenantID, ownerUID, personaID)
items, _ = u.repo.ListFormulaPools(ctx, tenantID, ownerUID, personaID, limit)
}
out := make([]domusecase.FormulaPoolSummary, 0, len(items))
for _, item := range items {
out = append(out, formulaSummary(item))
}
return out, nil
}
func (u *useCase) seedDefaultFormulas(ctx context.Context, tenantID, ownerUID, personaID string) error {
defs := []domusecase.FormulaPoolInput{{Type: "opening", Name: "問題開場", Pattern: "用讀者正在問的具體問題開場", UseCases: []string{"互動文"}, Weight: 70}, {Type: "body", Name: "先共鳴再整理", Pattern: "先接住情緒,再整理 2-3 個可執行觀點", UseCases: []string{"共鳴文", "知識文"}, Weight: 75}, {Type: "emotion", Name: "被理解感", Pattern: "講出讀者不一定說出口的焦慮或委屈", UseCases: []string{"情緒共鳴文"}, Weight: 70}, {Type: "cta", Name: "經驗募集型", Pattern: "邀請讀者補充自己的經驗,不硬導購", UseCases: []string{"互動文"}, Weight: 65}}
for _, def := range defs {
def.TenantID = tenantID
def.OwnerUID = ownerUID
def.PersonaID = personaID
if _, err := u.CreateFormulaPool(ctx, def); err != nil {
return err
}
}
return nil
}
func decodeSourceContent(req domusecase.KnowledgeSourceInput) (string, error) {
if strings.TrimSpace(req.ContentBase64) != "" {
data, err := base64.StdEncoding.DecodeString(req.ContentBase64)
if err != nil {
return "", err
}
if strings.EqualFold(filepath.Ext(req.Filename), ".xlsx") {
return parseXLSX(data)
}
return string(data), nil
}
return strings.TrimSpace(req.Content), nil
}
type xlsxText struct {
T string `xml:",chardata"`
}
func parseXLSX(data []byte) (string, error) {
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return "", err
}
var b strings.Builder
for _, f := range zr.File {
if !strings.HasPrefix(f.Name, "xl/sharedStrings") && !strings.HasPrefix(f.Name, "xl/worksheets") {
continue
}
rc, err := f.Open()
if err != nil {
continue
}
var texts []xlsxText
_ = xml.NewDecoder(rc).Decode(&texts)
_ = rc.Close()
for _, t := range texts {
if s := strings.TrimSpace(t.T); s != "" {
b.WriteString(s)
b.WriteString("\n")
}
}
}
text := strings.TrimSpace(b.String())
if text == "" {
return "", nil
}
return text, nil
}
func splitChunks(raw string, max int) []string {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
paras := strings.Split(raw, "\n")
var out []string
var b strings.Builder
for _, p := range paras {
p = strings.TrimSpace(p)
if p == "" {
continue
}
if b.Len()+len([]rune(p)) > max && b.Len() > 0 {
out = append(out, b.String())
b.Reset()
}
if b.Len() > 0 {
b.WriteString("\n")
}
b.WriteString(p)
}
if b.Len() > 0 {
out = append(out, b.String())
}
return out
}
var topicRE = regexp.MustCompile(`[A-Za-z0-9_\p{Han}]{2,12}`)
func inferTopics(text string) []string {
seen := map[string]bool{}
var out []string
for _, m := range topicRE.FindAllString(text, 20) {
if !seen[m] && len(out) < 8 {
seen[m] = true
out = append(out, m)
}
}
return out
}
func inferStyleTags(text string) []string {
var out []string
if strings.Contains(text, "我") {
out = append(out, "第一人稱")
}
if strings.Contains(text, "") || strings.Contains(text, "?") {
out = append(out, "提問")
}
if strings.Contains(text, "。") {
out = append(out, "自然標點")
}
return out
}
func inferRisk(text string) string {
lower := strings.ToLower(text)
for _, k := range []string{"醫", "藥", "amh", "投資", "法律"} {
if strings.Contains(lower, k) {
return "medium"
}
}
return "low"
}
func normalizeRisk(raw string) string {
raw = strings.ToLower(strings.TrimSpace(raw))
if raw == "high" || raw == "low" {
return raw
}
return "medium"
}
func topicSummary(item entity.TopicCandidate) domusecase.TopicCandidateSummary {
return domusecase.TopicCandidateSummary{ID: item.ID, PersonaID: item.PersonaID, Name: item.Name, Source: item.Source, Category: item.Category, SeedQuery: item.SeedQuery, TrendReason: item.TrendReason, TrendKeywords: item.TrendKeywords, HeatScore: item.HeatScore, FitScore: item.FitScore, InteractionScore: item.InteractionScore, ExtendScore: item.ExtendScore, RiskScore: item.RiskScore, FinalScore: item.FinalScore, RecommendedMissions: item.RecommendedMissions, Status: item.Status, CreateAt: item.CreateAt, UpdateAt: item.UpdateAt}
}
func planSummary(item entity.ContentPlan) domusecase.ContentPlanSummary {
return domusecase.ContentPlanSummary{ID: item.ID, PersonaID: item.PersonaID, TopicCandidateID: item.TopicCandidateID, Topic: item.Topic, Mission: item.Mission, TargetAudience: item.TargetAudience, Angle: item.Angle, OpeningType: item.OpeningType, BodyType: item.BodyType, Emotion: item.Emotion, EndingType: item.EndingType, CtaType: item.CtaType, RiskLevel: item.RiskLevel, RequiresHumanReview: item.RequiresHumanReview, Avoid: item.Avoid, SelectedKnowledge: item.SelectedKnowledge, Status: item.Status, CreateAt: item.CreateAt, UpdateAt: item.UpdateAt}
}
func feedbackSummary(item entity.FeedbackEvent) domusecase.FeedbackEventSummary {
return domusecase.FeedbackEventSummary{ID: item.ID, PersonaID: item.PersonaID, ContentPlanID: item.ContentPlanID, DraftID: item.DraftID, Decision: item.Decision, Note: item.Note, Snapshot: item.Snapshot, CreateAt: item.CreateAt}
}
func sourceSummary(item entity.KnowledgeSource) domusecase.KnowledgeSourceSummary {
return domusecase.KnowledgeSourceSummary{ID: item.ID, PersonaID: item.PersonaID, SourceType: item.SourceType, Title: item.Title, Filename: item.Filename, ParsedStatus: item.ParsedStatus, ChunkCount: item.ChunkCount, CreateAt: item.CreateAt}
}
func chunkSummary(item entity.KnowledgeChunk) domusecase.KnowledgeChunkSummary {
return domusecase.KnowledgeChunkSummary{ID: item.ID, PersonaID: item.PersonaID, SourceID: item.SourceID, Content: item.Content, Topics: item.Topics, StyleTags: item.StyleTags, RiskLevel: item.RiskLevel, CreateAt: item.CreateAt}
}
func formulaSummary(item entity.FormulaPool) domusecase.FormulaPoolSummary {
return domusecase.FormulaPoolSummary{ID: item.ID, PersonaID: item.PersonaID, Type: item.Type, Name: item.Name, Pattern: item.Pattern, UseCases: item.UseCases, Avoid: item.Avoid, Weight: item.Weight, CreateAt: item.CreateAt, UpdateAt: item.UpdateAt}
}