416 lines
17 KiB
Go
416 lines
17 KiB
Go
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}
|
||
}
|