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

416 lines
17 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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}
}