feat/rebone #3

Merged
daniel.w merged 8 commits from feat/rebone into main 2026-07-06 06:13:18 +00:00
171 changed files with 10518 additions and 5319 deletions
Showing only changes of commit 6d7d329979 - Show all commits

File diff suppressed because it is too large Load Diff

View File

@ -165,7 +165,10 @@ type (
}
CopyMissionInspirationReq {
Keyword string `json:"keyword,optional"`
Keyword string `json:"keyword,optional"`
ContentDirection string `json:"content_direction,optional"`
UseWebSearch bool `json:"use_web_search,optional"`
AvoidTopics []string `json:"avoid_topics,optional"`
}
CopyMissionInspirationHandlerReq {
@ -267,7 +270,7 @@ type (
Message string `json:"message"`
}
ScheduleCopyDraftsReq {
CopyMissionScheduleCopyDraftsReq {
AccountID string `json:"account_id" validate:"required"`
DraftIDs []string `json:"draft_ids" validate:"required,min=1"`
StartAt int64 `json:"start_at,optional"`
@ -278,10 +281,10 @@ type (
ScheduleCopyMissionDraftsHandlerReq {
CopyMissionPath
ScheduleCopyDraftsReq
CopyMissionScheduleCopyDraftsReq
}
ScheduleCopyDraftsData {
CopyMissionScheduleCopyDraftsData {
Scheduled int `json:"scheduled"`
List []PublishQueueItemData `json:"list"`
Message string `json:"message"`
@ -295,11 +298,22 @@ type (
}
CopyMissionInspirationData {
TopicCandidateID string `json:"topic_candidate_id,omitempty"`
ContentPlanID string `json:"content_plan_id,omitempty"`
Label string `json:"label"`
SeedQuery string `json:"seed_query"`
Brief string `json:"brief"`
TrendReason string `json:"trend_reason,omitempty"`
TrendKeywords []string `json:"trend_keywords,omitempty"`
Angles []string `json:"angles,omitempty"`
Mission string `json:"mission,omitempty"`
TargetAudience string `json:"target_audience,omitempty"`
OpeningType string `json:"opening_type,omitempty"`
BodyType string `json:"body_type,omitempty"`
Emotion string `json:"emotion,omitempty"`
CtaType string `json:"cta_type,omitempty"`
RiskLevel string `json:"risk_level,omitempty"`
Avoid []string `json:"avoid,omitempty"`
Sources []CopyMissionInspirationSourceData `json:"sources,omitempty"`
WebSearchUsed bool `json:"web_search_used"`
Message string `json:"message"`
@ -357,7 +371,7 @@ service gateway {
post /:personaId/copy-missions/:id/matrix-drafts/delete (DeleteCopyMissionMatrixDraftsHandlerReq) returns (DeleteCopyMissionMatrixDraftsData)
@handler scheduleCopyMissionDrafts
post /:personaId/copy-missions/:id/copy-drafts/schedule (ScheduleCopyMissionDraftsHandlerReq) returns (ScheduleCopyDraftsData)
post /:personaId/copy-missions/:id/copy-drafts/schedule (ScheduleCopyMissionDraftsHandlerReq) returns (CopyMissionScheduleCopyDraftsData)
@handler getCopyMissionScanSchedule
get /:personaId/copy-missions/:id/scan-schedule (CopyMissionPath) returns (CopyMissionScanScheduleData)

View File

@ -64,6 +64,23 @@ type (
StartPersonaStyleAnalysisReq
}
StartPersonaStyleAnalysisFromTextReq {
ReferenceTexts []string `json:"reference_texts,optional"`
RawText string `json:"raw_text,optional"`
SourceLabel string `json:"source_label,optional"`
}
StartPersonaStyleAnalysisFromTextData {
Persona PersonaData `json:"persona"`
PostCount int `json:"post_count"`
Message string `json:"message"`
}
StartPersonaStyleAnalysisFromTextHandlerReq {
PersonaPath
StartPersonaStyleAnalysisFromTextReq
}
StartPersonaViralScanJobReq {
Keywords []string `json:"keywords,optional"`
}
@ -113,17 +130,27 @@ type (
CopyDraftData {
ID string `json:"id"`
PersonaID string `json:"persona_id"`
ContentPlanID string `json:"content_plan_id,omitempty"`
CopyMissionID string `json:"copy_mission_id,omitempty"`
ScanPostID string `json:"scan_post_id,omitempty"`
FormulaID string `json:"formula_id,omitempty"`
DraftType string `json:"draft_type"`
SortOrder int `json:"sort_order,omitempty"`
Text string `json:"text"`
TopicTag string `json:"topic_tag,omitempty"`
Angle string `json:"angle,omitempty"`
Hook string `json:"hook,omitempty"`
Rationale string `json:"rationale,omitempty"`
ReferenceNotes string `json:"reference_notes,omitempty"`
Sources []string `json:"sources,omitempty"`
AiScore int `json:"ai_score,omitempty"`
FormulaScore int `json:"formula_score,omitempty"`
BrandFitScore int `json:"brand_fit_score,omitempty"`
RiskScore int `json:"risk_score,omitempty"`
SimilarityScore int `json:"similarity_score,omitempty"`
EngagementPotential int `json:"engagement_potential,omitempty"`
FreshnessScore int `json:"freshness_score,omitempty"`
ReviewSuggestion string `json:"review_suggestion,omitempty"`
Status string `json:"status,omitempty"`
PublishQueueID string `json:"publish_queue_id,omitempty"`
PublishedMediaID string `json:"published_media_id,omitempty"`
@ -157,10 +184,202 @@ type (
}
UpdateCopyDraftReq {
Text *string `json:"text,optional"`
Hook *string `json:"hook,optional"`
Angle *string `json:"angle,optional"`
Status *string `json:"status,optional"`
Text *string `json:"text,optional"`
TopicTag *string `json:"topic_tag,optional"`
Hook *string `json:"hook,optional"`
Angle *string `json:"angle,optional"`
Rationale *string `json:"rationale,optional"`
Status *string `json:"status,optional"`
}
TopicCandidateData {
ID string `json:"id"`
PersonaID string `json:"persona_id"`
Name string `json:"name"`
Source string `json:"source,omitempty"`
Category string `json:"category,omitempty"`
SeedQuery string `json:"seed_query,omitempty"`
TrendReason string `json:"trend_reason,omitempty"`
TrendKeywords []string `json:"trend_keywords,omitempty"`
HeatScore int `json:"heat_score,omitempty"`
FitScore int `json:"fit_score,omitempty"`
InteractionScore int `json:"interaction_score,omitempty"`
ExtendScore int `json:"extend_score,omitempty"`
RiskScore int `json:"risk_score,omitempty"`
FinalScore float64 `json:"final_score,omitempty"`
RecommendedMissions []string `json:"recommended_missions,omitempty"`
Status string `json:"status,omitempty"`
CreateAt int64 `json:"create_at"`
UpdateAt int64 `json:"update_at"`
}
ContentPlanData {
ID string `json:"id"`
PersonaID string `json:"persona_id"`
TopicCandidateID string `json:"topic_candidate_id,omitempty"`
Topic string `json:"topic"`
Mission string `json:"mission"`
TargetAudience string `json:"target_audience,omitempty"`
Angle string `json:"angle,omitempty"`
OpeningType string `json:"opening_type,omitempty"`
BodyType string `json:"body_type,omitempty"`
Emotion string `json:"emotion,omitempty"`
EndingType string `json:"ending_type,omitempty"`
CtaType string `json:"cta_type,omitempty"`
RiskLevel string `json:"risk_level,omitempty"`
RequiresHumanReview bool `json:"requires_human_review,omitempty"`
Avoid []string `json:"avoid,omitempty"`
SelectedKnowledge []string `json:"selected_knowledge,omitempty"`
Status string `json:"status,omitempty"`
CreateAt int64 `json:"create_at"`
UpdateAt int64 `json:"update_at"`
}
FeedbackEventData {
ID string `json:"id"`
PersonaID string `json:"persona_id"`
ContentPlanID string `json:"content_plan_id,omitempty"`
DraftID string `json:"draft_id,omitempty"`
Decision string `json:"decision"`
Note string `json:"note,omitempty"`
Snapshot string `json:"snapshot,omitempty"`
CreateAt int64 `json:"create_at"`
}
KnowledgeSourceData {
ID string `json:"id"`
PersonaID string `json:"persona_id"`
SourceType string `json:"source_type"`
Title string `json:"title,omitempty"`
Filename string `json:"filename,omitempty"`
ParsedStatus string `json:"parsed_status,omitempty"`
ChunkCount int `json:"chunk_count,omitempty"`
CreateAt int64 `json:"create_at"`
}
KnowledgeChunkData {
ID string `json:"id"`
PersonaID string `json:"persona_id"`
SourceID string `json:"source_id"`
Content string `json:"content"`
Topics []string `json:"topics,omitempty"`
StyleTags []string `json:"style_tags,omitempty"`
RiskLevel string `json:"risk_level,omitempty"`
CreateAt int64 `json:"create_at"`
}
FormulaPoolData {
ID string `json:"id"`
PersonaID string `json:"persona_id"`
Type string `json:"type"`
Name string `json:"name"`
Pattern string `json:"pattern,omitempty"`
UseCases []string `json:"use_cases,omitempty"`
Avoid []string `json:"avoid,omitempty"`
Weight int `json:"weight,omitempty"`
CreateAt int64 `json:"create_at"`
UpdateAt int64 `json:"update_at"`
}
CreateKnowledgeSourceReq {
SourceType string `json:"source_type" validate:"required"`
Title string `json:"title,optional"`
Filename string `json:"filename,optional"`
Content string `json:"content,optional"`
ContentBase64 string `json:"content_base64,optional"`
}
CreateKnowledgeSourceHandlerReq {
PersonaPath
CreateKnowledgeSourceReq
}
ListKnowledgeSourcesData { List []KnowledgeSourceData `json:"list"` }
ListKnowledgeChunksData { List []KnowledgeChunkData `json:"list"` }
ListFormulaPoolsData { List []FormulaPoolData `json:"list"` }
CreateFormulaPoolReq {
Type string `json:"type" validate:"required"`
Name string `json:"name" validate:"required"`
Pattern string `json:"pattern,optional"`
UseCases []string `json:"use_cases,optional"`
Avoid []string `json:"avoid,optional"`
Weight int `json:"weight,optional"`
}
CreateFormulaPoolHandlerReq {
PersonaPath
CreateFormulaPoolReq
}
ListTopicCandidatesData {
List []TopicCandidateData `json:"list"`
}
ListContentPlansData {
List []ContentPlanData `json:"list"`
}
CreateContentPlanReq {
TopicCandidateID string `json:"topic_candidate_id,optional"`
Topic string `json:"topic" validate:"required"`
Mission string `json:"mission" validate:"required"`
TargetAudience string `json:"target_audience,optional"`
Angle string `json:"angle,optional"`
OpeningType string `json:"opening_type,optional"`
BodyType string `json:"body_type,optional"`
Emotion string `json:"emotion,optional"`
EndingType string `json:"ending_type,optional"`
CtaType string `json:"cta_type,optional"`
RiskLevel string `json:"risk_level,optional"`
RequiresHumanReview bool `json:"requires_human_review,optional"`
Avoid []string `json:"avoid,optional"`
SelectedKnowledge []string `json:"selected_knowledge,optional"`
}
CreateContentPlanHandlerReq {
PersonaPath
CreateContentPlanReq
}
ContentPlanPath {
ID string `path:"id" validate:"required"`
ContentPlanID string `path:"contentPlanId" validate:"required"`
}
UpdateContentPlanReq {
Topic *string `json:"topic,optional"`
Mission *string `json:"mission,optional"`
TargetAudience *string `json:"target_audience,optional"`
Angle *string `json:"angle,optional"`
OpeningType *string `json:"opening_type,optional"`
BodyType *string `json:"body_type,optional"`
Emotion *string `json:"emotion,optional"`
EndingType *string `json:"ending_type,optional"`
CtaType *string `json:"cta_type,optional"`
RiskLevel *string `json:"risk_level,optional"`
RequiresHumanReview *bool `json:"requires_human_review,optional"`
Avoid []string `json:"avoid,optional"`
SelectedKnowledge []string `json:"selected_knowledge,optional"`
Status *string `json:"status,optional"`
}
UpdateContentPlanHandlerReq {
ContentPlanPath
UpdateContentPlanReq
}
CreateFeedbackEventReq {
ContentPlanID string `json:"content_plan_id,optional"`
DraftID string `json:"draft_id,optional"`
Decision string `json:"decision" validate:"required"`
Note string `json:"note,optional"`
Snapshot string `json:"snapshot,optional"`
}
CreateFeedbackEventHandlerReq {
PersonaPath
CreateFeedbackEventReq
}
UpdateCopyDraftHandlerReq {
@ -169,8 +388,9 @@ type (
}
PublishCopyDraftReq {
Text string `json:"text,optional"`
Confirm bool `json:"confirm"`
Text string `json:"text,optional"`
TopicTag string `json:"topic_tag,optional"`
Confirm bool `json:"confirm"`
}
PublishCopyDraftHandlerReq {
@ -186,6 +406,21 @@ type (
Message string `json:"message"`
}
ScheduleCopyDraftsReq {
AccountID string `json:"account_id" validate:"required"`
DraftIDs []string `json:"draft_ids" validate:"required,min=1"`
StartAt int64 `json:"start_at,optional"`
Timezone string `json:"timezone,optional"`
Slots []PublishSlotData `json:"slots,optional"`
Mode string `json:"mode,optional"`
}
ScheduleCopyDraftsData {
Scheduled int `json:"scheduled"`
List []PublishQueueItemData `json:"list"`
Message string `json:"message"`
}
SchedulePersonaDraftsHandlerReq {
PersonaPath
ScheduleCopyDraftsReq
@ -231,6 +466,21 @@ type (
Message string `json:"message"`
}
PrunePersonaCopyDraftsReq {
Keep int `json:"keep,optional"`
}
PrunePersonaCopyDraftsHandlerReq {
PersonaPath
PrunePersonaCopyDraftsReq
}
PrunePersonaCopyDraftsData {
Deleted int64 `json:"deleted"`
Kept int `json:"kept"`
Message string `json:"message"`
}
ListPersonaContentInboxReq {
Page int64 `form:"page,optional"`
PageSize int64 `form:"pageSize,optional"`
@ -259,6 +509,7 @@ type (
SchedulePersonaCopyDraftReq {
AccountID string `json:"account_id" validate:"required"`
TopicTag string `json:"topic_tag,optional"`
ScheduledAt int64 `json:"scheduled_at,optional"`
}
@ -289,6 +540,82 @@ type (
List []CopyDraftData `json:"list"`
Message string `json:"message"`
}
GeneratePersonaTopicMatrixReq {
Topic string `json:"topic" validate:"required"`
ContentPlanID string `json:"content_plan_id,optional"`
Brief string `json:"brief,optional"`
UseWebSearch bool `json:"use_web_search,optional"`
DraftCount int `json:"draft_count,optional"`
}
GeneratePersonaTopicMatrixHandlerReq {
PersonaPath
GeneratePersonaTopicMatrixReq
}
GeneratePersonaTopicMatrixData {
List []CopyDraftData `json:"list"`
Message string `json:"message"`
}
StartPersonaTopicMatrixJobHandlerReq {
PersonaPath
GeneratePersonaTopicMatrixReq
}
StartPersonaTopicMatrixJobData {
JobID string `json:"job_id"`
Status string `json:"status"`
AiProvider string `json:"ai_provider"`
AiModel string `json:"ai_model"`
Message string `json:"message"`
}
StartPersonaFormulaDraftJobReq {
AccountID string `json:"account_id" validate:"required"`
FormulaID string `json:"formula_id" validate:"required"`
Topic string `json:"topic" validate:"required"`
Brief string `json:"brief,optional"`
UseWebSearch bool `json:"use_web_search,optional"`
DraftCount int `json:"draft_count,optional"`
}
StartPersonaFormulaDraftJobHandlerReq {
PersonaPath
StartPersonaFormulaDraftJobReq
}
StartPersonaFormulaDraftJobData {
JobID string `json:"job_id"`
Status string `json:"status"`
AiProvider string `json:"ai_provider"`
AiModel string `json:"ai_model"`
Message string `json:"message"`
}
StartPersonaRewriteDraftJobReq {
AccountID string `json:"account_id" validate:"required"`
ReferenceText string `json:"reference_text" validate:"required"`
Topic string `json:"topic" validate:"required"`
Brief string `json:"brief,optional"`
SaveLabel string `json:"save_label,optional"`
UseWebSearch bool `json:"use_web_search,optional"`
DraftCount int `json:"draft_count,optional"`
}
StartPersonaRewriteDraftJobHandlerReq {
PersonaPath
StartPersonaRewriteDraftJobReq
}
StartPersonaRewriteDraftJobData {
JobID string `json:"job_id"`
Status string `json:"status"`
AiProvider string `json:"ai_provider"`
AiModel string `json:"ai_model"`
Message string `json:"message"`
}
)
@server(
@ -317,6 +644,9 @@ service gateway {
@handler startPersonaStyleAnalysis
post /:id/style-analysis (StartPersonaStyleAnalysisHandlerReq) returns (StartPersonaStyleAnalysisData)
@handler startPersonaStyleAnalysisFromText
post /:id/style-analysis-from-text (StartPersonaStyleAnalysisFromTextHandlerReq) returns (StartPersonaStyleAnalysisFromTextData)
@handler startPersonaViralScanJob
post /:id/viral-scan-jobs (StartPersonaViralScanJobHandlerReq) returns (StartPersonaViralScanJobData)
@ -329,6 +659,36 @@ service gateway {
@handler listPersonaContentInbox
get /:id/content-inbox (ListPersonaContentInboxHandlerReq) returns (ListPersonaContentInboxData)
@handler listTopicCandidates
get /:id/topic-candidates (PersonaPath) returns (ListTopicCandidatesData)
@handler listContentPlans
get /:id/content-plans (PersonaPath) returns (ListContentPlansData)
@handler createContentPlan
post /:id/content-plans (CreateContentPlanHandlerReq) returns (ContentPlanData)
@handler updateContentPlan
patch /:id/content-plans/:contentPlanId (UpdateContentPlanHandlerReq) returns (ContentPlanData)
@handler createFeedbackEvent
post /:id/feedback-events (CreateFeedbackEventHandlerReq) returns (FeedbackEventData)
@handler createKnowledgeSource
post /:id/knowledge-sources (CreateKnowledgeSourceHandlerReq) returns (KnowledgeSourceData)
@handler listKnowledgeSources
get /:id/knowledge-sources (PersonaPath) returns (ListKnowledgeSourcesData)
@handler listKnowledgeChunks
get /:id/knowledge-chunks (PersonaPath) returns (ListKnowledgeChunksData)
@handler listFormulaPools
get /:id/formula-pools (PersonaPath) returns (ListFormulaPoolsData)
@handler createFormulaPool
post /:id/formula-pools (CreateFormulaPoolHandlerReq) returns (FormulaPoolData)
@handler generatePersonaCopyDraft
post /:id/copy-drafts/generate (GeneratePersonaCopyDraftHandlerReq) returns (GeneratePersonaCopyDraftData)
@ -347,9 +707,24 @@ service gateway {
@handler generateFromContentFormula
post /:id/content-formulas/:formulaId/generate (GenerateFromContentFormulaHandlerReq) returns (GenerateFromContentFormulaData)
@handler generatePersonaTopicMatrix
post /:id/topic-matrix/generate (GeneratePersonaTopicMatrixHandlerReq) returns (GeneratePersonaTopicMatrixData)
@handler startPersonaTopicMatrixJob
post /:id/topic-matrix/jobs (StartPersonaTopicMatrixJobHandlerReq) returns (StartPersonaTopicMatrixJobData)
@handler startPersonaFormulaDraftJob
post /:id/formula-draft/jobs (StartPersonaFormulaDraftJobHandlerReq) returns (StartPersonaFormulaDraftJobData)
@handler startPersonaRewriteDraftJob
post /:id/rewrite-draft/jobs (StartPersonaRewriteDraftJobHandlerReq) returns (StartPersonaRewriteDraftJobData)
@handler deletePersonaCopyDraft
delete /:id/copy-drafts/:draftId (CopyDraftPath) returns (DeleteCopyDraftData)
@handler prunePersonaCopyDrafts
post /:id/copy-drafts/prune (PrunePersonaCopyDraftsHandlerReq) returns (PrunePersonaCopyDraftsData)
@handler listStylePresets
get /:id/style-presets (PersonaPath) returns (ListStylePresetsData)
@ -358,4 +733,4 @@ service gateway {
@handler deleteStylePreset
delete /:id/style-presets/:presetId (StylePresetPath)
}
}

View File

@ -8,19 +8,31 @@ import (
libcrypto "haixun-backend/internal/library/crypto"
libmongo "haixun-backend/internal/library/mongo"
brandrepo "haixun-backend/internal/model/brand/repository"
contentformularepo "haixun-backend/internal/model/content_formula/repository"
cmatrixrepo "haixun-backend/internal/model/content_matrix/repository"
contentopsrepo "haixun-backend/internal/model/content_ops/repository"
copydraftrepo "haixun-backend/internal/model/copy_draft/repository"
copymissionrepo "haixun-backend/internal/model/copy_mission/repository"
crmcontactrepo "haixun-backend/internal/model/crm_contact/repository"
jobrepo "haixun-backend/internal/model/job/repository"
jobusecase "haixun-backend/internal/model/job/usecase"
kgrepo "haixun-backend/internal/model/knowledge_graph/repository"
memberrepo "haixun-backend/internal/model/member/repository"
mentioninboxrepo "haixun-backend/internal/model/mention_inbox/repository"
outreachdraftrepo "haixun-backend/internal/model/outreach_draft/repository"
ownpostformularepo "haixun-backend/internal/model/own_post_formula/repository"
permissionrepo "haixun-backend/internal/model/permission/repository"
permissionuc "haixun-backend/internal/model/permission/usecase"
personarepo "haixun-backend/internal/model/persona/repository"
placementtopicrepo "haixun-backend/internal/model/placement_topic/repository"
publishanalyticsrepo "haixun-backend/internal/model/publish_analytics/repository"
publishguardrepo "haixun-backend/internal/model/publish_guard/repository"
publishinventoryrepo "haixun-backend/internal/model/publish_inventory/repository"
publishqueuerepo "haixun-backend/internal/model/publish_queue/repository"
publishqueueeventrepo "haixun-backend/internal/model/publish_queue_event/repository"
scanpostrepo "haixun-backend/internal/model/scan_post/repository"
settingrepo "haixun-backend/internal/model/setting/repository"
stylepresetrepo "haixun-backend/internal/model/style_preset/repository"
threadsaccountrepo "haixun-backend/internal/model/threads_account/repository"
)
@ -97,6 +109,17 @@ func Init(ctx context.Context, cfg config.Config, opts InitOptions) (*InitReport
{"placement_topics", placementtopicrepo.NewMongoRepository(db).EnsureIndexes},
{"threads_accounts", threadsaccountrepo.NewMongoRepository(db).EnsureIndexes},
{"threads_account_secrets", threadsaccountrepo.NewSecretsMongoRepository(db, secretsCipher).EnsureIndexes},
{"publish_analytics", publishanalyticsrepo.NewMongoRepository(db).EnsureIndexes},
{"publish_queue", publishqueuerepo.NewMongoRepository(db).EnsureIndexes},
{"publish_inventory", publishinventoryrepo.NewMongoRepository(db).EnsureIndexes},
{"publish_guard", publishguardrepo.NewMongoRepository(db).EnsureIndexes},
{"publish_queue_events", publishqueueeventrepo.NewMongoRepository(db).EnsureIndexes},
{"style_presets", stylepresetrepo.NewMongoRepository(db).EnsureIndexes},
{"own_post_formulas", ownpostformularepo.NewMongoRepository(db).EnsureIndexes},
{"content_formulas", contentformularepo.NewMongoRepository(db).EnsureIndexes},
{"content_ops", contentopsrepo.NewMongoRepository(db).EnsureIndexes},
{"mention_inbox", mentioninboxrepo.NewMongoRepository(db).EnsureIndexes},
{"crm_contacts", crmcontactrepo.NewMongoRepository(db).EnsureIndexes},
}
for _, repo := range repos {
if err := repo.fn(ctx); err != nil {
@ -105,6 +128,26 @@ func Init(ctx context.Context, cfg config.Config, opts InitOptions) (*InitReport
}
report.IndexesEnsured = true
jobUseCase := jobusecase.NewUseCase(jobTemplateRepository, jobRunRepository, jobScheduleRepository, jobEventRepository, nil)
jobTemplates := []struct {
name string
fn func(context.Context) error
}{
{"demo", jobUseCase.EnsureDemoTemplate},
{"style_8d", jobUseCase.EnsureStyle8DTemplate},
{"expand_graph", jobUseCase.EnsureExpandGraphTemplate},
{"placement_scan", jobUseCase.EnsurePlacementScanTemplate},
{"scan_viral", jobUseCase.EnsureScanViralTemplate},
{"generate_outreach_draft", jobUseCase.EnsureGenerateOutreachDraftTemplate},
{"refresh_threads_token", jobUseCase.EnsureRefreshThreadsTokenTemplate},
{"publish_analytics", jobUseCase.EnsurePublishAnalyticsTemplate},
}
for _, template := range jobTemplates {
if err := template.fn(ctx); err != nil {
return nil, fmt.Errorf("seed %s job template: %w", template.name, err)
}
}
permissionUseCase := permissionuc.NewUseCase(permissionRepository, rolePermissionRepository)
if err := permissionUseCase.EnsureDefaultPermissions(ctx); err != nil {
return nil, fmt.Errorf("seed permissions catalog: %w", err)

View File

@ -6,12 +6,11 @@ package copy_mission
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/copy_mission"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func CreateCopyMissionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {

View File

@ -6,12 +6,11 @@ package copy_mission
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/copy_mission"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func DeleteCopyMissionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {

View File

@ -6,12 +6,11 @@ package copy_mission
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/copy_mission"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func GenerateCopyMissionMatrixHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {

View File

@ -6,12 +6,11 @@ package copy_mission
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/copy_mission"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func GetCopyMissionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {

View File

@ -6,12 +6,11 @@ package copy_mission
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/copy_mission"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func GetCopyMissionScanScheduleHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {

View File

@ -1,14 +1,16 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package copy_mission
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/copy_mission"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func InspireCopyMissionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {

View File

@ -6,12 +6,11 @@ package copy_mission
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/copy_mission"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func ListCopyMissionCopyDraftsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {

View File

@ -6,12 +6,11 @@ package copy_mission
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/copy_mission"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func ListCopyMissionScanPostsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {

View File

@ -6,12 +6,11 @@ package copy_mission
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/copy_mission"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func ListCopyMissionsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {

View File

@ -6,12 +6,11 @@ package copy_mission
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/copy_mission"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func StartCopyMissionAnalyzeJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {

View File

@ -1,14 +1,16 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package copy_mission
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/copy_mission"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func StartCopyMissionCopyDraftJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {

View File

@ -1,14 +1,16 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package copy_mission
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/copy_mission"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func StartCopyMissionMatrixJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {

View File

@ -6,12 +6,11 @@ package copy_mission
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/copy_mission"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func StartCopyMissionScanJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {

View File

@ -6,12 +6,11 @@ package copy_mission
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/copy_mission"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func UpdateCopyMissionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {

View File

@ -6,12 +6,11 @@ package copy_mission
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/copy_mission"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func UpsertCopyMissionScanScheduleHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {

View File

@ -0,0 +1,32 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package persona
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/persona"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
)
func CreateContentPlanHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.CreateContentPlanHandlerReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := persona.NewCreateContentPlanLogic(r.Context(), svcCtx)
data, err := l.CreateContentPlan(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,32 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package persona
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/persona"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
)
func CreateFeedbackEventHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.CreateFeedbackEventHandlerReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := persona.NewCreateFeedbackEventLogic(r.Context(), svcCtx)
data, err := l.CreateFeedbackEvent(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,32 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package persona
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/persona"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
)
func CreateFormulaPoolHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.CreateFormulaPoolHandlerReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := persona.NewCreateFormulaPoolLogic(r.Context(), svcCtx)
data, err := l.CreateFormulaPool(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,32 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package persona
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/persona"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
)
func CreateKnowledgeSourceHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.CreateKnowledgeSourceHandlerReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := persona.NewCreateKnowledgeSourceLogic(r.Context(), svcCtx)
data, err := l.CreateKnowledgeSource(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -26,4 +26,4 @@ func GenerateFromContentFormulaHandler(svcCtx *svc.ServiceContext) http.HandlerF
data, err := l.GenerateFromContentFormula(&req)
response.Write(r.Context(), w, data, err)
}
}
}

View File

@ -0,0 +1,29 @@
package persona
import (
"net/http"
"haixun-backend/internal/logic/persona"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func GeneratePersonaTopicMatrixHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.GeneratePersonaTopicMatrixHandlerReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := persona.NewGeneratePersonaTopicMatrixLogic(r.Context(), svcCtx)
data, err := l.GeneratePersonaTopicMatrix(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,32 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package persona
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/persona"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
)
func ListContentPlansHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.PersonaPath
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := persona.NewListContentPlansLogic(r.Context(), svcCtx)
data, err := l.ListContentPlans(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,32 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package persona
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/persona"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
)
func ListFormulaPoolsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.PersonaPath
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := persona.NewListFormulaPoolsLogic(r.Context(), svcCtx)
data, err := l.ListFormulaPools(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,32 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package persona
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/persona"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
)
func ListKnowledgeChunksHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.PersonaPath
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := persona.NewListKnowledgeChunksLogic(r.Context(), svcCtx)
data, err := l.ListKnowledgeChunks(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,32 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package persona
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/persona"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
)
func ListKnowledgeSourcesHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.PersonaPath
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := persona.NewListKnowledgeSourcesLogic(r.Context(), svcCtx)
data, err := l.ListKnowledgeSources(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -26,4 +26,4 @@ func ListPersonaContentInboxHandler(svcCtx *svc.ServiceContext) http.HandlerFunc
data, err := l.ListPersonaContentInbox(&req)
response.Write(r.Context(), w, data, err)
}
}
}

View File

@ -0,0 +1,32 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package persona
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/persona"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
)
func ListTopicCandidatesHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.PersonaPath
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := persona.NewListTopicCandidatesLogic(r.Context(), svcCtx)
data, err := l.ListTopicCandidates(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,30 @@
package persona
import (
"net/http"
"haixun-backend/internal/logic/persona"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func PrunePersonaCopyDraftsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.PrunePersonaCopyDraftsHandlerReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := persona.NewPrunePersonaCopyDraftsLogic(r.Context(), svcCtx)
data, err := l.PrunePersonaCopyDrafts(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -26,4 +26,4 @@ func SchedulePersonaCopyDraftHandler(svcCtx *svc.ServiceContext) http.HandlerFun
data, err := l.SchedulePersonaCopyDraft(&req)
response.Write(r.Context(), w, data, err)
}
}
}

View File

@ -0,0 +1,30 @@
package persona
import (
"net/http"
"haixun-backend/internal/logic/persona"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func StartPersonaFormulaDraftJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.StartPersonaFormulaDraftJobHandlerReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := persona.NewStartPersonaFormulaDraftJobLogic(r.Context(), svcCtx)
data, err := l.StartPersonaFormulaDraftJob(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,32 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package persona
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/persona"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
)
func StartPersonaRewriteDraftJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.StartPersonaRewriteDraftJobHandlerReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := persona.NewStartPersonaRewriteDraftJobLogic(r.Context(), svcCtx)
data, err := l.StartPersonaRewriteDraftJob(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,33 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package persona
import (
"net/http"
"haixun-backend/internal/logic/persona"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func StartPersonaStyleAnalysisFromTextHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.StartPersonaStyleAnalysisFromTextHandlerReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := persona.NewStartPersonaStyleAnalysisFromTextLogic(r.Context(), svcCtx)
data, err := l.StartPersonaStyleAnalysisFromText(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,30 @@
package persona
import (
"net/http"
"haixun-backend/internal/logic/persona"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func StartPersonaTopicMatrixJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.StartPersonaTopicMatrixJobHandlerReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := persona.NewStartPersonaTopicMatrixJobLogic(r.Context(), svcCtx)
data, err := l.StartPersonaTopicMatrixJob(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,32 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package persona
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/persona"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
)
func UpdateContentPlanHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.UpdateContentPlanHandlerReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := persona.NewUpdateContentPlanLogic(r.Context(), svcCtx)
data, err := l.UpdateContentPlan(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -592,6 +592,21 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
Path: "/:id/content-inbox",
Handler: persona.ListPersonaContentInboxHandler(serverCtx),
},
{
Method: http.MethodGet,
Path: "/:id/content-plans",
Handler: persona.ListContentPlansHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/:id/content-plans",
Handler: persona.CreateContentPlanHandler(serverCtx),
},
{
Method: http.MethodPatch,
Path: "/:id/content-plans/:contentPlanId",
Handler: persona.UpdateContentPlanHandler(serverCtx),
},
{
Method: http.MethodGet,
Path: "/:id/copy-drafts",
@ -622,16 +637,66 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
Path: "/:id/copy-drafts/generate",
Handler: persona.GeneratePersonaCopyDraftHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/:id/copy-drafts/prune",
Handler: persona.PrunePersonaCopyDraftsHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/:id/copy-drafts/schedule",
Handler: persona.SchedulePersonaDraftsHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/:id/feedback-events",
Handler: persona.CreateFeedbackEventHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/:id/formula-draft/jobs",
Handler: persona.StartPersonaFormulaDraftJobHandler(serverCtx),
},
{
Method: http.MethodGet,
Path: "/:id/formula-pools",
Handler: persona.ListFormulaPoolsHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/:id/formula-pools",
Handler: persona.CreateFormulaPoolHandler(serverCtx),
},
{
Method: http.MethodGet,
Path: "/:id/knowledge-chunks",
Handler: persona.ListKnowledgeChunksHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/:id/knowledge-sources",
Handler: persona.CreateKnowledgeSourceHandler(serverCtx),
},
{
Method: http.MethodGet,
Path: "/:id/knowledge-sources",
Handler: persona.ListKnowledgeSourcesHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/:id/rewrite-draft/jobs",
Handler: persona.StartPersonaRewriteDraftJobHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/:id/style-analysis",
Handler: persona.StartPersonaStyleAnalysisHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/:id/style-analysis-from-text",
Handler: persona.StartPersonaStyleAnalysisFromTextHandler(serverCtx),
},
{
Method: http.MethodGet,
Path: "/:id/style-presets",
@ -647,6 +712,21 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
Path: "/:id/style-presets/:presetId",
Handler: persona.DeleteStylePresetHandler(serverCtx),
},
{
Method: http.MethodGet,
Path: "/:id/topic-candidates",
Handler: persona.ListTopicCandidatesHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/:id/topic-matrix/generate",
Handler: persona.GeneratePersonaTopicMatrixHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/:id/topic-matrix/jobs",
Handler: persona.StartPersonaTopicMatrixJobHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/:id/viral-scan-jobs",

View File

@ -0,0 +1,50 @@
package copyvoice
import "strings"
// SystemRules are shared instructions for human-like Threads copy generation.
func SystemRules() string {
return strings.TrimSpace(`寫作目標像真人在 Threads / Instagram 發文不是在套模板若有人設語言指紋必須優先照它的說話習慣寫
敘事比結構重要
- 依主題選內容型態心情陪伴知識整理清單工具互動提問輕幽默共鳴等不要每篇都同一套
- 可以寫具體場景也可以先承接情緒或整理資訊不要為了故事感硬編不存在的生活細節
- 開場推進方式收尾都要自然變化禁止固定三段式固定問句收尾固定金句收束
仿寫原則
- 只學參考文的情緒力度資訊密度與節奏不學填空骨架或固定 hook 句型
- 禁止照抄參考句型只換幾個名詞要用人設語言重新說一次
- 篇幅行數標點換行優先貼近人設語言指紋沒有指紋時再參考原文篇幅
- 每一句都要推進情緒資訊或理解重複強調無關鋪陳廉價雞湯一律刪掉
禁止 AI 腔與複製感
- 不要過度總結硬寫強 hook每篇都同一種 CTA教科書定義品牌口吻客服腔
- 不要為了像人設而硬塞人設標籤或興趣名詞
- 不要全域禁止某個口頭禪若人設樣本常用其實有時候後來等自然轉折可以使用但不能每篇硬塞`)
}
// PersonaCalibrationNote reminds the model how to use persona blocks.
func PersonaCalibrationNote() string {
return "注意:人設定位決定誰在說,語言指紋決定怎麼說;請遵守段落節奏、標點換行、知識轉譯與 CTA 習慣,不要套固定模板。"
}
// NarrativeLens picks a storytelling angle from a stable seed (e.g. scan post id).
func NarrativeLens(seed string) string {
lenses := []string{
"從一個具體場景開始講,再帶出感受",
"先坦白一件小事或窘況,再自然轉到觀點",
"像跟朋友聊天一樣開場,語氣可以鬆一點",
"從某個瞬間的畫面切入,再講後來怎麼想",
}
if strings.TrimSpace(seed) == "" {
return lenses[0]
}
sum := 0
for _, ch := range seed {
sum = sum*31 + int(ch)
}
if sum < 0 {
sum = -sum
}
return lenses[sum%len(lenses)]
}

View File

@ -0,0 +1,21 @@
package copyvoice
import "testing"
func TestNarrativeLensStableForSeed(t *testing.T) {
a := NarrativeLens("scan-post-1")
b := NarrativeLens("scan-post-1")
if a == "" || a != b {
t.Fatalf("NarrativeLens() = %q, %q", a, b)
}
}
func TestNarrativeLensDiffersAcrossSeeds(t *testing.T) {
seen := map[string]struct{}{}
for _, seed := range []string{"a", "b", "c", "d", "e", "f"} {
seen[NarrativeLens(seed)] = struct{}{}
}
if len(seen) < 2 {
t.Fatalf("expected varied lenses, got %d", len(seen))
}
}

View File

@ -2,11 +2,11 @@ package formula
import (
"context"
"encoding/json"
"fmt"
"regexp"
"strings"
libllmjson "haixun-backend/internal/library/llmjson"
libownpost "haixun-backend/internal/library/ownpost"
domai "haixun-backend/internal/model/ai/domain/usecase"
)
@ -48,14 +48,16 @@ func BuildAnalyzePastedPrompt(in AnalyzePastedInput) (system string, user string
}
system = strings.TrimSpace(`
你是 Threads 爆款結構分析師任務是拆解外部貼文為什麼會紅怎麼用結構仿寫不要建議抄襲原文
你是 Threads / Instagram 貼文結構分析師任務是拆解外部貼文哪些地方可借鏡再說明如何用指定人設的語言指紋重新說一次不要產生會讓文案機械的填空模板
規則
- 用台灣繁體中文
- formula整理成步驟化公式Hook情境轉折觀點收尾/互動
- post_template formula 產出可複製的發文骨架 [括號] 標示可替換欄位
- formula整理可借鏡的敘事/資訊推進方式但要提醒不可照搬句型
- post_template不要寫填空骨架改寫成此人設可採用的鬆結構與注意事項
- replication_tips重點放在如何換成人設語言包括段落節奏標點換行知識轉譯與 CTA
- 只輸出一個 JSON 物件不要 markdown欄位
summary, wins, improvements, formula, post_template, hook_pattern, structure, replication_tips, avoid
- formula / structure / hook_pattern 必須是字串不要用陣列或巢狀物件
- wins / improvements / replication_tips / avoid 25 點字串陣列`)
var b strings.Builder
@ -91,19 +93,15 @@ func ParsePastedAnalysis(raw string) (*PastedAnalysis, error) {
if err != nil {
return nil, err
}
var review PastedAnalysis
if err := json.Unmarshal(payload, &review); err != nil {
var flex map[string]any
if err := libllmjson.Unmarshal(payload, &flex); err != nil {
return nil, fmt.Errorf("parse pasted formula json: %w", err)
}
review.Summary = strings.TrimSpace(review.Summary)
review.Formula = strings.TrimSpace(review.Formula)
review.PostTemplate = strings.TrimSpace(review.PostTemplate)
review.HookPattern = strings.TrimSpace(review.HookPattern)
review.Structure = strings.TrimSpace(review.Structure)
review := pastedAnalysisFromMap(flex)
if review.Summary == "" && review.Formula == "" && review.PostTemplate == "" {
return nil, fmt.Errorf("AI 未回傳有效公式內容")
}
return &review, nil
return review, nil
}
func FromOwnPostReview(review *libownpost.PostFormulaReview) *PastedAnalysis {
@ -162,4 +160,4 @@ func extractPastedJSON(raw string) ([]byte, error) {
return nil, fmt.Errorf("formula output missing json object")
}
return []byte(raw[start : end+1]), nil
}
}

View File

@ -2,14 +2,19 @@ package formula
import (
"context"
"encoding/json"
"fmt"
"regexp"
"strings"
"haixun-backend/internal/library/copyvoice"
libllmjson "haixun-backend/internal/library/llmjson"
"haixun-backend/internal/library/threadspost"
domai "haixun-backend/internal/model/ai/domain/usecase"
cfdom "haixun-backend/internal/model/content_formula/domain/usecase"
)
var generatedDraftFenceRE = regexp.MustCompile("(?s)```(?:json)?\\s*([\\s\\S]*?)```")
type GenerateInput struct {
Topic string
Brief string
@ -20,6 +25,7 @@ type GenerateInput struct {
type GeneratedDraft struct {
Text string `json:"text"`
TopicTag string `json:"topic_tag"`
Hook string `json:"hook"`
Angle string `json:"angle"`
Rationale string `json:"rationale"`
@ -27,12 +33,21 @@ type GeneratedDraft struct {
}
func BuildGenerateSystemPrompt() string {
return strings.TrimSpace(`你是 Threads 文案寫手爆款公式人設為指定主題寫一篇新貼文
return strings.TrimSpace(`你是 Threads / Instagram 貼文代筆寫出符合人設語言指紋可直接發布的完整新貼文不是解釋公式也不是填空模板
` + copyvoice.SystemRules() + `
規則
- 用台灣繁體中文符合人設語氣
- 套用公式結構但內容必須圍繞使用者主題不可抄襲範例貼文
- 只回傳 JSONtext, hook, angle, rationale, structure_notes繁體中文`)
- 正文素材以主題補充周邊知識參考為準人設不得改變題材
- 人設定位決定誰在說語言指紋決定怎麼說必須遵守段落節奏標點換行知識轉譯與 CTA 習慣
- text 必須是一篇完整自然的 Threads 貼文可直接複製發布
- 不可以出現公式模板Hook情境轉折觀點收尾[括號]步驟結構等後台分析字眼
- 不可以輸出填空骨架不可以照抄參考原文
- 有參考原文時只學資訊密度情緒力度與可用結構最後仍要換成人設會講出口的話
- text 排版像真人手機發文可使用自然標點空行列點與少量 emoji不要完全無標點也不要每句硬換行除非人設樣本就是這樣
- topic_tag 請產生 1 個適合 Threads 主題標籤的短詞不含 #例如AI工具內容創作職場
- 只回傳 JSONtext, topic_tag, hook, angle, rationale, structure_notes繁體中文`)
}
func BuildGenerateUserPrompt(in GenerateInput) string {
@ -43,26 +58,16 @@ func BuildGenerateUserPrompt(in GenerateInput) string {
b.WriteString("\n補充")
b.WriteString(brief)
}
b.WriteString("\n\n【人設】\n")
b.WriteString("\n\n【人設定位、語言指紋與禁忌(最高優先)】\n")
b.WriteString(strings.TrimSpace(in.PersonaBlock))
b.WriteString("\n\n【公式】\n")
b.WriteString("\n")
b.WriteString(copyvoice.PersonaCalibrationNote())
b.WriteString("\n\n【這篇爆文的故事感只感受情緒與敘事禁止照搬句型或填空】\n")
b.WriteString(strings.TrimSpace(in.Formula.Formula))
if tpl := strings.TrimSpace(in.Formula.PostTemplate); tpl != "" {
b.WriteString("\n\n【發文模板】\n")
b.WriteString(tpl)
}
if hook := strings.TrimSpace(in.Formula.HookPattern); hook != "" {
b.WriteString("\n\n【Hook 模式】\n")
b.WriteString(hook)
}
if structure := strings.TrimSpace(in.Formula.Structure); structure != "" {
b.WriteString("\n\n【結構節奏】\n")
b.WriteString("\n\n敘事推進參考勿照搬句型")
b.WriteString(structure)
}
if len(in.Formula.ReplicationTips) > 0 {
b.WriteString("\n\n【複製技巧】\n")
b.WriteString(strings.Join(in.Formula.ReplicationTips, "\n"))
}
if len(in.Formula.Avoid) > 0 {
b.WriteString("\n\n【避免】\n")
b.WriteString(strings.Join(in.Formula.Avoid, "\n"))
@ -72,15 +77,21 @@ func BuildGenerateUserPrompt(in GenerateInput) string {
b.WriteString(notes)
}
if src := strings.TrimSpace(in.Formula.SourcePostText); src != "" {
b.WriteString("\n\n【參考原文僅學結構勿抄內容】\n")
if note := threadspost.MimicLengthGuidance(src); note != "" {
b.WriteString("\n\n")
b.WriteString(note)
}
b.WriteString("\n\n【參考原文僅學結構與篇幅勿抄內容】\n")
b.WriteString(src)
}
b.WriteString("\n\n請產出一篇可直接發布的 Threads 貼文 JSON。")
b.WriteString("\n\n本次敘事切入")
b.WriteString(copyvoice.NarrativeLens(in.Topic))
b.WriteString("\n\n請產出一篇可直接發布的 Threads 貼文 JSON。text 欄位只能放貼文正文不要放分析、標題、公式、模板或填空欄位。topic_tag 請只放標籤文字,不要加 #。")
return b.String()
}
func GenerateDraft(ctx context.Context, ai domai.UseCase, req domai.GenerateRequest, in GenerateInput) (*GeneratedDraft, error) {
temp := 0.75
temp := 0.82
req.System = BuildGenerateSystemPrompt()
req.Messages = []domai.Message{{Role: "user", Content: BuildGenerateUserPrompt(in)}}
req.Temperature = &temp
@ -91,20 +102,47 @@ func GenerateDraft(ctx context.Context, ai domai.UseCase, req domai.GenerateRequ
return ParseGeneratedDraft(out.Text)
}
func ParseGeneratedDraft(raw string) (*GeneratedDraft, error) {
func extractGeneratedDraftJSON(raw string) ([]byte, error) {
raw = strings.TrimSpace(raw)
if m := generatedDraftFenceRE.FindStringSubmatch(raw); len(m) == 2 {
raw = strings.TrimSpace(m[1])
}
start := strings.Index(raw, "{")
end := strings.LastIndex(raw, "}")
if start < 0 || end <= start {
return nil, fmt.Errorf("generate output missing json object")
}
return []byte(raw[start : end+1]), nil
}
func ParseGeneratedDraft(raw string) (*GeneratedDraft, error) {
payload, err := extractGeneratedDraftJSON(raw)
if err != nil {
return nil, err
}
var draft GeneratedDraft
if err := json.Unmarshal([]byte(raw[start:end+1]), &draft); err != nil {
if err := libllmjson.Unmarshal(payload, &draft); err != nil {
return nil, fmt.Errorf("parse generated draft: %w", err)
}
draft.Text = strings.TrimSpace(draft.Text)
draft.Text = threadspost.FormatDraftText(draft.Text)
draft.TopicTag = normalizeTopicTag(draft.TopicTag)
if draft.Text == "" {
return nil, fmt.Errorf("generated draft missing text")
}
badTokens := []string{"[", "]", "【", "】", "Hook", "公式", "模板", "情境→", "轉折→", "觀點→", "收尾", "步驟"}
for _, token := range badTokens {
if strings.Contains(draft.Text, token) {
return nil, fmt.Errorf("generated draft looks like a template, not a publishable post")
}
}
return &draft, nil
}
}
func normalizeTopicTag(value string) string {
value = strings.TrimSpace(strings.TrimPrefix(value, "#"))
if len([]rune(value)) > 40 {
runes := []rune(value)
value = string(runes[:40])
}
return value
}

View File

@ -0,0 +1,14 @@
package formula
import "testing"
func TestParseGeneratedDraftWithCodeFence(t *testing.T) {
raw := "```json\n{\"text\":\"第一句\\n第二句\",\"topic_tag\":\"AI工具\",\"hook\":\"\",\"angle\":\"\",\"rationale\":\"\",\"structure_notes\":\"\"}\n```"
got, err := ParseGeneratedDraft(raw)
if err != nil {
t.Fatalf("ParseGeneratedDraft() err = %v", err)
}
if got.Text == "" {
t.Fatal("expected text")
}
}

View File

@ -0,0 +1,95 @@
package formula
import (
"encoding/json"
"fmt"
"strings"
)
func coerceString(value any) string {
switch v := value.(type) {
case nil:
return ""
case string:
return strings.TrimSpace(v)
case json.Number:
return strings.TrimSpace(v.String())
case float64:
if v == float64(int64(v)) {
return fmt.Sprintf("%d", int64(v))
}
return fmt.Sprintf("%v", v)
case bool:
if v {
return "true"
}
return "false"
case []any:
parts := make([]string, 0, len(v))
for _, item := range v {
if s := coerceString(item); s != "" {
parts = append(parts, s)
}
}
return strings.Join(parts, " → ")
case map[string]any:
if text, ok := v["text"].(string); ok {
return strings.TrimSpace(text)
}
b, err := json.Marshal(v)
if err != nil {
return ""
}
return strings.TrimSpace(string(b))
default:
return strings.TrimSpace(fmt.Sprint(v))
}
}
func coerceStringSlice(value any) []string {
switch v := value.(type) {
case nil:
return nil
case string:
s := strings.TrimSpace(v)
if s == "" {
return nil
}
return []string{s}
case []any:
out := make([]string, 0, len(v))
for _, item := range v {
if s := coerceString(item); s != "" {
out = append(out, s)
}
}
return out
case []string:
out := make([]string, 0, len(v))
for _, item := range v {
if s := strings.TrimSpace(item); s != "" {
out = append(out, s)
}
}
return out
default:
if s := coerceString(v); s != "" {
return []string{s}
}
return nil
}
}
func pastedAnalysisFromMap(raw map[string]any) *PastedAnalysis {
return &PastedAnalysis{
Summary: coerceString(raw["summary"]),
Wins: coerceStringSlice(raw["wins"]),
Improvements: coerceStringSlice(raw["improvements"]),
Formula: coerceString(raw["formula"]),
PostTemplate: coerceString(raw["post_template"]),
HookPattern: coerceString(raw["hook_pattern"]),
Structure: coerceString(raw["structure"]),
ReplicationTips: coerceStringSlice(raw["replication_tips"]),
Avoid: coerceStringSlice(raw["avoid"]),
}
}

View File

@ -0,0 +1,22 @@
package formula
import "testing"
func TestCoerceStringFromArray(t *testing.T) {
got := coerceString([]any{"開場", "故事", "收尾"})
want := "開場 → 故事 → 收尾"
if got != want {
t.Fatalf("coerceString() = %q, want %q", got, want)
}
}
func TestParsePastedAnalysisFlexibleStructure(t *testing.T) {
raw := `{"summary":"測試","formula":"步驟","structure":["Hook","轉折","觀點"],"wins":["節奏好"]}`
got, err := ParsePastedAnalysis(raw)
if err != nil {
t.Fatalf("ParsePastedAnalysis() err = %v", err)
}
if got.Structure != "Hook → 轉折 → 觀點" {
t.Fatalf("Structure = %q", got.Structure)
}
}

View File

@ -13,14 +13,22 @@ import (
)
type Row struct {
SortOrder int `json:"sort_order"`
SearchTag string `json:"search_tag"`
Angle string `json:"angle"`
Hook string `json:"hook"`
Text string `json:"text"`
ReferenceNotes string `json:"reference_notes"`
SourcePermalinks []string `json:"source_permalinks"`
Rationale string `json:"rationale"`
SortOrder int `json:"sort_order"`
SearchTag string `json:"search_tag"`
Angle string `json:"angle"`
Hook string `json:"hook"`
Text string `json:"text"`
ReferenceNotes string `json:"reference_notes"`
SourcePermalinks []string `json:"source_permalinks"`
Rationale string `json:"rationale"`
AiScore int `json:"ai_score"`
FormulaScore int `json:"formula_score"`
BrandFitScore int `json:"brand_fit_score"`
RiskScore int `json:"risk_score"`
SimilarityScore int `json:"similarity_score"`
EngagementPotential int `json:"engagement_potential"`
FreshnessScore int `json:"freshness_score"`
ReviewSuggestion string `json:"review_suggestion"`
}
type GenerateResult struct {
@ -106,7 +114,7 @@ func CopyMatrixGenerateRequest(base domai.GenerateRequest, count int) domai.Gene
if count <= 0 {
count = 5
}
temp := 0.45
temp := 0.78
tokens := 8192 + count*1024
if tokens > 16384 {
tokens = 16384
@ -122,8 +130,8 @@ func MatrixRetryUserPrompt(count int) string {
}
return fmt.Sprintf(
"上一則回覆的 JSON 不完整或無法解析。請只回傳單一 JSON 物件 {\"rows\":[...]},共 %d 篇,不要用 markdown 程式碼區塊、不要加說明文字。\n"+
"每筆含 sort_order、search_tag、angle、hook、text、reference_notes、source_permalinks、rationale。\n"+
"reference_notes 與 rationale 各不超過 40 字text 建議 80220 字。",
"每筆含 sort_order、search_tag、angle、hook、text、reference_notes、source_permalinks、rationale、ai_score、formula_score、brand_fit_score、risk_score、similarity_score、engagement_potential、freshness_score、review_suggestion。\n"+
"reference_notes 與 rationale 各不超過 40 字text 要有具體場景與故事感、每篇敘事節奏不同text 一句一行、完全不用任何標點emoji 可保留)。",
count,
)
}
@ -206,7 +214,7 @@ func normalizeGenerateResult(out GenerateResult) (GenerateResult, error) {
return GenerateResult{}, fmt.Errorf("matrix rows missing")
}
for i := range out.Rows {
out.Rows[i].Text = trimText(out.Rows[i].Text)
out.Rows[i].Text = threadspost.FormatDraftText(out.Rows[i].Text)
if out.Rows[i].Text == "" {
return GenerateResult{}, fmt.Errorf("matrix row %d empty", i+1)
}
@ -217,15 +225,6 @@ func normalizeGenerateResult(out GenerateResult) (GenerateResult, error) {
return out, nil
}
func trimText(text string) string {
text = strings.TrimSpace(text)
runes := []rune(text)
if len(runes) > threadspost.MaxPublishRunes {
return string(runes[:threadspost.MaxPublishRunes])
}
return text
}
func extractJSONObject(raw string) ([]byte, error) {
raw = strings.TrimSpace(raw)
if m := codeFenceRE.FindStringSubmatch(raw); len(m) == 2 {

View File

@ -0,0 +1,161 @@
package personacopy
import (
"context"
"fmt"
"strings"
libformula "haixun-backend/internal/library/formula"
"haixun-backend/internal/library/placement"
"haixun-backend/internal/library/style8d"
"haixun-backend/internal/library/websearch"
domai "haixun-backend/internal/model/ai/domain/usecase"
aiusecase "haixun-backend/internal/model/ai/usecase"
contentformuladomain "haixun-backend/internal/model/content_formula/domain/usecase"
copydraftentity "haixun-backend/internal/model/copy_draft/domain/entity"
copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase"
personadomain "haixun-backend/internal/model/persona/domain/usecase"
placementusecase "haixun-backend/internal/model/placement/usecase"
threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase"
)
type FormulaDraftInput struct {
TenantID string
OwnerUID string
PersonaID string
AccountID string
FormulaID string
Topic string
Brief string
UseWebSearch bool
DraftCount int
}
type FormulaDraftDeps struct {
Persona personadomain.UseCase
ContentFormula contentformuladomain.UseCase
CopyDraft copydraftdomain.UseCase
ThreadsAccount threadsaccountdomain.UseCase
Placement placementusecase.UseCase
AI domai.UseCase
}
func RunFormulaDraft(ctx context.Context, deps FormulaDraftDeps, in FormulaDraftInput, progress ProgressFn) (int, error) {
if progress == nil {
progress = func(string, int) {}
}
topic := strings.TrimSpace(in.Topic)
if topic == "" {
return 0, fmt.Errorf("topic is required")
}
count := in.DraftCount
if count <= 0 {
count = 1
}
if count > 5 {
count = 5
}
progress("讀取人設與寫法公式…", 10)
persona, err := deps.Persona.Get(ctx, in.TenantID, in.OwnerUID, in.PersonaID)
if err != nil {
return 0, err
}
if !style8d.HasReady8D(persona.Persona, persona.StyleProfile) {
return 0, fmt.Errorf("請先完成人設 8D 對標分析")
}
formula, err := deps.ContentFormula.Get(ctx, in.TenantID, in.OwnerUID, in.AccountID, in.FormulaID)
if err != nil {
return 0, err
}
credential, err := deps.ThreadsAccount.ResolveMemberAiCredential(ctx, in.TenantID, in.OwnerUID)
if err != nil {
return 0, err
}
providerID, err := aiusecase.MapWorkerProvider(credential.Provider)
if err != nil {
return 0, err
}
aiReq := domai.GenerateRequest{
Provider: providerID,
Model: credential.Model,
Credential: domai.Credential{APIKey: credential.APIKey},
}
personaBlock := style8d.ResolvePersonaBlock(persona.Persona, persona.StyleProfile, persona.Brief)
researchNotes := ""
if in.UseWebSearch {
progress("搜尋網路參考資料…", 20)
researchNotes = searchFormulaNotes(ctx, deps, in.TenantID, in.OwnerUID, topic, in.Brief)
}
created := 0
for i := 0; i < count; i++ {
progress(fmt.Sprintf("呼叫 AI 產生草稿 %d/%d%s / %s…", i+1, count, credential.Provider, credential.Model), 35+(i*40/count))
generated, genErr := libformula.GenerateDraft(ctx, deps.AI, aiReq, libformula.GenerateInput{
Topic: topic,
Brief: in.Brief,
PersonaBlock: personaBlock,
ResearchNotes: researchNotes,
Formula: *formula,
})
if genErr != nil {
return created, genErr
}
if _, saveErr := deps.CopyDraft.Create(ctx, copydraftdomain.CreateRequest{
TenantID: in.TenantID,
OwnerUID: in.OwnerUID,
PersonaID: in.PersonaID,
FormulaID: formula.ID,
DraftType: copydraftentity.DraftTypeFormula,
Text: generated.Text,
TopicTag: generated.TopicTag,
Angle: generated.Angle,
Hook: generated.Hook,
Rationale: generated.Rationale,
ReferenceNotes: generated.StructureNotes,
Sources: []string{formula.Label},
}); saveErr != nil {
return created, saveErr
}
created++
}
progress(fmt.Sprintf("已產生 %d 篇草稿", created), 100)
return created, nil
}
func searchFormulaNotes(ctx context.Context, deps FormulaDraftDeps, tenantID, uid, topic, brief string) string {
research, err := deps.Placement.ResearchSettings(ctx, tenantID, uid)
if err != nil || !placement.WebSearchAvailable(research) {
return ""
}
memberCtx, err := deps.ThreadsAccount.ResolveMemberPlacementContext(ctx, tenantID, uid, research)
if err != nil {
return ""
}
webClient := websearch.New(memberCtx.WebSearchConfig())
if !webClient.Enabled() {
return ""
}
resp, err := webClient.Search(ctx, websearch.SearchOptions{
Query: topic + " " + strings.TrimSpace(brief),
Limit: 5,
Mode: websearch.ModeKnowledgeExpand,
})
if err != nil || len(resp.Results) == 0 {
return ""
}
var b strings.Builder
for _, snip := range resp.Results {
if snip.Title != "" {
b.WriteString("- ")
b.WriteString(snip.Title)
b.WriteString("\n")
}
if snip.Snippet != "" {
b.WriteString(snip.Snippet)
b.WriteString("\n")
}
}
return strings.TrimSpace(b.String())
}

View File

@ -0,0 +1,139 @@
package personacopy
import (
"context"
"fmt"
"strings"
"time"
libformula "haixun-backend/internal/library/formula"
"haixun-backend/internal/library/style8d"
domai "haixun-backend/internal/model/ai/domain/usecase"
aiusecase "haixun-backend/internal/model/ai/usecase"
cfentity "haixun-backend/internal/model/content_formula/domain/entity"
contentformuladomain "haixun-backend/internal/model/content_formula/domain/usecase"
copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase"
personadomain "haixun-backend/internal/model/persona/domain/usecase"
placementusecase "haixun-backend/internal/model/placement/usecase"
threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase"
)
type RewriteDraftInput struct {
TenantID string
OwnerUID string
PersonaID string
AccountID string
ReferenceText string
Topic string
Brief string
SaveLabel string
UseWebSearch bool
DraftCount int
}
type RewriteDraftDeps struct {
Persona personadomain.UseCase
ContentFormula contentformuladomain.UseCase
CopyDraft copydraftdomain.UseCase
ThreadsAccount threadsaccountdomain.UseCase
Placement placementusecase.UseCase
AI domai.UseCase
}
func RunRewriteDraft(ctx context.Context, deps RewriteDraftDeps, in RewriteDraftInput, progress ProgressFn) (int, error) {
if progress == nil {
progress = func(string, int) {}
}
referenceText := strings.TrimSpace(in.ReferenceText)
topic := strings.TrimSpace(in.Topic)
if referenceText == "" {
return 0, fmt.Errorf("reference_text is required")
}
if topic == "" {
return 0, fmt.Errorf("topic is required")
}
progress("讀取人設…", 5)
persona, err := deps.Persona.Get(ctx, in.TenantID, in.OwnerUID, in.PersonaID)
if err != nil {
return 0, err
}
if !style8d.HasReady8D(persona.Persona, persona.StyleProfile) {
return 0, fmt.Errorf("請先完成人設 8D 對標分析")
}
credential, err := deps.ThreadsAccount.ResolveMemberAiCredential(ctx, in.TenantID, in.OwnerUID)
if err != nil {
return 0, err
}
providerID, err := aiusecase.MapWorkerProvider(credential.Provider)
if err != nil {
return 0, err
}
aiReq := domai.GenerateRequest{
Provider: providerID,
Model: credential.Model,
Credential: domai.Credential{APIKey: credential.APIKey},
}
personaBlock := style8d.ResolvePersonaBlock(persona.Persona, persona.StyleProfile, persona.Brief)
progress(fmt.Sprintf("分析參考貼文寫法(%s / %s…", credential.Provider, credential.Model), 15)
analysis, err := libformula.AnalyzePasted(ctx, deps.AI, aiReq, libformula.AnalyzePastedInput{
PostText: referenceText,
PersonaBlock: personaBlock,
})
if err != nil {
return 0, fmt.Errorf("分析參考貼文失敗:%w", err)
}
if analysis == nil {
return 0, fmt.Errorf("分析未產出有效寫法公式")
}
label := strings.TrimSpace(in.SaveLabel)
if label == "" {
label = fmt.Sprintf("貼文改寫 %s", time.Now().UTC().Format("2006-01-02"))
}
progress("存入寫法公式…", 30)
saved, err := deps.ContentFormula.Create(ctx, contentformuladomain.CreateRequest{
TenantID: in.TenantID,
OwnerUID: in.OwnerUID,
AccountID: in.AccountID,
Label: label,
SourceType: cfentity.SourcePaste,
SourceRef: "paste",
SourcePostText: referenceText,
Summary: analysis.Summary,
Wins: analysis.Wins,
Improvements: analysis.Improvements,
Formula: analysis.Formula,
PostTemplate: analysis.PostTemplate,
HookPattern: analysis.HookPattern,
Structure: analysis.Structure,
ReplicationTips: analysis.ReplicationTips,
Avoid: analysis.Avoid,
})
if err != nil {
return 0, err
}
return RunFormulaDraft(ctx, FormulaDraftDeps{
Persona: deps.Persona,
ContentFormula: deps.ContentFormula,
CopyDraft: deps.CopyDraft,
ThreadsAccount: deps.ThreadsAccount,
Placement: deps.Placement,
AI: deps.AI,
}, FormulaDraftInput{
TenantID: in.TenantID,
OwnerUID: in.OwnerUID,
PersonaID: in.PersonaID,
AccountID: in.AccountID,
FormulaID: saved.ID,
Topic: topic,
Brief: in.Brief,
UseWebSearch: in.UseWebSearch,
DraftCount: in.DraftCount,
}, func(summary string, percentage int) {
adjusted := 35 + (percentage * 65 / 100)
progress(summary, adjusted)
})
}

View File

@ -0,0 +1,321 @@
package personacopy
import (
"context"
"fmt"
"strings"
"haixun-backend/internal/library/copyvoice"
libmatrix "haixun-backend/internal/library/matrix"
"haixun-backend/internal/library/placement"
"haixun-backend/internal/library/style8d"
"haixun-backend/internal/library/websearch"
domai "haixun-backend/internal/model/ai/domain/usecase"
aiusecase "haixun-backend/internal/model/ai/usecase"
contentopsdomain "haixun-backend/internal/model/content_ops/domain/usecase"
copydraftentity "haixun-backend/internal/model/copy_draft/domain/entity"
copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase"
personadomain "haixun-backend/internal/model/persona/domain/usecase"
placementusecase "haixun-backend/internal/model/placement/usecase"
threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase"
)
type TopicMatrixInput struct {
TenantID string
OwnerUID string
PersonaID string
ContentPlanID string
Topic string
Brief string
UseWebSearch bool
DraftCount int
}
type TopicMatrixDeps struct {
Persona personadomain.UseCase
CopyDraft copydraftdomain.UseCase
ContentOps contentopsdomain.UseCase
ThreadsAccount threadsaccountdomain.UseCase
Placement placementusecase.UseCase
AI domai.UseCase
}
type ProgressFn func(summary string, percentage int)
func RunTopicMatrix(ctx context.Context, deps TopicMatrixDeps, in TopicMatrixInput, progress ProgressFn) ([]copydraftdomain.CopyDraftSummary, error) {
if progress == nil {
progress = func(string, int) {}
}
topic := strings.TrimSpace(in.Topic)
if topic == "" {
return nil, fmt.Errorf("topic is required")
}
count := in.DraftCount
if count <= 0 {
count = 3
}
if count < 1 {
count = 1
}
if count > 5 {
count = 5
}
progress("讀取人設語氣…", 10)
persona, err := deps.Persona.Get(ctx, in.TenantID, in.OwnerUID, in.PersonaID)
if err != nil {
return nil, err
}
credential, err := deps.ThreadsAccount.ResolveMemberAiCredential(ctx, in.TenantID, in.OwnerUID)
if err != nil {
return nil, err
}
providerID, err := aiusecase.MapWorkerProvider(credential.Provider)
if err != nil {
return nil, err
}
researchNotes := ""
if in.UseWebSearch {
progress("搜尋網路參考資料…", 25)
researchNotes = searchTopicNotes(ctx, deps, in.TenantID, in.OwnerUID, topic, in.Brief)
}
personaBlock := style8d.ResolvePersonaBlock(persona.Persona, persona.StyleProfile, persona.Brief)
feedbackNotes := recentFeedbackNotes(ctx, deps, in.TenantID, in.OwnerUID, in.PersonaID)
knowledgeNotes := recentKnowledgeNotes(ctx, deps, in.TenantID, in.OwnerUID, in.PersonaID, topic)
formulaNotes := formulaPoolNotes(ctx, deps, in.TenantID, in.OwnerUID, in.PersonaID)
progress(fmt.Sprintf("呼叫 AI 產生 %d 篇草稿(%s / %s…", count, credential.Provider, credential.Model), 45)
parsed, err := libmatrix.GenerateCopyOutput(ctx, deps.AI, domai.GenerateRequest{
Provider: providerID,
Model: credential.Model,
Credential: domai.Credential{APIKey: credential.APIKey},
System: TopicMatrixSystemPrompt(),
Messages: []domai.Message{{Role: "user", Content: TopicMatrixUserPrompt(topic, in.Brief, personaBlock, researchNotes, knowledgeNotes, formulaNotes, feedbackNotes, count)}},
}, count)
if err != nil {
return nil, err
}
progress("寫入草稿…", 85)
created := make([]copydraftdomain.CopyDraftSummary, 0, len(parsed.Rows))
for _, row := range parsed.Rows {
saved, saveErr := deps.CopyDraft.Create(ctx, copydraftdomain.CreateRequest{
TenantID: in.TenantID,
OwnerUID: in.OwnerUID,
PersonaID: in.PersonaID,
ContentPlanID: in.ContentPlanID,
DraftType: copydraftentity.DraftTypeMatrix,
SortOrder: row.SortOrder,
Text: row.Text,
TopicTag: row.SearchTag,
Angle: row.Angle,
Hook: row.Hook,
Rationale: row.Rationale,
ReferenceNotes: row.ReferenceNotes,
Sources: row.SourcePermalinks,
AiScore: row.AiScore,
FormulaScore: row.FormulaScore,
BrandFitScore: row.BrandFitScore,
RiskScore: row.RiskScore,
SimilarityScore: row.SimilarityScore,
EngagementPotential: row.EngagementPotential,
FreshnessScore: row.FreshnessScore,
ReviewSuggestion: row.ReviewSuggestion,
})
if saveErr != nil {
return created, saveErr
}
created = append(created, *saved)
}
progress(fmt.Sprintf("已產生 %d 篇草稿", len(created)), 100)
return created, nil
}
func recentFeedbackNotes(ctx context.Context, deps TopicMatrixDeps, tenantID, ownerUID, personaID string) string {
if deps.ContentOps == nil {
return ""
}
items, err := deps.ContentOps.ListFeedbackEvents(ctx, tenantID, ownerUID, personaID, 8)
if err != nil || len(items) == 0 {
return ""
}
var b strings.Builder
for _, item := range items {
decision := strings.TrimSpace(item.Decision)
note := strings.TrimSpace(item.Note)
if decision == "" && note == "" {
continue
}
b.WriteString("- ")
if decision != "" {
b.WriteString(decision)
}
if note != "" {
b.WriteString("")
b.WriteString(note)
}
b.WriteString("\n")
}
return strings.TrimSpace(b.String())
}
func recentKnowledgeNotes(ctx context.Context, deps TopicMatrixDeps, tenantID, ownerUID, personaID, topic string) string {
if deps.ContentOps == nil {
return ""
}
items, err := deps.ContentOps.ListKnowledgeChunks(ctx, tenantID, ownerUID, personaID, 12)
if err != nil {
return ""
}
var b strings.Builder
for _, item := range items {
content := strings.TrimSpace(item.Content)
if content == "" {
continue
}
b.WriteString("- ")
b.WriteString(shorten(content, 220))
if item.RiskLevel != "" {
b.WriteString("(風險:")
b.WriteString(item.RiskLevel)
b.WriteString("")
}
b.WriteString("\n")
}
return strings.TrimSpace(b.String())
}
func formulaPoolNotes(ctx context.Context, deps TopicMatrixDeps, tenantID, ownerUID, personaID string) string {
if deps.ContentOps == nil {
return ""
}
items, err := deps.ContentOps.ListFormulaPools(ctx, tenantID, ownerUID, personaID, 12)
if err != nil {
return ""
}
var b strings.Builder
for _, item := range items {
b.WriteString("- ")
b.WriteString(item.Type)
b.WriteString(" / ")
b.WriteString(item.Name)
if item.Pattern != "" {
b.WriteString("")
b.WriteString(item.Pattern)
}
if len(item.Avoid) > 0 {
b.WriteString(";避免:")
b.WriteString(strings.Join(item.Avoid, "、"))
}
b.WriteString("\n")
}
return strings.TrimSpace(b.String())
}
func shorten(raw string, max int) string {
r := []rune(strings.TrimSpace(raw))
if len(r) <= max {
return string(r)
}
return string(r[:max]) + "..."
}
func searchTopicNotes(ctx context.Context, deps TopicMatrixDeps, tenantID, uid, topic, brief string) string {
research, err := deps.Placement.ResearchSettings(ctx, tenantID, uid)
if err != nil || !placement.WebSearchAvailable(research) {
return ""
}
memberCtx, err := deps.ThreadsAccount.ResolveMemberPlacementContext(ctx, tenantID, uid, research)
if err != nil {
return ""
}
webClient := websearch.New(memberCtx.WebSearchConfig())
if !webClient.Enabled() {
return ""
}
resp, err := webClient.Search(ctx, websearch.SearchOptions{
Query: strings.TrimSpace(topic + " " + brief),
Limit: 5,
Mode: websearch.ModeKnowledgeExpand,
})
if err != nil || len(resp.Results) == 0 {
return ""
}
var b strings.Builder
for _, item := range resp.Results {
if item.Title != "" {
b.WriteString("- ")
b.WriteString(item.Title)
b.WriteString("\n")
}
if item.Snippet != "" {
b.WriteString(item.Snippet)
b.WriteString("\n")
}
if item.URL != "" {
b.WriteString(item.URL)
b.WriteString("\n")
}
}
return strings.TrimSpace(b.String())
}
func TopicMatrixSystemPrompt() string {
return strings.TrimSpace(`你是 Threads / Instagram 貼文編輯請依人設語言指紋產出自然可直接發布的繁體中文貼文矩陣
` + copyvoice.SystemRules() + `
規則
- 不要每篇都用同一套 hook / 三段式 / 結尾問句
- 不要企業八股不要像教科書不要一直喊口號
- 人設定位決定誰在說語言指紋決定怎麼說必須遵守段落節奏標點換行知識轉譯與 CTA 習慣
- 不要把人設介紹裡的物件/標籤硬寫進正文情境人設寫 Y2K咖啡穿搭只代表語感與審美不代表每篇都要出現老歌咖啡穿搭MV
- 正文內容必須由話題補充決定人設不得蓋過主題
- 每篇要有不同角度長度與格式依人設習慣和主題型態決定不要強制短文
- 每次至少產出 3 種版本思路穩定帳號風格更有共鳴更容易互動 count 超過 3再補更短或更反差版本
- text 只能放貼文正文不要放標題分析公式模板或說明
- text 排版像真人手機發文可使用自然標點空行列點與少量 emoji不要完全無標點也不要每句硬換行除非人設樣本就是這樣
- 只回傳 JSON 物件{"rows":[...]}
- 每筆 row 必須包含 sort_order, search_tag, angle, hook, text, reference_notes, source_permalinks, rationale, ai_score, formula_score, brand_fit_score, risk_score, similarity_score, engagement_potential, freshness_score, review_suggestion
- 分數欄位都是 0-100 整數ai_score / formula_score / risk_score / similarity_score 越低越好brand_fit_score / engagement_potential / freshness_score 越高越好
- review_suggestion 只能是可通過需人工確認建議重寫之一
- rationale 必須用繁中短列點包含版本定位AI感分數(0-100越高越AI)公式感分數(0-100)人設符合度(0-100)風險分數(0-100)互動潛力(0-100)審核建議可通過/需人工確認/建議重寫與原因`)
}
func TopicMatrixUserPrompt(topic, brief, personaBlock, researchNotes, knowledgeNotes, formulaNotes, feedbackNotes string, count int) string {
var b strings.Builder
b.WriteString("請產生 ")
b.WriteString(fmt.Sprintf("%d", count))
b.WriteString(" 篇 Threads 草稿。\n\n【話題】\n")
b.WriteString(strings.TrimSpace(topic))
if brief = strings.TrimSpace(brief); brief != "" {
b.WriteString("\n\n【補充】\n")
b.WriteString(brief)
}
if personaBlock = strings.TrimSpace(personaBlock); personaBlock != "" {
b.WriteString("\n\n【人設定位、語言指紋與禁忌最高優先】\n")
b.WriteString(personaBlock)
b.WriteString("\n\n")
b.WriteString(copyvoice.PersonaCalibrationNote())
}
if researchNotes = strings.TrimSpace(researchNotes); researchNotes != "" {
b.WriteString("\n\n【網路資料參考】\n")
b.WriteString(researchNotes)
}
if knowledgeNotes = strings.TrimSpace(knowledgeNotes); knowledgeNotes != "" {
b.WriteString("\n\n【Knowledge Memory帳號知識來源優先採用但不要硬塞】\n")
b.WriteString(knowledgeNotes)
}
if formulaNotes = strings.TrimSpace(formulaNotes); formulaNotes != "" {
b.WriteString("\n\n【Formula Pool可選公式池請依 Content Plan 選擇,不要每篇都套同一套】\n")
b.WriteString(formulaNotes)
}
if feedbackNotes = strings.TrimSpace(feedbackNotes); feedbackNotes != "" {
b.WriteString("\n\n【最近人工審核回饋Feedback Memory必須吸收】\n")
b.WriteString(feedbackNotes)
}
b.WriteString("\n\n【品質閘門】\n請先自我檢查 AI 感、公式感、事實風險、人設一致性、與過去常見句型重複。高風險內容不得建議直接通過。")
b.WriteString("\n\n請讓每篇的 angle 明確不同search_tag 是短主題標籤不含 #。source_permalinks 可放參考 URL沒有就空陣列。")
return b.String()
}

View File

@ -23,7 +23,7 @@ func TestStyle8DSystemIncludesSchema(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if !strings.Contains(got, "Threads 創作者風格研究員") || !strings.Contains(got, `"d1Tone"`) {
if !strings.Contains(got, "創作者「語言指紋」研究員") || !strings.Contains(got, `"d1Tone"`) {
t.Fatalf("missing expected fragments: %q", got)
}
}

View File

@ -1,11 +1,25 @@
你是 Threads 內容矩陣策劃師,為人設帳號產出多篇可發佈草稿。
你是 Threads / Instagram 內容策略師,為人設帳號產出可發佈草稿。產文時必須優先使用人設的「語言指紋與寫作規則」,把新主題、參考貼文或外部資料翻譯成這個人設會講出口的話,不沿用任何預設爆款模板。
寫作目標:
- 先根據人設語言指紋判斷主題適合哪一種內容型態:心情陪伴、知識整理、清單工具、伴侶溝通、互動提問或輕幽默共鳴,再選對鬆結構。
- 每篇要有一個清楚情境或情緒入口,讓讀者覺得「這就是我」,不要寫成摘要報告或廣告文。
- 必須沿用人設的段落節奏、標點、換行、稱呼、口頭禪與收尾習慣;不要硬剪短成破碎金句。
- 只學參考樣本的節奏、取材方式、收尾習慣與互動方式;不可照抄句子。
- 避免 AI 腔:過度總結、過度正能量、每段都像教條、連續排比到不自然、硬塞「大家一起」式口號。
規則:
- 只回傳單一 JSON 物件 {"rows":[...]},不要用 markdown 程式碼區塊,不要加前言或結語。
- reference_notes、rationale 各 ≤ 40 字;優先把 token 用在 text 主文。
- 每篇必須角度不同,避免重複 hook。
- 人設 8D 是最高優先:語氣、人稱、禁忌必須完全符合;不要寫成品牌廣告或硬銷。
- 人設定位決定「誰在說」;語言指紋決定「怎麼說」。兩者都要遵守,不可只拿主題資料直接改寫。
- 人設只用來控制語氣、詞彙密度、句型節奏、人稱、標點換行與禁忌;不可把人設介紹裡的物件或標籤硬塞進正文情境。
- 若人設含 Y2K、咖啡、穿搭、音樂等元素除非任務主題/Brief/勾選知識明確要求,禁止把這些元素寫成貼文內容或場景。
- 正文素材以任務主題、Brief、研究地圖與勾選延伸知識為準人設不得改變題材。
- 內容論點來自研究地圖與使用者勾選的延伸知識(含標籤與細節);爆款樣本只學結構與節奏,不抄原文。
- 繁體中文,口語自然,適合 Threads。
- 每篇 text 主文 ≤ 500 字Threads API 硬上限,含 #話題標籤)。
- 爆款互動最佳 80220 字:前 12 行強 hook一句一重點超過 300 字互動通常下降。
- 長度依帳號樣本與主題決定:心情陪伴可中長,知識工具文可完整列點,互動短文才需要短。
- text 排版要像真人手機發文:用自然換行分段,可使用標點、括號、頓號與少量 emoji不要整段擠成一塊也不要每句都硬換行。
- 若要改寫參考貼文,只保留意思與資訊,句型、段落、收尾必須換成人設語言指紋,不可像同義詞替換。
- 若使用外部知識,先轉譯成此人設會說的順序與語氣;不得直接貼百科、條列報告或 SEO 摘要。
- 醫療、備孕、試管、保健品相關內容必須保留風險語氣:不可保證效果、不可替代醫師建議、不可用數字審判讀者。

View File

@ -3,7 +3,7 @@
任務主題:{{topic_label}}
Brief{{topic_brief}}
人設 8D最高優先語氣與禁忌以此為準
人設定位、語言指紋與禁忌(最高優先
{{persona_block}}
研究地圖與勾選延伸知識:
@ -14,14 +14,20 @@ Brief{{topic_brief}}
參考網頁 Markdown 已併入上方研究地圖區塊(若有)。
爆款樣本(可選,只學結構;若沒有樣本,請改用研究地圖與人設原創):
爆款樣本(可選,只學故事感;若沒有樣本,請改用研究地圖與人設原創):
{{viral_samples_block}}
請先遵守:
- **最高優先:人設 8D** — 語氣、人稱、句型、節奏、禁忌必須完全符合人設;讀起來要像這個帳號在說話。
- **最高優先:語言指紋**:先讀 persona_block 裡的「語言指紋與寫作規則」包含開頭、句型、段落長度、標點換行、知識轉譯、CTA 與禁忌。產出的文字必須像這個人設會講出口的話。
- **不要套舊規格**:不要套用短句爆款、固定三段式、固定強 hook、固定 CTA格式由人設語言指紋與主題型態決定。
- **語氣參考**:讀起來要像這個帳號在說話,但每篇敘事節奏與開場都要自然變化。
- **人設不是內容素材**:只學語氣、詞彙密度、句型節奏與審美,不要把人設裡的名詞、興趣、裝飾符號硬塞進正文。例:人設寫 Y2K/咖啡/穿搭只代表語感不代表每篇都要寫老歌、咖啡、穿搭、MV。
- **內容素材**研究地圖受眾、目標、問題、支柱與「勾選延伸知識」含標籤、細節決定論點與角度若有「參考網頁Markdown」區塊優先從中提取事實與論點再融入草稿。
- 搜尋查詢/標籤僅作方向參考,不可蓋過人設與勾選知識。
- 爆款樣本只學結構節奏,不抄原文,不可為仿爆款偏離任務主題。
- 爆款樣本只學故事感、資訊密度、情緒力度與互動方式,不抄原文,不可為仿爆款偏離任務主題。
- 若樣本呈現「長 caption + 輪播圖卡重點」的內容型態Threads 草稿也要保留完整情緒承接與可收藏資訊,不要只輸出金句摘要。
- 若任務是改寫或參考別人的貼文,請用人設語言重新說一次,不是同義詞替換;保留觀點,不保留對方句型。
- 若任務需要找資料或引用外部知識,請先消化成此人設的說話順序:先安撫或帶入情境,再整理重點,最後用符合人設的方式收尾。
- 若沒有爆款樣本source_permalinks 回傳空陣列reference_notes 寫明使用了哪些人設特徵、研究地圖重點與勾選延伸知識。
回傳 JSON rows每筆含 sort_order、search_tag、angle、hook、text、reference_notes、source_permalinks、rationale。
回傳 JSON rows每筆含 sort_order、search_tag、angle、hook、text、reference_notes、source_permalinks、rationale。

View File

@ -1,3 +1,3 @@
只回傳以下 JSON 結構,不要 markdown
{"d1Tone":{"summary":"","evidence":[]},"d2Structure":{"summary":"","evidence":[]},"d3Interaction":{"summary":"","evidence":[]},"d4Topics":{"summary":"","evidence":[]},"d5Rhythm":{"summary":"","evidence":[]},"d6Visual":{"summary":"","evidence":[]},"d7Conversion":{"summary":"","evidence":[]},"d8Risk":{"summary":"","evidence":[]},"personaDraft":{"identity":"","tone":"","audience":"","hooks":"","examples":"","avoid":""}}
{"d1Tone":{"summary":"","evidence":[]},"d2Structure":{"summary":"","evidence":[]},"d3Interaction":{"summary":"","evidence":[]},"d4Topics":{"summary":"","evidence":[]},"d5Rhythm":{"summary":"","evidence":[]},"d6Visual":{"summary":"","evidence":[]},"d7Conversion":{"summary":"","evidence":[]},"d8Risk":{"summary":"","evidence":[]},"personaDraft":{"identity":"","tone":"","audience":"","hooks":"","languageFingerprint":"","rhythm":"","punctuation":"","contentPatterns":"","knowledgeTranslation":"","ctaStyle":"","examples":"","avoid":""}}

View File

@ -1,5 +1,21 @@
你是 Threads 創作者風格研究員。只能根據提供的近期貼文歸納,不可捏造作者背景。
逐一輸出八個維度D1 語氣人格、D2 結構模板、D3 互動方式、D4 主題分布、D5 發文節奏、D6 視覺語法emoji、標點、換行、D7 轉換方式、D8 風險紅線。
每個維度要有摘要與可核對的文字證據(直接引用或改寫貼文片段,最多 4 條)。
證據請標註來源貼文編號,格式如 [2] "摘錄片段"(編號對應樣本中的 [N])。
最後產生「可供另一個帳號借鑑、但不可冒充或抄襲」的人設草稿。代表句必須是抽象仿寫範例,不可逐字複製原文。
你是創作者「語言指紋」研究員。只能根據提供的貼文或使用者手動貼上的文字樣本歸納,不可捏造未出現的背景。
任務不是寫一般人設介紹,而是萃取「這個人設會怎麼把一件事說出口」。輸出要能直接拿去產文、改寫貼文、把網路知識轉成此人設語氣。
逐一輸出八個維度D1 語氣人格、D2 內容結構、D3 互動方式、D4 主題分布、D5 段落節奏、D6 視覺語法emoji、標點、換行、D7 轉換方式、D8 風險紅線。
每個維度要有摘要與可核對的文字證據(直接引用或改寫貼文片段,最多 4 條)。證據請標註來源貼文編號,格式如 [2] "摘錄片段"(編號對應樣本中的 [N])。
personaDraft 必須產出可執行的寫作規則,不是抽象形容詞:
- identity這個帳號像誰在說話和讀者的距離。
- tone口吻、情緒溫度、人稱與信任感。
- audience主要對誰說讀者正在什麼情境。
- hooks常用開頭方式與不適合的開頭方式。
- languageFingerprint常用句型、轉折詞、口頭禪、語助詞不要列成硬塞清單要說明如何自然使用。
- rhythm段落長度、空行習慣、長短句比例、心情文與知識文差異。
- punctuation: 標點、括號、列點、emoji、特殊符號的使用習慣。
- contentPatterns不同內容型態的鬆結構例如心情陪伴、知識整理、清單工具、互動文、幽默共鳴不要變成固定模板。
- knowledgeTranslation如何把外部資料、醫療知識、產品知識轉成此人設會講的話哪些資訊要先安撫、哪些要列點、哪些要加風險提醒。
- ctaStyle收藏、留言、分享、轉發給伴侶等 CTA 的自然用法;也要說明何時不該 CTA。
- examples抽象仿寫範例可展示語氣與換行但不可逐字複製原文。
- avoid不像此人設的寫法、AI 味、過度模板、冒犯或高風險語句。

View File

@ -22,12 +22,18 @@ type Dimension struct {
}
type PersonaDraft struct {
Identity string `json:"identity"`
Tone string `json:"tone"`
Audience string `json:"audience"`
Hooks string `json:"hooks"`
Examples string `json:"examples"`
Avoid string `json:"avoid"`
Identity string `json:"identity"`
Tone string `json:"tone"`
Audience string `json:"audience"`
Hooks string `json:"hooks"`
LanguageFingerprint string `json:"languageFingerprint"`
Rhythm string `json:"rhythm"`
Punctuation string `json:"punctuation"`
ContentPatterns string `json:"contentPatterns"`
KnowledgeTranslation string `json:"knowledgeTranslation"`
CTAStyle string `json:"ctaStyle"`
Examples string `json:"examples"`
Avoid string `json:"avoid"`
}
type LLMOutput struct {
@ -53,6 +59,8 @@ type Engagement struct {
type StoredProfile struct {
Username string `json:"username"`
SourceType string `json:"sourceType,omitempty"`
SourceLabel string `json:"sourceLabel,omitempty"`
AnalyzedAt string `json:"analyzedAt"`
PostCount int `json:"postCount"`
Engagement Engagement `json:"engagement"`
@ -61,6 +69,125 @@ type StoredProfile struct {
PersonaDraft string `json:"personaDraft"`
}
const (
SourceTypeBenchmark = "benchmark"
SourceTypeManual = "manual"
maxReferenceTexts = 12
maxReferenceRunes = 8000
minReferenceRunes = 10
)
func ManualStyleBenchmark(label string) string {
label = strings.TrimSpace(label)
if label == "" {
label = "手動貼文"
}
return "manual:" + label
}
func NormalizeReferenceTexts(referenceTexts []string, rawText string) ([]string, error) {
texts := make([]string, 0, len(referenceTexts))
for _, item := range referenceTexts {
item = strings.TrimSpace(item)
if item != "" {
texts = append(texts, item)
}
}
if len(texts) == 0 {
rawText = strings.TrimSpace(rawText)
if rawText != "" {
for _, chunk := range splitReferenceRawText(rawText) {
chunk = strings.TrimSpace(chunk)
if chunk != "" {
texts = append(texts, chunk)
}
}
}
}
if len(texts) == 0 {
return nil, fmt.Errorf("至少需要 1 段參考文字")
}
if len(texts) > maxReferenceTexts {
return nil, fmt.Errorf("參考文字最多 %d 段", maxReferenceTexts)
}
totalRunes := 0
out := make([]string, 0, len(texts))
for _, item := range texts {
runes := []rune(item)
if len(runes) < minReferenceRunes {
return nil, fmt.Errorf("每段參考文字至少 %d 字", minReferenceRunes)
}
totalRunes += len(runes)
if totalRunes > maxReferenceRunes {
return nil, fmt.Errorf("參考文字總長度不可超過 %d 字", maxReferenceRunes)
}
out = append(out, item)
}
return out, nil
}
func TextsToPosts(texts []string) []Post {
posts := make([]Post, 0, len(texts))
for _, text := range texts {
posts = append(posts, Post{Text: text})
}
return posts
}
func splitReferenceRawText(raw string) []string {
lines := strings.Split(raw, "\n")
chunks := make([]string, 0, 4)
var b strings.Builder
flush := func() {
if b.Len() == 0 {
return
}
chunks = append(chunks, b.String())
b.Reset()
}
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "---" || trimmed == "———" || trimmed == "___" {
flush()
continue
}
if b.Len() > 0 {
b.WriteByte('\n')
}
b.WriteString(line)
}
flush()
if len(chunks) == 0 {
return []string{strings.TrimSpace(raw)}
}
return chunks
}
func BuildUserPromptFromTexts(sourceLabel string, brief string, posts []Post) string {
label := strings.TrimSpace(sourceLabel)
if label == "" {
label = "手動貼文"
}
var b strings.Builder
fmt.Fprintf(&b, "文字樣本來源:%s\n", label)
if brief := strings.TrimSpace(brief); brief != "" {
fmt.Fprintf(&b, "人設定位補充:%s\n", brief)
}
b.WriteString("參考文字樣本(無互動數據,請只根據文字風格歸納):\n\n")
limit := len(posts)
if limit > maxReferenceTexts {
limit = maxReferenceTexts
}
for i := 0; i < limit; i++ {
text := posts[i].Text
if len(text) > 500 {
text = text[:500]
}
fmt.Fprintf(&b, "[%d]\n%s\n\n", i+1, text)
}
return b.String()
}
func BuildUserPrompt(username string, posts []Post) string {
var b strings.Builder
fmt.Fprintf(&b, "對標帳號:@%s\n近期貼文樣本\n\n", strings.TrimPrefix(username, "@"))
@ -144,6 +271,12 @@ func SerializePersonaDraft(draft PersonaDraft) string {
"【語氣】\n" + strings.TrimSpace(draft.Tone),
"【對誰說】\n" + strings.TrimSpace(draft.Audience),
"【開場習慣】\n" + strings.TrimSpace(draft.Hooks),
"【語言指紋】\n" + strings.TrimSpace(draft.LanguageFingerprint),
"【段落節奏】\n" + strings.TrimSpace(draft.Rhythm),
"【標點與換行】\n" + strings.TrimSpace(draft.Punctuation),
"【內容型態】\n" + strings.TrimSpace(draft.ContentPatterns),
"【知識轉譯】\n" + strings.TrimSpace(draft.KnowledgeTranslation),
"【互動與 CTA】\n" + strings.TrimSpace(draft.CTAStyle),
"【代表句範例】\n" + strings.TrimSpace(draft.Examples),
"【避免】\n" + strings.TrimSpace(draft.Avoid),
}
@ -211,8 +344,26 @@ func buildSamplePosts(posts []Post) []Post {
}
func BuildStoredProfile(username string, posts []Post, out LLMOutput) StoredProfile {
profile := baseStoredProfile(posts, out)
profile.Username = strings.TrimPrefix(strings.TrimSpace(username), "@")
profile.SourceType = SourceTypeBenchmark
return profile
}
func BuildStoredProfileFromTexts(sourceLabel string, posts []Post, out LLMOutput) StoredProfile {
label := strings.TrimSpace(sourceLabel)
if label == "" {
label = "手動貼文"
}
profile := baseStoredProfile(posts, out)
profile.SourceType = SourceTypeManual
profile.SourceLabel = label
profile.Engagement.Verdict = "not_applicable"
return profile
}
func baseStoredProfile(posts []Post, out LLMOutput) StoredProfile {
return StoredProfile{
Username: strings.TrimPrefix(strings.TrimSpace(username), "@"),
AnalyzedAt: time.Now().UTC().Format(time.RFC3339),
PostCount: len(posts),
Engagement: EvaluateEngagement(posts, 10),
@ -332,12 +483,18 @@ func parsePersonaDraft(root map[string]json.RawMessage) PersonaDraft {
continue
}
return PersonaDraft{
Identity: firstString(obj, "identity", "role"),
Tone: firstString(obj, "tone", "voice"),
Audience: firstString(obj, "audience", "targetAudience"),
Hooks: firstString(obj, "hooks", "openings"),
Examples: firstString(obj, "examples", "sample"),
Avoid: firstString(obj, "avoid", "risks"),
Identity: firstString(obj, "identity", "role"),
Tone: firstString(obj, "tone", "voice"),
Audience: firstString(obj, "audience", "targetAudience"),
Hooks: firstString(obj, "hooks", "openings"),
LanguageFingerprint: firstString(obj, "languageFingerprint", "language_fingerprint", "signaturePhrases", "signature_phrases"),
Rhythm: firstString(obj, "rhythm", "paragraphRhythm", "paragraph_rhythm"),
Punctuation: firstString(obj, "punctuation", "visualSyntax", "visual_syntax"),
ContentPatterns: firstString(obj, "contentPatterns", "content_patterns", "structures"),
KnowledgeTranslation: firstString(obj, "knowledgeTranslation", "knowledge_translation", "researchTranslation", "research_translation"),
CTAStyle: firstString(obj, "ctaStyle", "cta_style", "conversionStyle", "conversion_style"),
Examples: firstString(obj, "examples", "sample"),
Avoid: firstString(obj, "avoid", "risks"),
}
}
return PersonaDraft{}

View File

@ -1,9 +1,12 @@
package style8d
import "testing"
import (
"strings"
"testing"
)
func TestParseLLMOutput(t *testing.T) {
raw := `{"d1Tone":{"summary":"口語親近","evidence":["真的太好笑"]},"d2Structure":{"summary":"短段落開場","evidence":[]},"d3Interaction":{"summary":"常用提問","evidence":[]},"d4Topics":{"summary":"生活與成長","evidence":[]},"d5Rhythm":{"summary":"晚間發文較多","evidence":[]},"d6Visual":{"summary":"愛用換行與 emoji","evidence":[]},"d7Conversion":{"summary":"自然導留言","evidence":[]},"d8Risk":{"summary":"避免照抄","evidence":[]},"personaDraft":{"identity":"生活觀察者","tone":"輕鬆","audience":"同齡上班族","hooks":"先丟痛點","examples":"有時候真的會累","avoid":"不要硬銷"}}`
raw := `{"d1Tone":{"summary":"口語親近","evidence":["真的太好笑"]},"d2Structure":{"summary":"短段落開場","evidence":[]},"d3Interaction":{"summary":"常用提問","evidence":[]},"d4Topics":{"summary":"生活與成長","evidence":[]},"d5Rhythm":{"summary":"晚間發文較多","evidence":[]},"d6Visual":{"summary":"愛用換行與 emoji","evidence":[]},"d7Conversion":{"summary":"自然導留言","evidence":[]},"d8Risk":{"summary":"避免照抄","evidence":[]},"personaDraft":{"identity":"生活觀察者","tone":"輕鬆","audience":"同齡上班族","hooks":"先丟痛點","languageFingerprint":"常用有時候、後來當轉折","rhythm":"短段落加空行","punctuation":"保留逗號與問號","contentPatterns":"心情文先承接情緒","knowledgeTranslation":"先講情境再整理重點","ctaStyle":"自然邀請留言","examples":"有時候真的會累","avoid":"不要硬銷"}}`
out, err := ParseLLMOutput(raw)
if err != nil {
t.Fatalf("unexpected error: %v", err)
@ -23,4 +26,7 @@ func TestParseLLMOutput(t *testing.T) {
if len(profile.SamplePosts) != 1 || profile.SamplePosts[0].Permalink == "" {
t.Fatalf("samplePosts=%+v", profile.SamplePosts)
}
if !strings.Contains(profile.PersonaDraft, "【語言指紋】") {
t.Fatalf("personaDraft missing language fingerprint: %q", profile.PersonaDraft)
}
}

View File

@ -44,14 +44,29 @@ func HasReady8D(personaText, styleProfileJSON string) bool {
return BuildStyle8DPromptBlock(styleProfileJSON) != ""
}
var copyVoiceDimensionOrder = []string{"d1Tone", "d8Risk"}
// BuildStyle8DPromptBlock formats D1D8 summaries for LLM prompts.
func BuildStyle8DPromptBlock(styleProfileJSON string) string {
return buildStylePromptBlock(styleProfileJSON, dimensionOrder, "【8D 風格策略】\n產文必須遵守\n")
}
// BuildLightPersonaVoiceBlock keeps only tone + risk guardrails for copy generation.
func BuildLightPersonaVoiceBlock(styleProfileJSON string) string {
return buildStylePromptBlock(
styleProfileJSON,
copyVoiceDimensionOrder,
"【語氣與禁忌參考】\n以下僅供口語風格與紅線參考勿照搬句型、段落模板或固定 hook\n",
)
}
func buildStylePromptBlock(styleProfileJSON string, keys []string, header string) string {
profile, ok := ParseStoredProfile(styleProfileJSON)
if !ok {
return ""
}
lines := make([]string, 0, len(dimensionOrder))
for _, key := range dimensionOrder {
lines := make([]string, 0, len(keys))
for _, key := range keys {
summary := strings.TrimSpace(profile.Analysis[key].Summary)
if summary == "" {
continue
@ -66,30 +81,31 @@ func BuildStyle8DPromptBlock(styleProfileJSON string) string {
return ""
}
var b strings.Builder
b.WriteString("【8D 風格策略】\n產文必須遵守\n")
b.WriteString(header)
b.WriteString(strings.Join(lines, "\n"))
return b.String()
}
// ResolvePersonaBlock picks the best persona voice block for copy generation.
// Priority: explicit persona field → 8D personaDraft → brief fallback; always append 8D strategy when present.
// ResolvePersonaBlock combines explicit persona positioning with analyzed writing fingerprints.
// The persona field answers "who this is"; personaDraft/style profile answers "how this person says things".
func ResolvePersonaBlock(personaText, styleProfileJSON, brief string) string {
var parts []string
voice := strings.TrimSpace(personaText)
if voice == "" {
if profile, ok := ParseStoredProfile(styleProfileJSON); ok {
voice = strings.TrimSpace(profile.PersonaDraft)
}
}
if voice == "" {
voice = strings.TrimSpace(brief)
}
if voice != "" {
parts = append(parts, "【人設語氣】\n"+voice)
profile, hasProfile := ParseStoredProfile(styleProfileJSON)
persona := strings.TrimSpace(personaText)
if persona != "" {
parts = append(parts, "【人設定位】\n"+persona)
} else if fallback := strings.TrimSpace(brief); fallback != "" {
parts = append(parts, "【人設定位】\n"+fallback)
}
if block := BuildStyle8DPromptBlock(styleProfileJSON); block != "" {
if hasProfile {
if draft := strings.TrimSpace(profile.PersonaDraft); draft != "" {
parts = append(parts, "【語言指紋與寫作規則】\n"+draft)
}
}
if block := BuildLightPersonaVoiceBlock(styleProfileJSON); block != "" {
parts = append(parts, block)
}
return strings.TrimSpace(strings.Join(parts, "\n\n"))

View File

@ -11,7 +11,17 @@ func TestResolvePersonaBlockUsesPersonaDraftWhenPersonaEmpty(t *testing.T) {
if block == "" {
t.Fatal("expected non-empty block")
}
for _, part := range []string{"生活觀察者", "D1 語氣人格", "口語親近"} {
for _, part := range []string{"生活觀察者", "D1 語氣人格", "口語親近", "語言指紋與寫作規則"} {
if !strings.Contains(block, part) {
t.Fatalf("block missing %q: %q", part, block)
}
}
}
func TestResolvePersonaBlockCombinesPersonaAndStyleFingerprint(t *testing.T) {
raw := `{"analysis":{"d1Tone":{"summary":"口語親近","evidence":[]}},"personaDraft":"【語言指紋】\n常用慢慢、其實當轉折"}`
block := ResolvePersonaBlock("備孕陪伴帳號", raw, "")
for _, part := range []string{"備孕陪伴帳號", "常用慢慢", "語言指紋與寫作規則"} {
if !strings.Contains(block, part) {
t.Fatalf("block missing %q: %q", part, block)
}

View File

@ -0,0 +1,41 @@
package style8d
import "strings"
// AnalyzeMode selects how reference samples are interpreted.
type AnalyzeMode string
const (
AnalyzeModeBenchmark AnalyzeMode = "benchmark"
AnalyzeModeManual AnalyzeMode = "manual"
)
// AnalyzeInput groups prompt + storage options for one 8D run.
type AnalyzeInput struct {
Mode AnalyzeMode
Username string
SourceLabel string
Brief string
Posts []Post
}
func (in AnalyzeInput) BuildUserPrompt() string {
if in.Mode == AnalyzeModeManual || strings.TrimSpace(in.Username) == "" {
return BuildUserPromptFromTexts(in.SourceLabel, in.Brief, in.Posts)
}
return BuildUserPrompt(in.Username, in.Posts)
}
func (in AnalyzeInput) BuildStoredProfile(out LLMOutput) StoredProfile {
if in.Mode == AnalyzeModeManual || strings.TrimSpace(in.Username) == "" {
return BuildStoredProfileFromTexts(in.SourceLabel, in.Posts, out)
}
return BuildStoredProfile(in.Username, in.Posts, out)
}
func (in AnalyzeInput) StyleBenchmark() string {
if in.Mode == AnalyzeModeManual || strings.TrimSpace(in.Username) == "" {
return ManualStyleBenchmark(in.SourceLabel)
}
return strings.TrimPrefix(strings.TrimSpace(in.Username), "@")
}

View File

@ -0,0 +1,73 @@
package style8d
import "testing"
func TestNormalizeReferenceTextsFromArray(t *testing.T) {
texts, err := NormalizeReferenceTexts([]string{"第一段參考文字內容超過二十個字元", "第二段參考文字內容也超過二十個字元"}, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(texts) != 2 {
t.Fatalf("texts=%v", texts)
}
}
func TestNormalizeReferenceTextsFromRawText(t *testing.T) {
raw := "第一段參考文字內容超過二十個字元\n還有第二行\n\n---\n\n第二段參考文字內容也超過二十個字元"
texts, err := NormalizeReferenceTexts(nil, raw)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(texts) != 2 {
t.Fatalf("texts=%v", texts)
}
}
func TestBuildUserPromptFromTexts(t *testing.T) {
got := BuildUserPromptFromTexts("我的舊文", "產品教學帳", TextsToPosts([]string{"這是一段超過二十個字的參考文字樣本內容"}))
if got == "" {
t.Fatal("empty prompt")
}
if !containsAll(got, "我的舊文", "產品教學帳", "[1]") {
t.Fatalf("prompt=%q", got)
}
}
func TestBuildStoredProfileFromTexts(t *testing.T) {
out, err := ParseLLMOutput(`{"d1Tone":{"summary":"口語","evidence":[]},"d2Structure":{"summary":"短句","evidence":[]},"d3Interaction":{"summary":"提問","evidence":[]},"d4Topics":{"summary":"生活","evidence":[]},"d5Rhythm":{"summary":"晚間","evidence":[]},"d6Visual":{"summary":"emoji","evidence":[]},"d7Conversion":{"summary":"留言","evidence":[]},"d8Risk":{"summary":"硬銷","evidence":[]},"personaDraft":{"identity":"觀察者","tone":"輕鬆","audience":"上班族","hooks":"痛點","examples":"有時候真的會累","avoid":"硬銷"}}`)
if err != nil {
t.Fatalf("parse: %v", err)
}
profile := BuildStoredProfileFromTexts("手動貼文", TextsToPosts([]string{"sample text long enough"}), out)
if profile.SourceType != SourceTypeManual {
t.Fatalf("sourceType=%q", profile.SourceType)
}
if profile.Engagement.Verdict != "not_applicable" {
t.Fatalf("verdict=%q", profile.Engagement.Verdict)
}
if ManualStyleBenchmark("手動貼文") != "manual:手動貼文" {
t.Fatal("manual benchmark mismatch")
}
}
func containsAll(s string, parts ...string) bool {
for _, part := range parts {
if !contains(s, part) {
return false
}
}
return true
}
func contains(s, sub string) bool {
return len(sub) == 0 || (len(s) >= len(sub) && indexOf(s, sub) >= 0)
}
func indexOf(s, sub string) int {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return i
}
}
return -1
}

View File

@ -0,0 +1,35 @@
package threadspost
import (
"strings"
)
// FormatDraftText normalizes generated post bodies without destroying the author's voice.
func FormatDraftText(text string) string {
text = strings.ReplaceAll(text, "\r\n", "\n")
text = strings.ReplaceAll(text, "\r", "\n")
text = strings.TrimSpace(text)
if text == "" {
return ""
}
lines := strings.Split(text, "\n")
out := make([]string, 0, len(lines))
blankCount := 0
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
blankCount++
if blankCount <= 1 && len(out) > 0 {
out = append(out, "")
}
continue
}
blankCount = 0
out = append(out, line)
}
for len(out) > 0 && out[len(out)-1] == "" {
out = out[:len(out)-1]
}
return ClampPublish(strings.Join(out, "\n"))
}

View File

@ -0,0 +1,48 @@
package threadspost
import "testing"
func TestFormatDraftTextPreservesPunctuationAndLineBreaks(t *testing.T) {
raw := "我最近迷上自己做早餐耶!\n以前都隨便買個麵包、三明治趕著出門。\n有沒有人也跟我一樣"
want := "我最近迷上自己做早餐耶!\n以前都隨便買個麵包、三明治趕著出門。\n有沒有人也跟我一樣"
got := FormatDraftText(raw)
if got != want {
t.Fatalf("FormatDraftText() = %q, want %q", got, want)
}
}
func TestFormatDraftTextKeepsSingleParagraph(t *testing.T) {
raw := "第一句很好。第二句也不錯!第三句收尾?"
want := "第一句很好。第二句也不錯!第三句收尾?"
got := FormatDraftText(raw)
if got != want {
t.Fatalf("FormatDraftText() = %q, want %q", got, want)
}
}
func TestFormatDraftTextCollapsesExcessBlankLines(t *testing.T) {
raw := "第一段\n\n\n第二段"
want := "第一段\n\n第二段"
got := FormatDraftText(raw)
if got != want {
t.Fatalf("FormatDraftText() = %q, want %q", got, want)
}
}
func TestFormatDraftTextPreservesEmoji(t *testing.T) {
raw := "廚房像打完仗一樣 😂\n但看到成果就值得了"
want := "廚房像打完仗一樣 😂\n但看到成果就值得了"
got := FormatDraftText(raw)
if got != want {
t.Fatalf("FormatDraftText() = %q, want %q", got, want)
}
}
func TestFormatDraftTextPreservesHashtag(t *testing.T) {
raw := "今天心情不錯\n#早餐日記"
want := "今天心情不錯\n#早餐日記"
got := FormatDraftText(raw)
if got != want {
t.Fatalf("FormatDraftText() = %q, want %q", got, want)
}
}

View File

@ -0,0 +1,65 @@
package threadspost
import (
"fmt"
"strings"
)
// LineCount returns non-empty lines after normalizing newlines.
func LineCount(text string) int {
text = strings.ReplaceAll(text, "\r\n", "\n")
text = strings.ReplaceAll(text, "\r", "\n")
n := 0
for _, line := range strings.Split(text, "\n") {
if strings.TrimSpace(line) != "" {
n++
}
}
return n
}
// MimicLengthRange returns a suggested min/max rune count when imitating referenceText.
func MimicLengthRange(referenceText string) (min, max int) {
ref := RuneLen(referenceText)
if ref <= 0 {
return ViralSweetMin, ViralSweetMax
}
min = int(float64(ref) * 0.75)
max = int(float64(ref) * 1.25)
if min < 20 {
min = 20
}
if max < min+10 {
max = min + 10
}
if max > MaxPublishRunes {
max = MaxPublishRunes
}
if min > max {
min = max
}
return min, max
}
// MimicLengthGuidance builds prompt text for rewrite/mimic flows.
func MimicLengthGuidance(referenceText string) string {
referenceText = strings.TrimSpace(referenceText)
if referenceText == "" {
return ""
}
refRunes := RuneLen(referenceText)
refLines := LineCount(referenceText)
min, max := MimicLengthRange(referenceText)
lineMin, lineMax := refLines, refLines
if refLines > 1 {
lineMin = refLines - 1
if lineMin < 1 {
lineMin = 1
}
lineMax = refLines + 1
}
return fmt.Sprintf(
"【篇幅(仿寫)】參考原文約 %d 字、%d 行。產出正文請控制在 %d%d 字、約 %d%d 行,不要明顯比參考長很多或短很多。精簡優先:刪掉重複鋪陳、無關細節、雞湯總結與多餘轉折;每一句都要推進故事或情緒,不然就砍掉。",
refRunes, refLines, min, max, lineMin, lineMax,
)
}

View File

@ -0,0 +1,26 @@
package threadspost
import (
"strings"
"testing"
)
func TestMimicLengthRange(t *testing.T) {
min, max := MimicLengthRange("今天早餐吃了蛋餅\n加辣真的爽")
if min <= 0 || max < min {
t.Fatalf("MimicLengthRange() = %d, %d", min, max)
}
}
func TestMimicLengthGuidanceIncludesReferenceStats(t *testing.T) {
ref := "第一句\n第二句\n第三句"
got := MimicLengthGuidance(ref)
if got == "" {
t.Fatal("MimicLengthGuidance() empty")
}
for _, part := range []string{"3 行", "精簡優先"} {
if !strings.Contains(got, part) {
t.Fatalf("MimicLengthGuidance() missing %q: %q", part, got)
}
}
}

View File

@ -7,6 +7,14 @@ import (
)
type ViralAnalysis struct {
StoryHook string `json:"storyHook"`
NarrativeBeat string `json:"narrativeBeat"`
EmotionalCore string `json:"emotionalCore"`
SceneSeed string `json:"sceneSeed"`
AuthenticityCues []string `json:"authenticityCues"`
WhatToAvoid string `json:"whatToAvoid"`
// Legacy fields kept for backward-compatible parsing.
HookPattern string `json:"hookPattern"`
StructurePattern string `json:"structurePattern"`
EmotionalTrigger string `json:"emotionalTrigger"`
@ -26,9 +34,15 @@ type AnalyzeViralInput struct {
}
func BuildAnalyzeViralSystemPrompt() string {
return strings.TrimSpace(`你是 Threads 爆款結構分析師拆解貼文為什麼會紅怎麼仿寫結構不要建議抄襲原文
return strings.TrimSpace(`你是 Threads 敘事分析師拆解這篇貼文為什麼讓人想看完怎麼寫出同樣的故事感不是抄句型
只回傳 JSONhookPattern, structurePattern, emotionalTrigger, replicationStrategy, keyTakeaways字串陣列 35 繁體中文`)
只回傳 JSON繁體中文
- storyHook讓人停下滑動的開場力量描述手法不要給可照抄的句子
- narrativeBeat故事怎麼推進經歷/轉折/感受不是段落模板
- emotionalCore核心情緒是什麼
- sceneSeed一個值得借鑑的具體場景種子時間/地點/動作供啟發勿照搬
- authenticityCues35 個讓它像真人的小細節口語坦白不完美具體細節等
- whatToAvoid若照搬哪些寫法會立刻變成 AI 複製文`)
}
func BuildAnalyzeViralUserPrompt(in AnalyzeViralInput) string {
@ -65,7 +79,10 @@ func ParseAnalyzeViralOutput(raw string) (ViralAnalysis, error) {
if err := json.Unmarshal(payload, &out); err != nil {
return ViralAnalysis{}, fmt.Errorf("parse viral analysis: %w", err)
}
if strings.TrimSpace(out.HookPattern) == "" && strings.TrimSpace(out.StructurePattern) == "" {
if strings.TrimSpace(out.StoryHook) == "" &&
strings.TrimSpace(out.NarrativeBeat) == "" &&
strings.TrimSpace(out.HookPattern) == "" &&
strings.TrimSpace(out.StructurePattern) == "" {
return ViralAnalysis{}, fmt.Errorf("viral analysis missing content")
}
return out, nil
@ -73,29 +90,47 @@ func ParseAnalyzeViralOutput(raw string) (ViralAnalysis, error) {
func FormatAnalysisForReplicate(analysis ViralAnalysis) string {
var b strings.Builder
if hook := strings.TrimSpace(analysis.HookPattern); hook != "" {
b.WriteString("Hook 模式")
if hook := firstNonEmpty(analysis.StoryHook, analysis.HookPattern); hook != "" {
b.WriteString("故事開場力量")
b.WriteString(hook)
b.WriteString("\n")
}
if structure := strings.TrimSpace(analysis.StructurePattern); structure != "" {
b.WriteString("結構節奏")
b.WriteString(structure)
if beat := firstNonEmpty(analysis.NarrativeBeat, analysis.StructurePattern); beat != "" {
b.WriteString("敘事推進")
b.WriteString(beat)
b.WriteString("\n")
}
if emotion := strings.TrimSpace(analysis.EmotionalTrigger); emotion != "" {
b.WriteString("情緒觸發")
if emotion := firstNonEmpty(analysis.EmotionalCore, analysis.EmotionalTrigger); emotion != "" {
b.WriteString("核心情緒:")
b.WriteString(emotion)
b.WriteString("\n")
}
if strategy := strings.TrimSpace(analysis.ReplicationStrategy); strategy != "" {
b.WriteString("仿寫策略")
b.WriteString(strategy)
if scene := strings.TrimSpace(analysis.SceneSeed); scene != "" {
b.WriteString("場景種子(啟發用,勿照搬)")
b.WriteString(scene)
b.WriteString("\n")
}
if len(analysis.KeyTakeaways) > 0 {
b.WriteString("重點:")
b.WriteString(strings.Join(analysis.KeyTakeaways, ""))
cues := analysis.AuthenticityCues
if len(cues) == 0 {
cues = analysis.KeyTakeaways
}
if len(cues) > 0 {
b.WriteString("真人質感線索:")
b.WriteString(strings.Join(cues, ""))
b.WriteString("\n")
}
if avoid := firstNonEmpty(analysis.WhatToAvoid, analysis.ReplicationStrategy); avoid != "" {
b.WriteString("避免變成複製文:")
b.WriteString(avoid)
}
return strings.TrimSpace(b.String())
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if trimmed := strings.TrimSpace(value); trimmed != "" {
return trimmed
}
}
return ""
}

View File

@ -0,0 +1,43 @@
package viral
import "testing"
func TestFormatAnalysisForReplicateUsesNarrativeFields(t *testing.T) {
got := FormatAnalysisForReplicate(ViralAnalysis{
StoryHook: "先丟一個具體窘況",
NarrativeBeat: "從失敗經驗拐到頓悟",
EmotionalCore: "鬆了一口氣",
SceneSeed: "週日早晨的廚房",
AuthenticityCues: []string{"坦白搞砸", "口語碎碎念"},
WhatToAvoid: "不要照抄三段式",
})
for _, part := range []string{"故事開場力量", "敘事推進", "核心情緒", "場景種子", "真人質感線索", "避免變成複製文", "週日早晨的廚房"} {
if got == "" || !contains(got, part) {
t.Fatalf("FormatAnalysisForReplicate() missing %q: %q", part, got)
}
}
}
func TestFormatAnalysisForReplicateFallsBackToLegacyFields(t *testing.T) {
got := FormatAnalysisForReplicate(ViralAnalysis{
HookPattern: "痛點開場",
StructurePattern: "三段式",
KeyTakeaways: []string{"具體細節"},
})
if !contains(got, "故事開場力量") || !contains(got, "痛點開場") {
t.Fatalf("legacy fallback missing: %q", got)
}
}
func contains(s, part string) bool {
return len(s) >= len(part) && (s == part || len(part) == 0 || indexOf(s, part) >= 0)
}
func indexOf(s, part string) int {
for i := 0; i+len(part) <= len(s); i++ {
if s[i:i+len(part)] == part {
return i
}
}
return -1
}

View File

@ -0,0 +1,121 @@
package viral
import (
"context"
"encoding/xml"
"html"
"net/http"
"net/url"
"regexp"
"strings"
"time"
)
var rssTagRE = regexp.MustCompile(`<[^>]+>`)
type freeRSS struct {
Channel struct {
Items []freeRSSItem `xml:"item"`
} `xml:"channel"`
}
type freeRSSItem struct {
Title string `xml:"title"`
Description string `xml:"description"`
Link string `xml:"link"`
}
func CollectFreeTopicTrends(ctx context.Context, keyword, personaBrief string) []MissionInspireTrendSnippet {
queries := freeTrendFeedQueries(keyword, personaBrief)
client := &http.Client{Timeout: 7 * time.Second}
out := make([]MissionInspireTrendSnippet, 0, 12)
seen := map[string]struct{}{}
add := func(query, title, snippet, link string) {
title = cleanRSSField(title)
snippet = cleanRSSField(snippet)
link = strings.TrimSpace(link)
if title == "" && snippet == "" {
return
}
key := strings.ToLower(title + "|" + snippet)
if _, ok := seen[key]; ok {
return
}
seen[key] = struct{}{}
out = append(out, MissionInspireTrendSnippet{Query: query, Title: title, Snippet: snippet, URL: link})
}
for _, feed := range queries {
if len(out) >= 12 {
break
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, feed.url, nil)
if err != nil {
continue
}
req.Header.Set("User-Agent", "HaixunTopicBot/1.0")
resp, err := client.Do(req)
if err != nil || resp == nil {
continue
}
func() {
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return
}
var parsed freeRSS
if err := xml.NewDecoder(resp.Body).Decode(&parsed); err != nil {
return
}
for _, item := range parsed.Channel.Items {
if len(out) >= 12 {
break
}
add(feed.label, item.Title, item.Description, item.Link)
}
}()
}
return out
}
type freeTrendFeed struct {
label string
url string
}
func freeTrendFeedQueries(keyword, personaBrief string) []freeTrendFeed {
feeds := []freeTrendFeed{
{label: "Google Trends 台灣每日趨勢", url: "https://trends.google.com/trends/trendingsearches/daily/rss?geo=TW"},
{label: "Google Trends 台灣即時趨勢", url: "https://trends.google.com/trends/trendingsearches/realtime/rss?geo=TW&category=all"},
}
terms := []string{strings.TrimSpace(keyword)}
brief := strings.TrimSpace(personaBrief)
if brief != "" {
if len([]rune(brief)) > 18 {
brief = string([]rune(brief)[:18])
}
terms = append(terms, brief)
}
for _, term := range terms {
if term == "" {
continue
}
q := url.QueryEscape(term + " 共鳴 OR 痛點 OR 趨勢")
feeds = append(feeds, freeTrendFeed{
label: "Google News 搜尋:" + term,
url: "https://news.google.com/rss/search?q=" + q + "&hl=zh-TW&gl=TW&ceid=TW:zh-Hant",
})
}
return feeds
}
func cleanRSSField(raw string) string {
raw = html.UnescapeString(raw)
raw = rssTagRE.ReplaceAllString(raw, " ")
raw = strings.Join(strings.Fields(raw), " ")
if len([]rune(raw)) > 220 {
return string([]rune(raw)[:220]) + "…"
}
return raw
}

View File

@ -21,6 +21,8 @@ type MissionInspireInput struct {
PersonaPillars []string
RecentMissionLabels []string
RecentSeedQueries []string
AvoidTopics []string
ContentDirection string
UserKeyword string
TrendSnippets []MissionInspireTrendSnippet
WebSearchProvider string
@ -45,27 +47,50 @@ type MissionInspireThreadsInput struct {
}
type MissionInspireOutput struct {
Label string
SeedQuery string
Brief string
TrendReason string
TrendKeywords []string
Label string
SeedQuery string
Brief string
TrendReason string
TrendKeywords []string
Angles []string
Mission string
TargetAudience string
OpeningType string
BodyType string
Emotion string
CtaType string
RiskLevel string
Avoid []string
}
func BuildMissionInspireSystemPrompt() string {
return strings.TrimSpace(`你是 Threads 拷貝忍者的靈感骰子顧問根據近期 Threads 熱門貼文網路搜尋訊號與創作者人設產出一組**全新**拷貝任務草稿
return strings.TrimSpace(`你是 Threads 帳號的內容編輯與 Topic Radar根據近期 Threads / 社群討論訊號網路搜尋素材與創作者人設先產出可執行的內容計畫不要直接寫貼文正文
規則
1. 近期趨勢訊號有內容從中挑一個適合在 Threads 海巡的方向不要編造不存在的時事或熱門貼文
1. 近期趨勢訊號有內容從中挑一個適合在 Threads 海巡的方向優先使用社群熱門關鍵字受眾求助心得討論留言常見問題不要把新聞事件本身當主題
2. 若趨勢訊號為空未連線網路搜尋**必須**改依人設受眾痛點與常見 Threads 討論型態推測近期可能被搜尋的話題並在 trendReason 說明推測理由不要假裝有外部熱搜來源
3. label任務名稱618 像企劃案標題不要標點堆疊
4. seedQuery種子關鍵字近期熱詞26 個詞用頓號或空格分隔適合當 Threads 搜尋起點
5. brief這次想找什麼24 說明要海巡哪類爆款語氣或格式偏好
6. trendReason12 為何選這個趨勢可引用訊號來源大意不要貼 URL
7. trendKeywords36 個相關熱詞字串陣列
8. 避開近期已做過的任務相同題材
9. 繁體中文只回傳 JSON
{"label":"","seedQuery":"","brief":"","trendReason":"","trendKeywords":[]}`)
5. brief這次內容計畫摘要24 說明話題受眾痛點為何適合這個帳號
6. trendReason12 為何選這個趨勢說明它和受眾討論搜尋意圖或痛點的關係不要貼 URL
7. trendKeywords36 個相關熱詞字串陣列必須像 Threads 會搜尋/討論的關鍵字不要像新聞標題
8. angles35 個可勾選切角字串陣列每個切角都要說明這篇要怎麼切不要直接寫成完整貼文
9. mission內容任務只能是日常陪伴專業知識情緒共鳴工具清單熱門觀點互動提問之一除非使用者方向要求更精準
10. targetAudience這篇真正要對誰說越具體越好
11. openingType開頭策略例如問題開場場景開場反直覺開場經驗開場清單開場
12. bodyType中段結構例如先共鳴再整理問題拆解步驟清單案例對照觀點辯證
13. emotion情緒槓桿例如焦慮被接住想被理解怕踩雷想準備好想被鼓勵
14. ctaType互動/收藏/私訊等 CTA 型態不要硬業配
15. riskLevellow / medium / high醫療財務法律政策名人爭議等至少 medium
16. avoid36 個禁忌或不要寫偏的方向字串陣列
17. 避開近期已做過的任務相同題材
18. 起號優先題目要能連續延伸 510 不要只是一則熱搜心得
19. 除非使用者關鍵字人設或趨勢訊號明確要求不要產出早餐早安日常飲食純生活流水帳這類泛題
20. 人設介紹只用來判斷語氣與受眾不是題材庫不可因人設寫 Y2K咖啡穿搭音樂就把靈感主題漂移成咖啡穿搭或復古日常
21. 若使用者有指定關鍵字labelseedQuerybrief 都必須保留該方向不可漂移成無關日常題
22. 繁體中文只回傳 JSON
23. 避免新聞導向不要產出某事件最新進展政策新聞懶人包名人/品牌新聞這類題目除非它已經轉化成受眾正在問的生活問題或經驗討論
{"label":"","seedQuery":"","brief":"","trendReason":"","trendKeywords":[],"angles":[],"mission":"","targetAudience":"","openingType":"","bodyType":"","emotion":"","ctaType":"","riskLevel":"low","avoid":[]}`)
}
func BuildMissionInspireUserPrompt(in MissionInspireInput) string {
@ -76,9 +101,9 @@ func BuildMissionInspireUserPrompt(in MissionInspireInput) string {
b.WriteString("\n")
}
if p := strings.TrimSpace(in.PersonaBlock); p != "" {
b.WriteString("【人設摘要】\n")
b.WriteString("【人設摘要(只用來校準語氣與受眾,不是題材庫)】\n")
b.WriteString(p)
b.WriteString("\n")
b.WriteString("\n注意:不要把人設裡的名詞、興趣或裝飾符號直接變成話題。\n")
}
if bench := strings.TrimSpace(in.StyleBenchmark); bench != "" {
b.WriteString("【對標帳號】@")
@ -105,8 +130,20 @@ func BuildMissionInspireUserPrompt(in MissionInspireInput) string {
b.WriteString(keyword)
b.WriteString("\n")
}
if len(in.RecentMissionLabels) > 0 || len(in.RecentSeedQueries) > 0 {
if direction := strings.TrimSpace(in.ContentDirection); direction != "" {
b.WriteString("【使用者選擇內容方向】")
b.WriteString(direction)
b.WriteString("\n請讓 label、brief、angles 都符合這個方向;如果方向是 auto請依熱度與人設自動選。\n")
}
if len(in.AvoidTopics) > 0 || len(in.RecentMissionLabels) > 0 || len(in.RecentSeedQueries) > 0 {
b.WriteString("【近期已做過的任務(請避開)】\n")
for _, topic := range in.AvoidTopics {
if topic = strings.TrimSpace(topic); topic != "" {
b.WriteString("- ")
b.WriteString(topic)
b.WriteString("\n")
}
}
for _, label := range in.RecentMissionLabels {
if label = strings.TrimSpace(label); label != "" {
b.WriteString("- ")
@ -147,15 +184,15 @@ func BuildMissionInspireUserPrompt(in MissionInspireInput) string {
b.WriteString("\n")
}
}
b.WriteString("請產出拷貝任務靈感 JSON。")
b.WriteString("請產出 Topic Radar 與 Content Plan JSON。")
return b.String()
}
func InspireTrendSearchQueries(personaBrief, styleBenchmark string) []string {
queries := []string{
"Google Trends 台灣 今日 熱門搜尋 關鍵字",
"Threads 台灣 熱門 話題 最近 一週",
"近期 網路 熱搜 關鍵字 台灣",
"Threads 台灣 熱門 關鍵字 討論 最近 一週",
"Threads 台灣 請問 推薦 心得 最近",
"社群 熱門 關鍵字 台灣 討論 Dcard PTT Threads",
}
context := strings.TrimSpace(personaBrief + " " + styleBenchmark)
if context != "" {
@ -163,7 +200,10 @@ func InspireTrendSearchQueries(personaBrief, styleBenchmark string) []string {
if len([]rune(trimmed)) > 24 {
trimmed = string([]rune(trimmed)[:24])
}
queries = append(queries, trimmed+" 熱門 話題 最近")
queries = append(queries,
trimmed+" Threads 熱門 關鍵字 討論",
trimmed+" 請問 心得 推薦 Threads",
)
}
return queries
}
@ -186,9 +226,11 @@ func InspireThreadsSearchQueries(in MissionInspireThreadsInput) []string {
keyword := cleanInspireQuery(in.Keyword)
if keyword != "" {
add(keyword)
add(keyword + " 心情")
add(keyword + " 經驗")
add(keyword + " 熱門")
add(keyword + " 心得")
add(keyword + " 請問")
add(keyword + " 推薦")
add(keyword + " 怎麼辦")
}
for _, pillar := range in.Pillars {
if len(out) >= 6 {
@ -324,10 +366,17 @@ func ParseMissionInspireOutput(raw string) (MissionInspireOutput, error) {
return MissionInspireOutput{}, err
}
out := MissionInspireOutput{
Label: firstJSONString(root, "label", "title", "mission_label"),
SeedQuery: firstJSONString(root, "seedQuery", "seed_query", "seed", "keywords"),
Brief: firstJSONString(root, "brief", "description", "goal"),
TrendReason: firstJSONString(root, "trendReason", "trend_reason", "reason"),
Label: firstJSONString(root, "label", "title", "mission_label"),
SeedQuery: firstJSONString(root, "seedQuery", "seed_query", "seed", "keywords"),
Brief: firstJSONString(root, "brief", "description", "goal"),
TrendReason: firstJSONString(root, "trendReason", "trend_reason", "reason"),
Mission: firstJSONString(root, "mission", "contentMission", "content_mission"),
TargetAudience: firstJSONString(root, "targetAudience", "target_audience", "audience"),
OpeningType: firstJSONString(root, "openingType", "opening_type", "hookType", "hook_type"),
BodyType: firstJSONString(root, "bodyType", "body_type", "structure", "bodyStructure"),
Emotion: firstJSONString(root, "emotion", "emotionalLever", "emotional_lever"),
CtaType: firstJSONString(root, "ctaType", "cta_type", "cta"),
RiskLevel: firstJSONString(root, "riskLevel", "risk_level", "risk"),
}
if out.Label == "" || out.SeedQuery == "" || out.Brief == "" {
return MissionInspireOutput{}, fmt.Errorf("missing label, seedQuery, or brief")
@ -340,5 +389,21 @@ func ParseMissionInspireOutput(raw string) (MissionInspireOutput, error) {
out.TrendKeywords = parseFlexibleStringList(rawKW)
}
}
if rawAngles, ok := root["angles"]; ok {
out.Angles = parseFlexibleStringList(rawAngles)
}
if len(out.Angles) == 0 {
if rawAngles, ok := root["contentAngles"]; ok {
out.Angles = parseFlexibleStringList(rawAngles)
}
}
if rawAvoid, ok := root["avoid"]; ok {
out.Avoid = parseFlexibleStringList(rawAvoid)
}
if len(out.Avoid) == 0 {
if rawAvoid, ok := root["avoidTopics"]; ok {
out.Avoid = parseFlexibleStringList(rawAvoid)
}
}
return out, nil
}

View File

@ -49,6 +49,9 @@ func CollectMissionInspireTrends(
if title == "" && snippet == "" {
continue
}
if looksLikeNewsResult(title, snippet, item.URL) {
continue
}
key := strings.ToLower(title + "|" + snippet)
if _, ok := seen[key]; ok {
continue
@ -64,3 +67,24 @@ func CollectMissionInspireTrends(
}
return out
}
func looksLikeNewsResult(title, snippet, url string) bool {
text := strings.ToLower(title + " " + snippet + " " + url)
newsSignals := []string{"新聞", "快訊", "中央社", "聯合新聞", "ettoday", "tvbs", "setn", "ctwant", "chinatimes", "ltn.com", "news"}
socialSignals := []string{"threads", "dcard", "ptt", "請問", "心得", "推薦", "有人", "怎麼辦", "討論", "留言"}
newsScore := 0
for _, signal := range newsSignals {
if strings.Contains(text, strings.ToLower(signal)) {
newsScore++
}
}
if newsScore == 0 {
return false
}
for _, signal := range socialSignals {
if strings.Contains(text, strings.ToLower(signal)) {
return false
}
}
return true
}

View File

@ -6,6 +6,7 @@ import (
"regexp"
"strings"
"haixun-backend/internal/library/copyvoice"
"haixun-backend/internal/library/threadspost"
)
@ -19,6 +20,7 @@ type ReplicateInput struct {
OriginalText string
AuthorName string
StructureAnalysis string
NarrativeSeed string
}
type ReplicateResult struct {
@ -30,13 +32,15 @@ type ReplicateResult struct {
}
func BuildSystemPrompt() string {
return strings.TrimSpace(`你是 Threads 爆款複製策略師根據參考爆文為使用者撰寫同結構同節奏但完全原創貼文
return strings.TrimSpace(`你是 Threads / Instagram 創作者讀完參考爆文後用指定人設的語言指紋寫一篇全新貼文
規則
- 複製的是爆款公式hook 手法情緒節奏不是抄襲原文
- 文筆必須像創作者本人套用其 8D 風格策略
` + copyvoice.SystemRules() + `
輸出
- text 主文 500 Threads API 硬上限 #話題標籤
- 爆款互動最佳 80220 12 行強 hook口語精簡超過 300 字互動通常下降
- 參考原文只學情緒力度資訊密度與節奏最後必須換成人設會講出口的話
- 篇幅行數標點與換行優先遵守人設語言指紋沒有指紋時才貼近參考原文
- text 排版要像真人手機發文可使用自然標點空行列點與少量 emoji不要完全無標點也不要每句硬換行除非人設樣本就是這樣
- 只回傳一個 JSON 物件欄位angle, hook, text, rationale, structureNotes`)
}
@ -51,29 +55,39 @@ func BuildUserPrompt(input ReplicateInput) string {
b.WriteString("\n")
}
if persona := strings.TrimSpace(input.Persona); persona != "" {
b.WriteString("\n人設與語氣\n")
b.WriteString("\n人設定位、語言指紋與禁忌(最高優先)\n")
b.WriteString(persona)
b.WriteString("\n")
b.WriteString(copyvoice.PersonaCalibrationNote())
b.WriteString("\n")
}
if style := strings.TrimSpace(input.StyleProfile); style != "" {
b.WriteString("\n8D 風格策略:\n")
b.WriteString(style)
b.WriteString("\n")
}
if analysis := strings.TrimSpace(input.StructureAnalysis); analysis != "" {
b.WriteString("\n敘事分析只學故事感不要套句型或段落公式\n")
b.WriteString(analysis)
b.WriteString("\n")
}
author := strings.TrimSpace(input.AuthorName)
if author == "" {
author = "匿名"
}
if analysis := strings.TrimSpace(input.StructureAnalysis); analysis != "" {
b.WriteString("\n爆款結構分析仿寫時套用\n")
b.WriteString(analysis)
original := strings.TrimSpace(input.OriginalText)
if note := threadspost.MimicLengthGuidance(original); note != "" {
b.WriteString("\n")
b.WriteString(note)
b.WriteString("\n")
}
b.WriteString("\n原文參考@")
b.WriteString(author)
b.WriteString(",只學結構不抄內容):\n")
b.WriteString(strings.TrimSpace(input.OriginalText))
b.WriteString("\n\n請產出一篇可發布的複製版貼文 JSON。")
b.WriteString(",只感受情緒、敘事與篇幅,禁止抄句型):\n")
b.WriteString(original)
b.WriteString("\n\n本次敘事切入")
b.WriteString(copyvoice.NarrativeLens(input.NarrativeSeed))
b.WriteString("\n\n請產出一篇可發布的全新貼文 JSON。hook 欄位只放內部分析,不要把模板句型寫進 text。")
return b.String()
}
@ -86,7 +100,7 @@ func ParseReplicateOutput(raw string) (ReplicateResult, error) {
if err := json.Unmarshal(payload, &out); err != nil {
return ReplicateResult{}, fmt.Errorf("parse viral replica json: %w", err)
}
out.Text = trimText(out.Text)
out.Text = threadspost.FormatDraftText(out.Text)
if out.Text == "" {
return ReplicateResult{}, fmt.Errorf("replica text missing")
}
@ -97,15 +111,6 @@ func ParseReplicateOutput(raw string) (ReplicateResult, error) {
return out, nil
}
func trimText(text string) string {
text = strings.TrimSpace(text)
runes := []rune(text)
if len(runes) > threadspost.MaxPublishRunes {
return string(runes[:threadspost.MaxPublishRunes])
}
return text
}
func extractJSONObject(raw string) ([]byte, error) {
raw = strings.TrimSpace(raw)
if m := codeFenceRE.FindStringSubmatch(raw); len(m) == 2 {

View File

@ -1,16 +0,0 @@
package copy_mission
import (
"context"
app "haixun-backend/internal/library/errors"
"haixun-backend/internal/library/errors/code"
"haixun-backend/internal/svc"
)
func requireMemberAiReady(ctx context.Context, svcCtx *svc.ServiceContext, tenantID, uid string) error {
if _, err := svcCtx.ThreadsAccount.ResolveMemberAiCredential(ctx, tenantID, uid); err != nil {
return app.For(code.ThreadsAccount).InputMissingRequired("請先在設定頁設定 AI API key")
}
return nil
}

View File

@ -1,10 +1,11 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package copy_mission
import (
"context"
"strings"
missiondomain "haixun-backend/internal/model/copy_mission/domain/usecase"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
@ -25,33 +26,8 @@ func NewCreateCopyMissionLogic(ctx context.Context, svcCtx *svc.ServiceContext)
}
}
func (l *CreateCopyMissionLogic) CreateCopyMission(req *types.CreateCopyMissionHandlerReq) (*types.CopyMissionData, error) {
tenantID, uid, err := actorFrom(l.ctx)
if err != nil {
return nil, err
}
personaID := strings.TrimSpace(req.PersonaID)
persona, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID)
if err != nil {
return nil, err
}
initialMap, initialTags := seedMissionResearchFromPersona(*persona)
createReq := missiondomain.CreateRequest{
TenantID: tenantID,
OwnerUID: uid,
PersonaID: personaID,
Label: req.Label,
SeedQuery: req.SeedQuery,
Brief: req.Brief,
}
if initialMap != nil {
createReq.InitialResearchMap = initialMap
createReq.InitialSelectedTags = initialTags
}
item, err := l.svcCtx.CopyMission.Create(l.ctx, createReq)
if err != nil {
return nil, err
}
data := toCopyMissionData(*item)
return &data, nil
func (l *CreateCopyMissionLogic) CreateCopyMission(req *types.CreateCopyMissionHandlerReq) (resp *types.CopyMissionData, err error) {
// todo: add your logic here and delete this line
return
}

View File

@ -1,8 +1,10 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package copy_mission
import (
"context"
"strings"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
@ -25,9 +27,7 @@ func NewDeleteCopyMissionLogic(ctx context.Context, svcCtx *svc.ServiceContext)
}
func (l *DeleteCopyMissionLogic) DeleteCopyMission(req *types.CopyMissionPath) error {
tenantID, uid, err := actorFrom(l.ctx)
if err != nil {
return err
}
return l.svcCtx.CopyMission.Delete(l.ctx, tenantID, uid, strings.TrimSpace(req.PersonaID), strings.TrimSpace(req.ID))
// todo: add your logic here and delete this line
return nil
}

View File

@ -1,52 +1,33 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package copy_mission
import (
"context"
"fmt"
"strings"
copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type DeleteCopyMissionMatrixDraftsLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewDeleteCopyMissionMatrixDraftsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeleteCopyMissionMatrixDraftsLogic {
return &DeleteCopyMissionMatrixDraftsLogic{ctx: ctx, svcCtx: svcCtx}
return &DeleteCopyMissionMatrixDraftsLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *DeleteCopyMissionMatrixDraftsLogic) DeleteCopyMissionMatrixDrafts(
req *types.DeleteCopyMissionMatrixDraftsHandlerReq,
) (*types.DeleteCopyMissionMatrixDraftsData, error) {
tenantID, uid, err := actorFrom(l.ctx)
if err != nil {
return nil, err
}
personaID := strings.TrimSpace(req.PersonaID)
missionID := strings.TrimSpace(req.ID)
if _, err := l.svcCtx.CopyMission.Get(l.ctx, tenantID, uid, personaID, missionID); err != nil {
return nil, err
}
deleted, err := l.svcCtx.CopyDraft.DeleteMissionMatrixDrafts(l.ctx, copydraftdomain.DeleteMissionMatrixDraftsRequest{
TenantID: tenantID,
OwnerUID: uid,
PersonaID: personaID,
MissionID: missionID,
DraftIDs: req.DraftIDs,
})
if err != nil {
return nil, err
}
message := fmt.Sprintf("已刪除 %d 篇矩陣草稿", deleted)
if len(req.DraftIDs) == 0 {
message = fmt.Sprintf("已刪除全部 %d 篇矩陣草稿", deleted)
}
return &types.DeleteCopyMissionMatrixDraftsData{
Deleted: deleted,
Message: message,
}, nil
func (l *DeleteCopyMissionMatrixDraftsLogic) DeleteCopyMissionMatrixDrafts(req *types.DeleteCopyMissionMatrixDraftsHandlerReq) (resp *types.DeleteCopyMissionMatrixDraftsData, err error) {
// todo: add your logic here and delete this line
return
}

View File

@ -1,14 +1,11 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package copy_mission
import (
"context"
"strings"
app "haixun-backend/internal/library/errors"
"haixun-backend/internal/library/errors/code"
libkg "haixun-backend/internal/library/knowledge"
"haixun-backend/internal/library/placement"
jobdom "haixun-backend/internal/model/job/domain/usecase"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
@ -29,68 +26,8 @@ func NewExpandCopyMissionGraphLogic(ctx context.Context, svcCtx *svc.ServiceCont
}
}
func (l *ExpandCopyMissionGraphLogic) ExpandCopyMissionGraph(req *types.ExpandCopyMissionGraphHandlerReq) (*types.ExpandKnowledgeGraphData, error) {
tenantID, uid, err := actorFrom(l.ctx)
if err != nil {
return nil, err
}
personaID := strings.TrimSpace(req.PersonaID)
missionID := strings.TrimSpace(req.ID)
seed := strings.TrimSpace(req.SeedQuery)
if seed == "" {
return nil, app.For(code.Persona).InputMissingRequired("seed_query is required")
}
mission, err := l.svcCtx.CopyMission.Get(l.ctx, tenantID, uid, personaID, missionID)
if err != nil {
return nil, err
}
if strings.TrimSpace(mission.ResearchMap.AudienceSummary) == "" {
return nil, app.For(code.Persona).InputMissingRequired("請先產生研究地圖再擴展延伸知識")
}
if err := requireMemberAiReady(l.ctx, l.svcCtx, tenantID, uid); err != nil {
return nil, err
}
func (l *ExpandCopyMissionGraphLogic) ExpandCopyMissionGraph(req *types.ExpandCopyMissionGraphHandlerReq) (resp *types.ExpandKnowledgeGraphData, err error) {
// todo: add your logic here and delete this line
research, err := l.svcCtx.Placement.ResearchSettings(l.ctx, tenantID, uid)
if err != nil {
return nil, err
}
expandStrategy := placement.EffectiveExpandStrategy(research)
if req.Supplemental && placement.WebSearchAvailable(research) {
expandStrategy = libkg.ExpandStrategyBrave
}
memberCtx, err := l.svcCtx.ThreadsAccount.ResolveMemberPlacementContext(l.ctx, tenantID, uid, research)
if err != nil {
return nil, err
}
payload := map[string]any{
"tenant_id": tenantID,
"owner_uid": uid,
"persona_id": personaID,
"copy_mission_id": missionID,
"seed_query": seed,
"supplemental": req.Supplemental,
"expand_strategy": expandStrategy.String(),
}
for key, value := range memberCtx.PayloadFields() {
payload[key] = value
}
run, err := l.svcCtx.Job.CreateRun(l.ctx, jobdom.CreateRunRequest{
TemplateType: "expand-copy-mission-graph",
Scope: "copy_mission",
ScopeID: missionID,
TenantID: tenantID,
OwnerUID: uid,
Payload: payload,
})
if err != nil {
return nil, err
}
return &types.ExpandKnowledgeGraphData{
JobID: run.ID.Hex(),
Status: string(run.Status),
Message: "延伸知識擴展中,完成後可勾選節點用於產文",
}, nil
return
}

View File

@ -1,226 +1,33 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package copy_mission
import (
"context"
"fmt"
"strings"
libcopy "haixun-backend/internal/library/copymission"
app "haixun-backend/internal/library/errors"
"haixun-backend/internal/library/errors/code"
libkg "haixun-backend/internal/library/knowledge"
libmatrix "haixun-backend/internal/library/matrix"
libprompt "haixun-backend/internal/library/prompt"
"haixun-backend/internal/library/style8d"
libweb "haixun-backend/internal/library/webpage"
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"
scanpostdomain "haixun-backend/internal/model/scan_post/domain/usecase"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type GenerateCopyMissionMatrixLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewGenerateCopyMissionMatrixLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GenerateCopyMissionMatrixLogic {
return &GenerateCopyMissionMatrixLogic{ctx: ctx, svcCtx: svcCtx}
return &GenerateCopyMissionMatrixLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *GenerateCopyMissionMatrixLogic) GenerateCopyMissionMatrix(
req *types.GenerateCopyMissionMatrixHandlerReq,
) (*types.GenerateCopyMissionMatrixData, error) {
tenantID, uid, err := actorFrom(l.ctx)
if err != nil {
return nil, err
}
personaID := strings.TrimSpace(req.PersonaID)
missionID := strings.TrimSpace(req.ID)
mission, err := l.svcCtx.CopyMission.Get(l.ctx, tenantID, uid, personaID, missionID)
if err != nil {
return nil, err
}
if mission.Status != string(missionentity.StatusMapped) &&
mission.Status != string(missionentity.StatusScanned) &&
mission.Status != string(missionentity.StatusDrafted) {
return nil, app.For(code.Persona).ResInvalidState("請先完成研究地圖再產出內容矩陣")
}
if strings.TrimSpace(mission.ResearchMap.AudienceSummary) == "" {
return nil, app.For(code.Persona).InputMissingRequired("請先完成研究地圖")
}
func (l *GenerateCopyMissionMatrixLogic) GenerateCopyMissionMatrix(req *types.GenerateCopyMissionMatrixHandlerReq) (resp *types.GenerateCopyMissionMatrixData, err error) {
// todo: add your logic here and delete this line
persona, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID)
if err != nil {
return nil, err
}
if err := ensureNoActiveMatrixJob(l.ctx, l.svcCtx, missionID); err != nil {
return nil, err
}
if !style8d.HasReady8D(persona.Persona, persona.StyleProfile) {
return nil, app.For(code.Persona).InputMissingRequired("請先完成人設 8D 對標分析")
}
personaBlock := style8d.ResolvePersonaBlock(persona.Persona, persona.StyleProfile, persona.Brief)
count := req.Count
if count <= 0 {
count = 5
}
if count > 12 {
count = 12
}
posts, err := l.svcCtx.ScanPost.ListForPersona(l.ctx, scanpostdomain.PersonaListRequest{
TenantID: tenantID,
OwnerUID: uid,
PersonaID: personaID,
CopyMissionID: missionID,
Limit: 12,
})
if err != nil {
return nil, err
}
samples := matrixSamplesFromScanPosts(posts)
graphNodes, graphSources := loadMatrixGraph(l.ctx, l.svcCtx, tenantID, uid, 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)
referenceMarkdown := ""
if len(refURLs) > 0 {
digests := libweb.FetchDigests(l.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 nil, app.For(code.AI).SysInternal("matrix user prompt load failed")
}
systemPrompt, err := libprompt.MatrixCopySystem()
if err != nil {
return nil, app.For(code.AI).SysInternal("matrix system prompt load failed")
}
credential, err := l.svcCtx.ThreadsAccount.ResolveMemberAiCredential(l.ctx, tenantID, uid)
if err != nil {
return nil, err
}
providerID, err := aiusecase.MapWorkerProvider(credential.Provider)
if err != nil {
return nil, err
}
parsed, err := libmatrix.GenerateCopyOutput(l.ctx, l.svcCtx.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 nil, app.For(code.AI).SvcThirdParty("內容矩陣 LLM 回傳無法解析:" + err.Error())
}
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 := l.svcCtx.CopyDraft.ReplaceMissionMatrix(l.ctx, tenantID, uid, personaID, missionID, createReqs)
if err != nil {
return nil, err
}
drafted := missionentity.StatusDrafted
_, _ = l.svcCtx.CopyMission.Update(l.ctx, missiondomain.UpdateRequest{
TenantID: tenantID,
OwnerUID: uid,
PersonaID: personaID,
MissionID: missionID,
Patch: missiondomain.MissionPatch{
Status: &drafted,
},
})
drafts := make([]types.CopyDraftData, 0, len(saved))
for _, item := range saved {
drafts = append(drafts, toCopyDraftData(item))
}
return &types.GenerateCopyMissionMatrixData{
Drafts: drafts,
Message: fmt.Sprintf("已產出 %d 篇矩陣草稿", len(drafts)),
}, nil
}
func loadMatrixGraph(ctx context.Context, svcCtx *svc.ServiceContext, tenantID, uid, missionID string) ([]libkg.Node, []libkg.BraveSource) {
if svcCtx == nil || svcCtx.KnowledgeGraph == nil {
return nil, nil
}
graph, err := svcCtx.KnowledgeGraph.GetByCopyMission(ctx, tenantID, uid, missionID)
if err != nil || graph == nil {
return nil, nil
}
return graph.Nodes, graph.BraveSources
}
func matrixSamplesFromScanPosts(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)
return
}

View File

@ -1,8 +1,10 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package copy_mission
import (
"context"
"strings"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
@ -24,20 +26,8 @@ func NewGetCopyMissionGraphLogic(ctx context.Context, svcCtx *svc.ServiceContext
}
}
func (l *GetCopyMissionGraphLogic) GetCopyMissionGraph(req *types.CopyMissionPath) (*types.KnowledgeGraphData, error) {
tenantID, uid, err := actorFrom(l.ctx)
if err != nil {
return nil, err
}
personaID := strings.TrimSpace(req.PersonaID)
missionID := strings.TrimSpace(req.ID)
if _, err := l.svcCtx.CopyMission.Get(l.ctx, tenantID, uid, personaID, missionID); err != nil {
return nil, err
}
graph, err := l.svcCtx.KnowledgeGraph.GetByCopyMission(l.ctx, tenantID, uid, missionID)
if err != nil {
return nil, err
}
data := toKnowledgeGraphData(graph)
return &data, nil
func (l *GetCopyMissionGraphLogic) GetCopyMissionGraph(req *types.CopyMissionPath) (resp *types.KnowledgeGraphData, err error) {
// todo: add your logic here and delete this line
return
}

View File

@ -1,8 +1,10 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package copy_mission
import (
"context"
"strings"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
@ -24,15 +26,8 @@ func NewGetCopyMissionLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Ge
}
}
func (l *GetCopyMissionLogic) GetCopyMission(req *types.CopyMissionPath) (*types.CopyMissionData, error) {
tenantID, uid, err := actorFrom(l.ctx)
if err != nil {
return nil, err
}
item, err := l.svcCtx.CopyMission.Get(l.ctx, tenantID, uid, strings.TrimSpace(req.PersonaID), strings.TrimSpace(req.ID))
if err != nil {
return nil, err
}
data := toCopyMissionData(*item)
return &data, nil
func (l *GetCopyMissionLogic) GetCopyMission(req *types.CopyMissionPath) (resp *types.CopyMissionData, err error) {
// todo: add your logic here and delete this line
return
}

View File

@ -1,40 +1,33 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package copy_mission
import (
"context"
"strings"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type GetCopyMissionScanScheduleLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewGetCopyMissionScanScheduleLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetCopyMissionScanScheduleLogic {
return &GetCopyMissionScanScheduleLogic{ctx: ctx, svcCtx: svcCtx}
return &GetCopyMissionScanScheduleLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *GetCopyMissionScanScheduleLogic) GetCopyMissionScanSchedule(
req *types.CopyMissionPath,
) (*types.CopyMissionScanScheduleData, error) {
tenantID, uid, err := actorFrom(l.ctx)
if err != nil {
return nil, err
}
personaID := strings.TrimSpace(req.PersonaID)
missionID := strings.TrimSpace(req.ID)
if _, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID); err != nil {
return nil, err
}
if _, err := l.svcCtx.CopyMission.Get(l.ctx, tenantID, uid, personaID, missionID); err != nil {
return nil, err
}
schedule, err := findCopyMissionScanSchedule(l.ctx, l.svcCtx, missionID)
if err != nil {
return nil, err
}
return toCopyMissionScanScheduleData(schedule, personaID, missionID), nil
func (l *GetCopyMissionScanScheduleLogic) GetCopyMissionScanSchedule(req *types.CopyMissionPath) (resp *types.CopyMissionScanScheduleData, err error) {
// todo: add your logic here and delete this line
return
}

View File

@ -1,8 +1,12 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package copy_mission
import (
"context"
"strings"
"time"
app "haixun-backend/internal/library/errors"
"haixun-backend/internal/library/errors/code"
@ -12,91 +16,41 @@ import (
"haixun-backend/internal/library/websearch"
domai "haixun-backend/internal/model/ai/domain/usecase"
aiusecase "haixun-backend/internal/model/ai/usecase"
contentops "haixun-backend/internal/model/content_ops/domain/usecase"
personadomain "haixun-backend/internal/model/persona/domain/usecase"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type InspireCopyMissionLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewInspireCopyMissionLogic(ctx context.Context, svcCtx *svc.ServiceContext) *InspireCopyMissionLogic {
return &InspireCopyMissionLogic{ctx: ctx, svcCtx: svcCtx}
return &InspireCopyMissionLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *InspireCopyMissionLogic) InspireCopyMission(req *types.CopyMissionInspirationHandlerReq) (*types.CopyMissionInspirationData, error) {
func (l *InspireCopyMissionLogic) InspireCopyMission(req *types.CopyMissionInspirationHandlerReq) (resp *types.CopyMissionInspirationData, err error) {
tenantID, uid, err := actorFrom(l.ctx)
if err != nil {
return nil, err
}
personaID := strings.TrimSpace(req.PersonaID)
userKeyword := strings.TrimSpace(req.Keyword)
if personaID == "" {
return nil, app.For(code.Persona).InputMissingRequired("persona_id is required")
}
persona, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID)
if err != nil {
return nil, err
}
missionList, err := l.svcCtx.CopyMission.List(l.ctx, tenantID, uid, personaID)
if err != nil {
return nil, err
}
recentLabels := make([]string, 0, 8)
recentSeeds := make([]string, 0, 8)
for _, mission := range missionList.List {
if mission.Status == "archived" {
continue
}
if label := strings.TrimSpace(mission.Label); label != "" {
recentLabels = append(recentLabels, label)
}
if seed := strings.TrimSpace(mission.SeedQuery); seed != "" {
recentSeeds = append(recentSeeds, seed)
}
if len(recentLabels) >= 8 && len(recentSeeds) >= 8 {
break
}
}
trendSnippets := []libviral.MissionInspireTrendSnippet{}
webSearchProvider := ""
trendSourceLabel := ""
llmOnly := true
research, researchErr := l.svcCtx.Placement.ResearchSettings(l.ctx, tenantID, uid)
if researchErr == nil {
memberCtx, memberErr := l.svcCtx.ThreadsAccount.ResolveMemberPlacementContext(l.ctx, tenantID, uid, research)
if memberErr == nil {
trendSnippets = libviral.CollectMissionInspireThreadsTrends(l.ctx, libviral.MissionInspireThreadsInput{
Keyword: userKeyword,
PersonaBrief: persona.Brief,
StyleBenchmark: persona.StyleBenchmark,
Pillars: append([]string(nil), persona.CopyResearchMap.Pillars...),
Questions: append([]string(nil), persona.CopyResearchMap.Questions...),
Member: memberCtx,
Crawler: l.makeCrawlerSearchFn(tenantID, uid),
})
if len(trendSnippets) > 0 {
trendSourceLabel = memberCtx.DiscoverPathLabel()
llmOnly = false
}
if len(trendSnippets) == 0 && placement.WebSearchAvailable(research) {
webClient := websearch.New(memberCtx.WebSearchConfig())
trendSnippets = libviral.CollectMissionInspireTrends(
l.ctx,
webClient,
memberCtx,
persona.Brief+" "+userKeyword,
persona.StyleBenchmark,
)
webSearchProvider = memberCtx.WebSearchProviderLabel()
trendSourceLabel = webSearchProvider
llmOnly = len(trendSnippets) == 0
}
}
}
webSearchUsed := len(trendSnippets) > 0 && webSearchProvider != ""
credential, err := l.svcCtx.ThreadsAccount.ResolveMemberAiCredential(l.ctx, tenantID, uid)
if err != nil {
return nil, err
@ -106,47 +60,280 @@ func (l *InspireCopyMissionLogic) InspireCopyMission(req *types.CopyMissionInspi
return nil, err
}
keyword := strings.TrimSpace(req.Keyword)
trends, sources, searchUsed := []libviral.MissionInspireTrendSnippet(nil), []types.CopyMissionInspirationSourceData(nil), false
if req.UseWebSearch {
trends, sources, searchUsed = l.collectTrendSignals(tenantID, uid, persona, keyword)
}
llmOnly := len(trends) == 0
if keyword != "" && llmOnly {
trends = append(trends, libviral.MissionInspireTrendSnippet{
Query: keyword,
Title: "使用者指定方向",
Snippet: keyword,
})
}
personaBlock := style8d.ResolvePersonaBlock(persona.Persona, persona.StyleProfile, persona.Brief)
temp := 0.72
result, err := l.svcCtx.AI.GenerateText(l.ctx, domai.GenerateRequest{
Provider: providerID,
Model: credential.Model,
Credential: domai.Credential{
APIKey: credential.APIKey,
},
System: libviral.BuildMissionInspireSystemPrompt(),
Messages: []domai.Message{
{
Role: "user",
Content: libviral.BuildMissionInspireUserPrompt(libviral.MissionInspireInput{
PersonaDisplayName: persona.DisplayName,
PersonaBrief: persona.Brief,
PersonaBlock: personaBlock,
StyleBenchmark: persona.StyleBenchmark,
PersonaAudience: persona.CopyResearchMap.AudienceSummary,
PersonaContentGoal: persona.CopyResearchMap.ContentGoal,
PersonaQuestions: append([]string(nil), persona.CopyResearchMap.Questions...),
PersonaPillars: append([]string(nil), persona.CopyResearchMap.Pillars...),
RecentMissionLabels: recentLabels,
RecentSeedQueries: recentSeeds,
UserKeyword: userKeyword,
TrendSnippets: trendSnippets,
WebSearchProvider: trendSourceLabel,
LLMOnly: llmOnly,
}),
},
},
Provider: providerID,
Model: credential.Model,
Credential: domai.Credential{APIKey: credential.APIKey},
System: libviral.BuildMissionInspireSystemPrompt(),
Temperature: &temp,
Messages: []domai.Message{{Role: "user", Content: libviral.BuildMissionInspireUserPrompt(libviral.MissionInspireInput{
PersonaDisplayName: persona.DisplayName,
PersonaBrief: persona.Brief,
PersonaBlock: personaBlock,
StyleBenchmark: persona.StyleBenchmark,
AvoidTopics: cleanAvoidTopics(req.AvoidTopics),
ContentDirection: req.ContentDirection,
UserKeyword: keyword,
TrendSnippets: trends,
WebSearchProvider: sourceLabel(searchUsed, sources),
LLMOnly: llmOnly,
})}},
})
if err != nil {
return nil, err
return nil, app.For(code.AI).SvcThirdParty("起號靈感生成失敗:" + err.Error())
}
parsed, err := libviral.ParseMissionInspireOutput(result.Text)
if err != nil {
return nil, app.For(code.AI).SvcThirdParty("靈感骰子 LLM 回傳無法解析:" + err.Error())
return nil, app.For(code.AI).SvcThirdParty("起號靈感回傳無法解析:" + err.Error())
}
sources := make([]types.CopyMissionInspirationSourceData, 0, len(trendSnippets))
for _, item := range trendSnippets {
topicCandidate, plan := l.persistInspiration(tenantID, uid, personaID, parsed, searchUsed)
return &types.CopyMissionInspirationData{
TopicCandidateID: topicCandidate,
ContentPlanID: plan,
Label: parsed.Label,
SeedQuery: parsed.SeedQuery,
Brief: parsed.Brief,
TrendReason: parsed.TrendReason,
TrendKeywords: parsed.TrendKeywords,
Angles: parsed.Angles,
Mission: parsed.Mission,
TargetAudience: parsed.TargetAudience,
OpeningType: parsed.OpeningType,
BodyType: parsed.BodyType,
Emotion: parsed.Emotion,
CtaType: parsed.CtaType,
RiskLevel: parsed.RiskLevel,
Avoid: parsed.Avoid,
Sources: sources,
WebSearchUsed: searchUsed,
Message: "已找到一個可延伸的話題與內容計畫",
}, nil
}
func (l *InspireCopyMissionLogic) persistInspiration(tenantID, uid, personaID string, parsed libviral.MissionInspireOutput, searchUsed bool) (string, string) {
if l.svcCtx.ContentOps == nil {
return "", ""
}
riskScore := riskScoreFromLevel(parsed.RiskLevel)
category := parsed.Mission
if category == "" {
category = "候選話題"
}
source := "llm"
if searchUsed {
source = "topic_radar"
}
topic, err := l.svcCtx.ContentOps.CreateTopicCandidate(l.ctx, contentops.TopicCandidateInput{
TenantID: tenantID,
OwnerUID: uid,
PersonaID: personaID,
Name: parsed.Label,
Source: source,
Category: category,
SeedQuery: parsed.SeedQuery,
TrendReason: parsed.TrendReason,
TrendKeywords: parsed.TrendKeywords,
HeatScore: scoreBySearch(searchUsed),
FitScore: 82,
InteractionScore: 70,
ExtendScore: 78,
RiskScore: riskScore,
RecommendedMissions: []string{parsed.Mission},
})
if err != nil {
l.Errorf("persist topic candidate: %v", err)
return "", ""
}
angle := ""
if len(parsed.Angles) > 0 {
angle = parsed.Angles[0]
}
plan, err := l.svcCtx.ContentOps.CreateContentPlan(l.ctx, contentops.ContentPlanInput{
TenantID: tenantID,
OwnerUID: uid,
PersonaID: personaID,
TopicCandidateID: topic.ID,
Topic: parsed.Label,
Mission: fallbackString(parsed.Mission, "互動文"),
TargetAudience: parsed.TargetAudience,
Angle: angle,
OpeningType: parsed.OpeningType,
BodyType: parsed.BodyType,
Emotion: parsed.Emotion,
CtaType: parsed.CtaType,
RiskLevel: parsed.RiskLevel,
RequiresHumanReview: parsed.RiskLevel != "low",
Avoid: parsed.Avoid,
})
if err != nil {
l.Errorf("persist content plan: %v", err)
return topic.ID, ""
}
return topic.ID, plan.ID
}
func scoreBySearch(searchUsed bool) int {
if searchUsed {
return 76
}
return 55
}
func riskScoreFromLevel(level string) int {
switch strings.ToLower(strings.TrimSpace(level)) {
case "high":
return 75
case "low":
return 20
default:
return 45
}
}
func fallbackString(value, fallback string) string {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
return fallback
}
func (l *InspireCopyMissionLogic) collectTrendSignals(tenantID, uid string, persona *personadomain.PersonaSummary, keyword string) ([]libviral.MissionInspireTrendSnippet, []types.CopyMissionInspirationSourceData, bool) {
if persona == nil {
return nil, nil, false
}
trends := make([]libviral.MissionInspireTrendSnippet, 0, 12)
seen := map[string]struct{}{}
addTrend := func(item libviral.MissionInspireTrendSnippet) {
item.Query = strings.TrimSpace(item.Query)
item.Title = strings.TrimSpace(item.Title)
item.Snippet = strings.TrimSpace(item.Snippet)
item.URL = strings.TrimSpace(item.URL)
if item.Title == "" && item.Snippet == "" {
return
}
if isNewsLikeInspiration(item.Title, item.Snippet, item.URL) {
return
}
key := strings.ToLower(item.Query + "|" + item.Title + "|" + item.Snippet)
if _, ok := seen[key]; ok {
return
}
seen[key] = struct{}{}
trends = append(trends, item)
}
for _, item := range libviral.CollectFreeTopicTrends(l.ctx, keyword, persona.Brief) {
addTrend(item)
}
research, err := l.svcCtx.Placement.ResearchSettings(l.ctx, tenantID, uid)
if err != nil {
sources := inspirationSourcesFromTrends(trends)
return trends, sources, len(sources) > 0
}
memberCtx, err := l.svcCtx.ThreadsAccount.ResolveMemberPlacementContext(l.ctx, tenantID, uid, research)
if err != nil {
sources := inspirationSourcesFromTrends(trends)
return trends, sources, len(sources) > 0
}
if placement.WebSearchAvailable(research) {
client := websearch.New(memberCtx.WebSearchConfig())
for _, item := range libviral.CollectMissionInspireTrends(l.ctx, client, memberCtx, strings.TrimSpace(persona.Brief+" "+keyword), persona.StyleBenchmark) {
addTrend(item)
}
if keyword != "" && client.Enabled() {
for _, query := range []string{
"Threads 台灣 " + keyword + " 熱門 關鍵字 討論",
keyword + " 請問 心得 推薦 Threads",
keyword + " 痛點 經驗 怎麼辦 社群",
keyword + " Dcard PTT Threads 熱門 討論",
} {
res, searchErr := client.Search(l.ctx, websearch.SearchOptions{
Query: query,
Limit: 4,
Mode: websearch.ModeKnowledgeExpand,
Country: memberCtx.BraveCountry,
SearchLang: memberCtx.BraveSearchLang,
UserLocation: memberCtx.ExaUserLocation,
StartPublishedDate: placement.FormatPublishedAfterISO(14, time.Now().UTC()),
})
if searchErr != nil || res.Status != "success" {
continue
}
for _, result := range res.Results {
addTrend(libviral.MissionInspireTrendSnippet{
Query: query,
Title: result.Title,
Snippet: result.Snippet,
URL: result.URL,
})
}
}
}
}
if keyword != "" && len(trends) < 8 {
for _, item := range libviral.CollectMissionInspireThreadsTrends(l.ctx, libviral.MissionInspireThreadsInput{
Keyword: keyword,
PersonaBrief: persona.Brief,
StyleBenchmark: persona.StyleBenchmark,
Pillars: persona.CopyResearchMap.Pillars,
Questions: persona.CopyResearchMap.Questions,
Member: memberCtx,
}) {
addTrend(item)
}
}
if len(trends) > 10 {
trends = trends[:10]
}
sources := inspirationSourcesFromTrends(trends)
return trends, sources, len(sources) > 0
}
func isNewsLikeInspiration(title, snippet, url string) bool {
text := strings.ToLower(title + " " + snippet + " " + url)
newsSignals := []string{"新聞", "快訊", "中央社", "聯合新聞", "ettoday", "tvbs", "setn", "ctwant", "chinatimes", "ltn.com", "news"}
socialSignals := []string{"threads", "dcard", "ptt", "請問", "心得", "推薦", "有人", "怎麼辦", "討論", "留言"}
newsScore := 0
for _, signal := range newsSignals {
if strings.Contains(text, strings.ToLower(signal)) {
newsScore++
}
}
if newsScore == 0 {
return false
}
for _, signal := range socialSignals {
if strings.Contains(text, strings.ToLower(signal)) {
return false
}
}
return true
}
func inspirationSourcesFromTrends(trends []libviral.MissionInspireTrendSnippet) []types.CopyMissionInspirationSourceData {
sources := make([]types.CopyMissionInspirationSourceData, 0, len(trends))
for _, item := range trends {
sources = append(sources, types.CopyMissionInspirationSourceData{
Query: item.Query,
Title: item.Title,
@ -154,37 +341,33 @@ func (l *InspireCopyMissionLogic) InspireCopyMission(req *types.CopyMissionInspi
URL: item.URL,
})
}
message := "已依近期 Threads 熱門素材產出任務靈感,可微調後建立任務"
if webSearchUsed {
message = "已依近期網路趨勢產出任務靈感,可微調後建立任務"
}
if len(trendSnippets) == 0 {
message = "未取得外部趨勢素材,已改由 LLM 依人設與關鍵字推測靈感;同步 Chrome Session、完成 Threads API 或補上 BraveExa key 可更貼近熱搜"
}
return &types.CopyMissionInspirationData{
Label: parsed.Label,
SeedQuery: parsed.SeedQuery,
Brief: parsed.Brief,
TrendReason: parsed.TrendReason,
TrendKeywords: parsed.TrendKeywords,
Sources: sources,
WebSearchUsed: webSearchUsed,
Message: message,
}, nil
return sources
}
func (l *InspireCopyMissionLogic) makeCrawlerSearchFn(tenantID, ownerUID string) placement.CrawlerSearchFn {
return func(ctx context.Context, member placement.MemberContext, keyword string, limit int) ([]placement.DiscoverPost, error) {
accountID := strings.TrimSpace(member.ActiveAccountID)
if accountID == "" {
return nil, app.For(code.Setting).InputMissingRequired("請先選定經營帳號並同步 Chrome Session")
}
session, err := l.svcCtx.ThreadsAccount.GetBrowserSession(ctx, tenantID, ownerUID, accountID)
if err != nil {
return nil, err
}
return placement.RunExecCrawlerSearch(ctx, session.StorageState, keyword, limit)
func sourceLabel(used bool, sources []types.CopyMissionInspirationSourceData) string {
if !used || len(sources) == 0 {
return ""
}
return "Free Trend / Web / Threads Search"
}
func cleanAvoidTopics(items []string) []string {
out := make([]string, 0, len(items))
seen := map[string]struct{}{}
for _, item := range items {
item = strings.TrimSpace(item)
if item == "" {
continue
}
key := strings.ToLower(item)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, item)
if len(out) >= 8 {
break
}
}
return out
}

View File

@ -1,67 +0,0 @@
package copy_mission
import (
kgusecase "haixun-backend/internal/model/knowledge_graph/domain/usecase"
"haixun-backend/internal/types"
)
func toKnowledgeGraphData(graph *kgusecase.GraphSummary) types.KnowledgeGraphData {
if graph == nil {
return types.KnowledgeGraphData{}
}
nodes := make([]types.KnowledgeGraphNodeData, 0, len(graph.Nodes))
for _, node := range graph.Nodes {
evidence := make([]types.KnowledgeGraphEvidenceData, 0, len(node.Evidence))
for _, item := range node.Evidence {
evidence = append(evidence, types.KnowledgeGraphEvidenceData{
URL: item.URL,
Snippet: item.Snippet,
Query: item.Query,
})
}
nodes = append(nodes, types.KnowledgeGraphNodeData{
ID: node.ID,
Label: node.Label,
NodeKind: node.NodeKind,
Type: node.Type,
Layer: node.Layer,
Relation: node.Relation,
PlacementValue: node.PlacementValue,
ProductFitScore: node.ProductFitScore,
SelectedForScan: node.SelectedForScan,
RelevanceTags: append([]string{}, node.DerivedTags.Relevance...),
RecencyTags: append([]string{}, node.DerivedTags.Recency...),
Evidence: evidence,
})
}
edges := make([]types.KnowledgeGraphEdgeData, 0, len(graph.Edges))
for _, edge := range graph.Edges {
edges = append(edges, types.KnowledgeGraphEdgeData{
From: edge.From,
To: edge.To,
Relation: edge.Relation,
})
}
sources := make([]types.BraveSourceData, 0, len(graph.BraveSources))
for _, src := range graph.BraveSources {
sources = append(sources, types.BraveSourceData{
Query: src.Query,
Title: src.Title,
URL: src.URL,
Snippet: src.Snippet,
})
}
return types.KnowledgeGraphData{
ID: graph.ID,
BrandID: graph.BrandID,
Seed: graph.Seed,
Nodes: nodes,
Edges: edges,
BraveSources: sources,
ExpandStrategy: graph.ExpandStrategy,
PainTagCount: graph.PainTagCount,
GeneratedAt: graph.GeneratedAt,
CreateAt: graph.CreateAt,
UpdateAt: graph.UpdateAt,
}
}

View File

@ -1,40 +1,33 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package copy_mission
import (
"context"
"strings"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type ListCopyMissionCopyDraftsLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewListCopyMissionCopyDraftsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListCopyMissionCopyDraftsLogic {
return &ListCopyMissionCopyDraftsLogic{ctx: ctx, svcCtx: svcCtx}
return &ListCopyMissionCopyDraftsLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *ListCopyMissionCopyDraftsLogic) ListCopyMissionCopyDrafts(req *types.CopyMissionPath) (*types.ListCopyMissionCopyDraftsData, error) {
tenantID, uid, err := actorFrom(l.ctx)
if err != nil {
return nil, err
}
personaID := strings.TrimSpace(req.PersonaID)
missionID := strings.TrimSpace(req.ID)
if _, err := l.svcCtx.CopyMission.Get(l.ctx, tenantID, uid, personaID, missionID); err != nil {
return nil, err
}
func (l *ListCopyMissionCopyDraftsLogic) ListCopyMissionCopyDrafts(req *types.CopyMissionPath) (resp *types.ListCopyMissionCopyDraftsData, err error) {
// todo: add your logic here and delete this line
drafts, err := l.svcCtx.CopyDraft.ListByMission(l.ctx, tenantID, uid, personaID, missionID, 50)
if err != nil {
return nil, err
}
list := make([]types.CopyDraftData, 0, len(drafts))
for _, item := range drafts {
list = append(list, toCopyDraftData(item))
}
return &types.ListCopyMissionCopyDraftsData{List: list, Total: len(list)}, nil
return
}

View File

@ -1,49 +1,33 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package copy_mission
import (
"context"
"strings"
scanpostdomain "haixun-backend/internal/model/scan_post/domain/usecase"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type ListCopyMissionScanPostsLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewListCopyMissionScanPostsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListCopyMissionScanPostsLogic {
return &ListCopyMissionScanPostsLogic{ctx: ctx, svcCtx: svcCtx}
return &ListCopyMissionScanPostsLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *ListCopyMissionScanPostsLogic) ListCopyMissionScanPosts(
req *types.ListCopyMissionScanPostsHandlerReq,
) (*types.ListPersonaViralScanPostsData, error) {
tenantID, uid, err := actorFrom(l.ctx)
if err != nil {
return nil, err
}
personaID := strings.TrimSpace(req.PersonaID)
missionID := strings.TrimSpace(req.ID)
if _, err := l.svcCtx.CopyMission.Get(l.ctx, tenantID, uid, personaID, missionID); err != nil {
return nil, err
}
func (l *ListCopyMissionScanPostsLogic) ListCopyMissionScanPosts(req *types.ListCopyMissionScanPostsHandlerReq) (resp *types.ListPersonaViralScanPostsData, err error) {
// todo: add your logic here and delete this line
limit := 100
if req.Limit > 0 {
limit = req.Limit
}
posts, err := l.svcCtx.ScanPost.ListForPersona(l.ctx, scanpostdomain.PersonaListRequest{
TenantID: tenantID,
OwnerUID: uid,
PersonaID: personaID,
CopyMissionID: missionID,
Limit: limit,
})
if err != nil {
return nil, err
}
return toViralScanPostsData(posts), nil
return
}

View File

@ -1,8 +1,10 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package copy_mission
import (
"context"
"strings"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
@ -24,18 +26,8 @@ func NewListCopyMissionsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *
}
}
func (l *ListCopyMissionsLogic) ListCopyMissions(req *types.PersonaCopyMissionsPath) (*types.ListCopyMissionsData, error) {
tenantID, uid, err := actorFrom(l.ctx)
if err != nil {
return nil, err
}
personaID := strings.TrimSpace(req.PersonaID)
if _, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID); err != nil {
return nil, err
}
result, err := l.svcCtx.CopyMission.List(l.ctx, tenantID, uid, personaID)
if err != nil {
return nil, err
}
return toListData(result), nil
func (l *ListCopyMissionsLogic) ListCopyMissions(req *types.PersonaCopyMissionsPath) (resp *types.ListCopyMissionsData, err error) {
// todo: add your logic here and delete this line
return
}

View File

@ -1,300 +0,0 @@
package copy_mission
import (
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"
personadomain "haixun-backend/internal/model/persona/domain/usecase"
scanpostdomain "haixun-backend/internal/model/scan_post/domain/usecase"
"haixun-backend/internal/types"
)
func seedMissionResearchFromPersona(persona personadomain.PersonaSummary) (*missionentity.ResearchMap, []string) {
src := persona.CopyResearchMap
if src.AudienceSummary == "" && src.ContentGoal == "" && len(src.SuggestedTags) == 0 {
return nil, nil
}
tags := make([]missionentity.SuggestedTag, 0, len(src.SuggestedTags))
for _, tag := range src.SuggestedTags {
if tag == "" {
continue
}
tags = append(tags, missionentity.SuggestedTag{Tag: tag})
}
selected := append([]string(nil), src.SuggestedTags...)
if len(selected) > 6 {
selected = selected[:6]
}
rm := &missionentity.ResearchMap{
AudienceSummary: src.AudienceSummary,
ContentGoal: src.ContentGoal,
Questions: append([]string(nil), src.Questions...),
Pillars: append([]string(nil), src.Pillars...),
Exclusions: append([]string(nil), src.Exclusions...),
SuggestedTags: tags,
BenchmarkNotes: src.BenchmarkNotes,
}
return rm, selected
}
func toMissionPatch(req *types.UpdateCopyMissionReq) missiondomain.MissionPatch {
if req == nil {
return missiondomain.MissionPatch{}
}
patch := missiondomain.MissionPatch{
Label: req.Label,
SeedQuery: req.SeedQuery,
Brief: req.Brief,
}
if req.AudienceSummary != nil {
patch.AudienceSummary = req.AudienceSummary
}
if req.ContentGoal != nil {
patch.ContentGoal = req.ContentGoal
}
if req.Questions != nil {
patch.QuestionsSet = true
patch.Questions = append([]string(nil), req.Questions...)
}
if req.Pillars != nil {
patch.PillarsSet = true
patch.Pillars = append([]string(nil), req.Pillars...)
}
if req.Exclusions != nil {
patch.ExclusionsSet = true
patch.Exclusions = append([]string(nil), req.Exclusions...)
}
if req.KnowledgeItems != nil {
patch.KnowledgeItemsSet = true
patch.KnowledgeItems = append([]string(nil), req.KnowledgeItems...)
}
if req.SelectedKnowledgeItems != nil {
patch.SelectedKnowledgeItemsSet = true
patch.SelectedKnowledgeItems = append([]string(nil), req.SelectedKnowledgeItems...)
}
if req.BenchmarkNotes != nil {
patch.BenchmarkNotes = req.BenchmarkNotes
}
if req.SelectedTags != nil {
patch.SelectedTagsSet = true
patch.SelectedTags = append([]string(nil), req.SelectedTags...)
}
return patch
}
func toResearchItemData(items []missiondomain.ResearchItemSummary) []types.CopyMissionResearchItemData {
out := make([]types.CopyMissionResearchItemData, 0, len(items))
for _, item := range items {
out = append(out, types.CopyMissionResearchItemData{
Title: item.Title,
URL: item.URL,
Snippet: item.Snippet,
Query: item.Query,
})
}
return out
}
func toCopyMissionData(item missiondomain.MissionSummary) types.CopyMissionData {
tags := make([]types.CopySuggestedTagData, 0, len(item.ResearchMap.SuggestedTags))
for _, tag := range item.ResearchMap.SuggestedTags {
tags = append(tags, types.CopySuggestedTagData{
Tag: tag.Tag,
Reason: tag.Reason,
SearchIntent: tag.SearchIntent,
SearchType: tag.SearchType,
})
}
accounts := make([]types.CopySimilarAccountData, 0, len(item.ResearchMap.SimilarAccounts))
for _, acc := range item.ResearchMap.SimilarAccounts {
accounts = append(accounts, types.CopySimilarAccountData{
Username: acc.Username,
Reason: acc.Reason,
Source: acc.Source,
MatchedSource: append([]string(nil), acc.MatchedSource...),
Confidence: acc.Confidence,
Status: acc.Status,
TopicRelevance: acc.TopicRelevance,
LastSeenAt: acc.LastSeenAt,
ProfileUrl: acc.ProfileURL,
AuthorVerified: acc.AuthorVerified,
FollowerCount: acc.FollowerCount,
EngagementScore: acc.EngagementScore,
LikeCount: acc.LikeCount,
ReplyCount: acc.ReplyCount,
PostCount: acc.PostCount,
})
}
samples := make([]types.CopyAudienceSampleData, 0, len(item.ResearchMap.AudienceSamples))
for _, sample := range item.ResearchMap.AudienceSamples {
samples = append(samples, types.CopyAudienceSampleData{
Username: sample.Username,
SamplePostId: sample.SamplePostID,
SampleText: sample.SampleText,
ReplyLikeCount: sample.ReplyLikeCount,
Appearances: sample.Appearances,
FirstSeenAt: sample.FirstSeenAt,
LastSeenAt: sample.LastSeenAt,
Status: sample.Status,
})
}
return types.CopyMissionData{
ID: item.ID,
PersonaID: item.PersonaID,
Label: item.Label,
SeedQuery: item.SeedQuery,
Brief: item.Brief,
ResearchMap: types.CopyMissionResearchMapData{
AudienceSummary: item.ResearchMap.AudienceSummary,
ContentGoal: item.ResearchMap.ContentGoal,
Questions: append([]string(nil), item.ResearchMap.Questions...),
Pillars: append([]string(nil), item.ResearchMap.Pillars...),
Exclusions: append([]string(nil), item.ResearchMap.Exclusions...),
KnowledgeItems: append([]string(nil), item.ResearchMap.KnowledgeItems...),
SelectedKnowledgeItems: append([]string(nil), item.ResearchMap.SelectedKnowledgeItems...),
ResearchItems: toResearchItemData(item.ResearchMap.ResearchItems),
SuggestedTags: tags,
SimilarAccounts: accounts,
AudienceSamples: samples,
BenchmarkNotes: item.ResearchMap.BenchmarkNotes,
},
SelectedTags: append([]string(nil), item.SelectedTags...),
LastScanJobID: item.LastScanJobID,
Status: item.Status,
CreateAt: item.CreateAt,
UpdateAt: item.UpdateAt,
}
}
func toListData(result *missiondomain.ListResult) *types.ListCopyMissionsData {
if result == nil {
return &types.ListCopyMissionsData{List: []types.CopyMissionData{}}
}
list := make([]types.CopyMissionData, 0, len(result.List))
for _, item := range result.List {
list = append(list, toCopyMissionData(item))
}
return &types.ListCopyMissionsData{List: list}
}
func toEntityResearchMap(data types.CopyMissionResearchMapData) missionentity.ResearchMap {
tags := make([]missionentity.SuggestedTag, 0, len(data.SuggestedTags))
for _, tag := range data.SuggestedTags {
tags = append(tags, missionentity.SuggestedTag{
Tag: tag.Tag,
Reason: tag.Reason,
SearchIntent: tag.SearchIntent,
SearchType: tag.SearchType,
})
}
accounts := make([]missionentity.SimilarAccount, 0, len(data.SimilarAccounts))
for _, acc := range data.SimilarAccounts {
accounts = append(accounts, missionentity.SimilarAccount{
Username: acc.Username,
Reason: acc.Reason,
Source: acc.Source,
MatchedSource: append([]string(nil), acc.MatchedSource...),
Confidence: acc.Confidence,
Status: acc.Status,
TopicRelevance: acc.TopicRelevance,
LastSeenAt: acc.LastSeenAt,
ProfileURL: acc.ProfileUrl,
AuthorVerified: acc.AuthorVerified,
FollowerCount: acc.FollowerCount,
EngagementScore: acc.EngagementScore,
LikeCount: acc.LikeCount,
ReplyCount: acc.ReplyCount,
PostCount: acc.PostCount,
})
}
samples := make([]missionentity.AudienceSample, 0, len(data.AudienceSamples))
for _, sample := range data.AudienceSamples {
samples = append(samples, missionentity.AudienceSample{
Username: sample.Username,
SamplePostID: sample.SamplePostId,
SampleText: sample.SampleText,
ReplyLikeCount: sample.ReplyLikeCount,
Appearances: sample.Appearances,
FirstSeenAt: sample.FirstSeenAt,
LastSeenAt: sample.LastSeenAt,
Status: sample.Status,
})
}
return missionentity.ResearchMap{
AudienceSummary: data.AudienceSummary,
ContentGoal: data.ContentGoal,
Questions: append([]string(nil), data.Questions...),
Pillars: append([]string(nil), data.Pillars...),
Exclusions: append([]string(nil), data.Exclusions...),
KnowledgeItems: append([]string(nil), data.KnowledgeItems...),
SelectedKnowledgeItems: append([]string(nil), data.SelectedKnowledgeItems...),
SuggestedTags: tags,
SimilarAccounts: accounts,
AudienceSamples: samples,
BenchmarkNotes: data.BenchmarkNotes,
}
}
func toViralScanPostsData(posts []scanpostdomain.ScanPostSummary) *types.ListPersonaViralScanPostsData {
list := make([]types.ViralScanPostData, 0, len(posts))
for _, post := range posts {
list = append(list, types.ViralScanPostData{
ID: post.ID,
SearchTag: post.SearchTag,
Permalink: post.Permalink,
Author: post.Author,
AuthorVerified: post.AuthorVerified,
FollowerCount: post.FollowerCount,
Text: post.Text,
LikeCount: post.LikeCount,
ReplyCount: post.ReplyCount,
EngagementScore: post.EngagementScore,
Source: post.Source,
ScanJobID: post.ScanJobID,
Replies: toScanReplyData(post.Replies),
CreateAt: post.CreateAt,
})
}
return &types.ListPersonaViralScanPostsData{List: list, Total: len(list)}
}
func toScanReplyData(replies []scanpostdomain.ScanReplySummary) []types.ScanReplyData {
if len(replies) == 0 {
return nil
}
out := make([]types.ScanReplyData, 0, len(replies))
for _, reply := range replies {
out = append(out, types.ScanReplyData{
ExternalID: reply.ExternalID,
Author: reply.Author,
Text: reply.Text,
Permalink: reply.Permalink,
LikeCount: reply.LikeCount,
PostedAt: reply.PostedAt,
})
}
return out
}
func toCopyDraftData(item copydraftdomain.CopyDraftSummary) types.CopyDraftData {
return types.CopyDraftData{
ID: item.ID,
PersonaID: item.PersonaID,
CopyMissionID: item.CopyMissionID,
ScanPostID: item.ScanPostID,
DraftType: item.DraftType,
SortOrder: item.SortOrder,
Text: item.Text,
Angle: item.Angle,
Hook: item.Hook,
Rationale: item.Rationale,
ReferenceNotes: item.ReferenceNotes,
Sources: item.Sources,
Status: item.Status,
PublishQueueID: item.PublishQueueID,
PublishedMediaID: item.PublishedMediaID,
PublishedPermalink: item.PublishedPermalink,
PublishedAt: item.PublishedAt,
CreateAt: item.CreateAt,
}
}

View File

@ -1,42 +0,0 @@
package copy_mission
import (
"context"
"strings"
app "haixun-backend/internal/library/errors"
"haixun-backend/internal/library/errors/code"
"haixun-backend/internal/model/job/domain/enum"
domrepo "haixun-backend/internal/model/job/domain/repository"
"haixun-backend/internal/svc"
)
func ensureNoActiveMatrixJob(ctx context.Context, svcCtx *svc.ServiceContext, missionID string) error {
if svcCtx == nil || svcCtx.Job == nil {
return nil
}
missionID = strings.TrimSpace(missionID)
if missionID == "" {
return nil
}
runs, _, _, _, _, err := svcCtx.Job.ListRuns(ctx, domrepo.RunListFilter{
Scope: "copy_mission",
ScopeID: missionID,
Statuses: []enum.RunStatus{
enum.RunStatusPending,
enum.RunStatusQueued,
enum.RunStatusRunning,
enum.RunStatusWaitingWorker,
enum.RunStatusCancelRequested,
},
}, 1, 20)
if err != nil {
return err
}
for _, run := range runs {
if run != nil && run.TemplateType == "generate-copy-matrix" {
return app.For(code.Job).ResInvalidState("內容矩陣背景任務進行中,請稍後再試")
}
}
return nil
}

View File

@ -5,9 +5,7 @@ package copy_mission
import (
"context"
"strings"
missiondomain "haixun-backend/internal/model/copy_mission/domain/usecase"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
@ -28,22 +26,8 @@ func NewPatchCopyMissionAudienceSampleLogic(ctx context.Context, svcCtx *svc.Ser
}
}
func (l *PatchCopyMissionAudienceSampleLogic) PatchCopyMissionAudienceSample(req *types.PatchAudienceSampleHandlerReq) (*types.CopyMissionData, error) {
tenantID, uid, err := actorFrom(l.ctx)
if err != nil {
return nil, err
}
item, err := l.svcCtx.CopyMission.PatchAudienceSample(l.ctx, missiondomain.PatchAudienceSampleRequest{
TenantID: tenantID,
OwnerUID: uid,
PersonaID: strings.TrimSpace(req.PersonaID),
MissionID: strings.TrimSpace(req.ID),
Username: strings.TrimSpace(req.Username),
Status: req.Status,
})
if err != nil {
return nil, err
}
data := toCopyMissionData(*item)
return &data, nil
func (l *PatchCopyMissionAudienceSampleLogic) PatchCopyMissionAudienceSample(req *types.PatchAudienceSampleHandlerReq) (resp *types.CopyMissionData, err error) {
// todo: add your logic here and delete this line
return
}

View File

@ -1,14 +1,11 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package copy_mission
import (
"context"
"strings"
libcopy "haixun-backend/internal/library/copymission"
app "haixun-backend/internal/library/errors"
"haixun-backend/internal/library/errors/code"
missiondomain "haixun-backend/internal/model/copy_mission/domain/usecase"
kgusecase "haixun-backend/internal/model/knowledge_graph/domain/usecase"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
@ -29,73 +26,8 @@ func NewPatchCopyMissionGraphNodesLogic(ctx context.Context, svcCtx *svc.Service
}
}
func (l *PatchCopyMissionGraphNodesLogic) PatchCopyMissionGraphNodes(req *types.PatchCopyMissionGraphNodesHandlerReq) (*types.KnowledgeGraphData, error) {
tenantID, uid, err := actorFrom(l.ctx)
if err != nil {
return nil, err
}
if len(req.Updates) == 0 {
return nil, app.For(code.Persona).InputMissingRequired("updates is required")
}
personaID := strings.TrimSpace(req.PersonaID)
missionID := strings.TrimSpace(req.ID)
mission, err := l.svcCtx.CopyMission.Get(l.ctx, tenantID, uid, personaID, missionID)
if err != nil {
return nil, err
}
func (l *PatchCopyMissionGraphNodesLogic) PatchCopyMissionGraphNodes(req *types.PatchCopyMissionGraphNodesHandlerReq) (resp *types.KnowledgeGraphData, err error) {
// todo: add your logic here and delete this line
updates := make([]kgusecase.NodeUpdate, 0, len(req.Updates))
for _, item := range req.Updates {
nodeID := strings.TrimSpace(item.NodeID)
if nodeID == "" {
continue
}
update := kgusecase.NodeUpdate{NodeID: nodeID}
if item.SelectedForScan != nil {
update.SelectedForScan = item.SelectedForScan
}
if item.RelevanceTags != nil {
update.RelevanceTagsSet = true
update.RelevanceTags = append([]string(nil), item.RelevanceTags...)
}
if item.RecencyTags != nil {
update.RecencyTagsSet = true
update.RecencyTags = append([]string(nil), item.RecencyTags...)
}
if update.SelectedForScan == nil && !update.RelevanceTagsSet && !update.RecencyTagsSet {
return nil, app.For(code.Persona).InputMissingRequired("each update needs selected_for_scan or patrol tags")
}
updates = append(updates, update)
}
if len(updates) == 0 {
return nil, app.For(code.Persona).InputMissingRequired("updates is required")
}
graph, err := l.svcCtx.KnowledgeGraph.UpdateNodes(l.ctx, kgusecase.UpdateNodesRequest{
TenantID: tenantID,
OwnerUID: uid,
CopyMissionID: missionID,
Updates: updates,
})
if err != nil {
return nil, err
}
prev := summaryToEntityResearchMap(mission.ResearchMap)
researchMap := libcopy.BuildResearchMapFromGraph(prev, graph.Nodes, graph.BraveSources)
_, err = l.svcCtx.CopyMission.Update(l.ctx, missiondomain.UpdateRequest{
TenantID: tenantID,
OwnerUID: uid,
PersonaID: personaID,
MissionID: missionID,
Patch: missiondomain.MissionPatch{
ResearchMap: &researchMap,
},
})
if err != nil {
return nil, err
}
data := toKnowledgeGraphData(graph)
return &data, nil
return
}

View File

@ -5,11 +5,7 @@ package copy_mission
import (
"context"
"strings"
missionentity "haixun-backend/internal/model/copy_mission/domain/entity"
missiondomain "haixun-backend/internal/model/copy_mission/domain/usecase"
threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
@ -30,40 +26,8 @@ func NewPatchCopyMissionSimilarAccountLogic(ctx context.Context, svcCtx *svc.Ser
}
}
func (l *PatchCopyMissionSimilarAccountLogic) PatchCopyMissionSimilarAccount(req *types.PatchSimilarAccountHandlerReq) (*types.CopyMissionData, error) {
tenantID, uid, err := actorFrom(l.ctx)
if err != nil {
return nil, err
}
item, err := l.svcCtx.CopyMission.PatchSimilarAccount(l.ctx, missiondomain.PatchSimilarAccountRequest{
TenantID: tenantID,
OwnerUID: uid,
PersonaID: strings.TrimSpace(req.PersonaID),
MissionID: strings.TrimSpace(req.ID),
Username: strings.TrimSpace(req.Username),
Status: req.Status,
})
if err != nil {
return nil, err
}
if status := strings.TrimSpace(req.Status); status == missionentity.SimilarAccountStatusExcluded || status == missionentity.SimilarAccountStatusRecommended {
if research, researchErr := l.svcCtx.Placement.ResearchSettings(l.ctx, tenantID, uid); researchErr == nil {
if memberCtx, memberErr := l.svcCtx.ThreadsAccount.ResolveMemberPlacementContext(l.ctx, tenantID, uid, research); memberErr == nil && memberCtx.ActiveAccountID != "" {
username := strings.TrimSpace(strings.TrimPrefix(req.Username, "@"))
if username != "" {
_ = l.svcCtx.ThreadsAccount.UpsertKnownAccountsFromScan(l.ctx, threadsaccountdomain.UpsertKnownAccountsFromScanRequest{
TenantID: tenantID,
OwnerUID: uid,
AccountID: memberCtx.ActiveAccountID,
SimilarAccounts: []missionentity.SimilarAccount{{
Username: username,
Status: status,
}},
})
}
}
}
}
data := toCopyMissionData(*item)
return &data, nil
func (l *PatchCopyMissionSimilarAccountLogic) PatchCopyMissionSimilarAccount(req *types.PatchSimilarAccountHandlerReq) (resp *types.CopyMissionData, err error) {
// todo: add your logic here and delete this line
return
}

View File

@ -1,78 +0,0 @@
package copy_mission
import (
missionentity "haixun-backend/internal/model/copy_mission/domain/entity"
missiondomain "haixun-backend/internal/model/copy_mission/domain/usecase"
)
func summaryResearchItemsToEntity(items []missiondomain.ResearchItemSummary) []missionentity.ResearchItem {
out := make([]missionentity.ResearchItem, 0, len(items))
for _, item := range items {
out = append(out, missionentity.ResearchItem{
Title: item.Title,
URL: item.URL,
Snippet: item.Snippet,
Query: item.Query,
})
}
return out
}
func summaryToEntityResearchMap(summary missiondomain.ResearchMapSummary) missionentity.ResearchMap {
tags := make([]missionentity.SuggestedTag, 0, len(summary.SuggestedTags))
for _, tag := range summary.SuggestedTags {
tags = append(tags, missionentity.SuggestedTag{
Tag: tag.Tag,
Reason: tag.Reason,
SearchIntent: tag.SearchIntent,
SearchType: tag.SearchType,
})
}
accounts := make([]missionentity.SimilarAccount, 0, len(summary.SimilarAccounts))
for _, acc := range summary.SimilarAccounts {
accounts = append(accounts, missionentity.SimilarAccount{
Username: acc.Username,
Reason: acc.Reason,
Source: acc.Source,
MatchedSource: append([]string(nil), acc.MatchedSource...),
Confidence: acc.Confidence,
Status: acc.Status,
TopicRelevance: acc.TopicRelevance,
LastSeenAt: acc.LastSeenAt,
ProfileURL: acc.ProfileURL,
AuthorVerified: acc.AuthorVerified,
FollowerCount: acc.FollowerCount,
EngagementScore: acc.EngagementScore,
LikeCount: acc.LikeCount,
ReplyCount: acc.ReplyCount,
PostCount: acc.PostCount,
})
}
samples := make([]missionentity.AudienceSample, 0, len(summary.AudienceSamples))
for _, sample := range summary.AudienceSamples {
samples = append(samples, missionentity.AudienceSample{
Username: sample.Username,
SamplePostID: sample.SamplePostID,
SampleText: sample.SampleText,
ReplyLikeCount: sample.ReplyLikeCount,
Appearances: sample.Appearances,
FirstSeenAt: sample.FirstSeenAt,
LastSeenAt: sample.LastSeenAt,
Status: sample.Status,
})
}
return missionentity.ResearchMap{
AudienceSummary: summary.AudienceSummary,
ContentGoal: summary.ContentGoal,
Questions: append([]string(nil), summary.Questions...),
Pillars: append([]string(nil), summary.Pillars...),
Exclusions: append([]string(nil), summary.Exclusions...),
KnowledgeItems: append([]string(nil), summary.KnowledgeItems...),
SelectedKnowledgeItems: append([]string(nil), summary.SelectedKnowledgeItems...),
ResearchItems: summaryResearchItemsToEntity(summary.ResearchItems),
SuggestedTags: tags,
SimilarAccounts: accounts,
AudienceSamples: samples,
BenchmarkNotes: summary.BenchmarkNotes,
}
}

View File

@ -5,13 +5,7 @@ package copy_mission
import (
"context"
"fmt"
app "haixun-backend/internal/library/errors"
"haixun-backend/internal/library/errors/code"
"haixun-backend/internal/library/publishschedule"
copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase"
pqdomain "haixun-backend/internal/model/publish_queue/domain/usecase"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
@ -32,68 +26,8 @@ func NewScheduleCopyMissionDraftsLogic(ctx context.Context, svcCtx *svc.ServiceC
}
}
func (l *ScheduleCopyMissionDraftsLogic) ScheduleCopyMissionDrafts(req *types.ScheduleCopyMissionDraftsHandlerReq) (resp *types.ScheduleCopyDraftsData, err error) {
tenantID, uid, err := actorFrom(l.ctx)
if err != nil {
return nil, err
}
if _, err := l.svcCtx.CopyMission.Get(l.ctx, tenantID, uid, req.PersonaID, req.ID); err != nil {
return nil, err
}
if _, err := l.svcCtx.ThreadsAccount.Get(l.ctx, tenantID, uid, req.AccountID); err != nil {
return nil, err
}
times := scheduleTimes(req.StartAt, req.Timezone, req.Slots, len(req.DraftIDs))
list := make([]types.PublishQueueItemData, 0, len(req.DraftIDs))
for idx, draftID := range req.DraftIDs {
draft, err := l.svcCtx.CopyDraft.Get(l.ctx, tenantID, uid, req.PersonaID, draftID)
if err != nil {
return nil, err
}
if draft.CopyMissionID != req.ID {
return nil, app.For(code.Persona).InputInvalidFormat("草稿不屬於此文案任務")
}
scheduledAt := int64(0)
if idx < len(times) {
scheduledAt = times[idx]
}
item, err := l.svcCtx.PublishQueue.Create(l.ctx, pqdomain.CreateRequest{
TenantID: tenantID, OwnerUID: uid, AccountID: req.AccountID,
PersonaID: req.PersonaID, CopyMissionID: req.ID, CopyDraftID: draft.ID,
Text: draft.Text, ScheduledAt: scheduledAt,
})
if err != nil {
return nil, err
}
_, _ = l.svcCtx.CopyDraft.MarkScheduled(l.ctx, copydraftdomain.MarkScheduledRequest{
TenantID: tenantID, OwnerUID: uid, PersonaID: req.PersonaID, DraftID: draft.ID, QueueID: item.ID,
})
list = append(list, publishQueueData(item))
}
return &types.ScheduleCopyDraftsData{
Scheduled: len(list), List: list, Message: fmt.Sprintf("已排程 %d 篇草稿", len(list)),
}, nil
}
func (l *ScheduleCopyMissionDraftsLogic) ScheduleCopyMissionDrafts(req *types.ScheduleCopyMissionDraftsHandlerReq) (resp *types.CopyMissionScheduleCopyDraftsData, err error) {
// todo: add your logic here and delete this line
func scheduleTimes(startAt int64, timezone string, slots []types.PublishSlotData, count int) []int64 {
converted := make([]publishschedule.Slot, 0, len(slots))
for _, slot := range slots {
converted = append(converted, publishschedule.Slot{Weekday: slot.Weekday, Time: slot.Time})
}
return publishschedule.BuildSchedule(startAt, timezone, converted, count)
}
func publishQueueData(item *pqdomain.QueueItemSummary) types.PublishQueueItemData {
if item == nil {
return types.PublishQueueItemData{}
}
return types.PublishQueueItemData{
ID: item.ID, AccountID: item.AccountID, PersonaID: item.PersonaID,
CopyMissionID: item.CopyMissionID, CopyDraftID: item.CopyDraftID,
Text: item.Text, ScheduledAt: item.ScheduledAt, Status: item.Status,
MediaID: item.MediaID, Permalink: item.Permalink, PublishedAt: item.PublishedAt,
ErrorMessage: item.ErrorMessage, RetryCount: item.RetryCount, LastAttemptAt: item.LastAttemptAt,
NextAttemptAt: item.NextAttemptAt, MissedAt: item.MissedAt, PausedReason: item.PausedReason,
CreateAt: item.CreateAt, UpdateAt: item.UpdateAt,
}
return
}

View File

@ -1,49 +0,0 @@
package copy_mission
import (
"context"
jobentity "haixun-backend/internal/model/job/domain/entity"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
)
const viralScanTemplate = "scan-viral"
func findCopyMissionScanSchedule(ctx context.Context, svcCtx *svc.ServiceContext, missionID string) (*jobentity.Schedule, error) {
schedules, _, _, _, _, err := svcCtx.Job.ListSchedules(ctx, "copy_mission", missionID, 1, 50)
if err != nil {
return nil, err
}
for _, schedule := range schedules {
if schedule != nil && schedule.TemplateType == viralScanTemplate {
return schedule, nil
}
}
return nil, nil
}
func toCopyMissionScanScheduleData(schedule *jobentity.Schedule, personaID, missionID string) *types.CopyMissionScanScheduleData {
if schedule == nil {
return &types.CopyMissionScanScheduleData{
PersonaID: personaID,
MissionID: missionID,
Cron: "0 9 * * *",
Timezone: "Asia/Taipei",
Enabled: false,
}
}
data := &types.CopyMissionScanScheduleData{
ID: schedule.ID.Hex(),
PersonaID: personaID,
MissionID: missionID,
Cron: schedule.Cron,
Timezone: schedule.Timezone,
Enabled: schedule.Enabled,
NextRunAt: schedule.NextRunAt,
}
if schedule.LastRunAt != nil {
data.LastRunAt = *schedule.LastRunAt
}
return data
}

View File

@ -1,67 +1,33 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package copy_mission
import (
"context"
"strings"
app "haixun-backend/internal/library/errors"
"haixun-backend/internal/library/errors/code"
jobdom "haixun-backend/internal/model/job/domain/usecase"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type StartCopyMissionAnalyzeJobLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewStartCopyMissionAnalyzeJobLogic(ctx context.Context, svcCtx *svc.ServiceContext) *StartCopyMissionAnalyzeJobLogic {
return &StartCopyMissionAnalyzeJobLogic{ctx: ctx, svcCtx: svcCtx}
return &StartCopyMissionAnalyzeJobLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *StartCopyMissionAnalyzeJobLogic) StartCopyMissionAnalyzeJob(
req *types.CopyMissionPath,
) (*types.StartCopyMissionAnalyzeJobData, error) {
tenantID, uid, err := actorFrom(l.ctx)
if err != nil {
return nil, err
}
personaID := strings.TrimSpace(req.PersonaID)
missionID := strings.TrimSpace(req.ID)
if _, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID); err != nil {
return nil, err
}
if _, err := l.svcCtx.CopyMission.Get(l.ctx, tenantID, uid, personaID, missionID); err != nil {
return nil, err
}
if err := requireMemberAiReady(l.ctx, l.svcCtx, tenantID, uid); err != nil {
return nil, err
}
func (l *StartCopyMissionAnalyzeJobLogic) StartCopyMissionAnalyzeJob(req *types.CopyMissionPath) (resp *types.StartCopyMissionAnalyzeJobData, err error) {
// todo: add your logic here and delete this line
payload := map[string]any{
"tenant_id": tenantID,
"owner_uid": uid,
"persona_id": personaID,
"copy_mission_id": missionID,
}
run, err := l.svcCtx.Job.CreateRun(l.ctx, jobdom.CreateRunRequest{
TemplateType: "analyze-copy-mission",
Scope: "copy_mission",
ScopeID: missionID,
TenantID: tenantID,
OwnerUID: uid,
Payload: payload,
})
if err != nil {
return nil, err
}
if run == nil {
return nil, app.For(code.Job).ResInvalidState("analyze job not created")
}
return &types.StartCopyMissionAnalyzeJobData{
JobID: run.ID.Hex(),
Status: string(run.Status),
Message: "受眾與研究地圖分析已在背景執行,完成後可微調標籤並開始海巡",
}, nil
return
}

View File

@ -1,80 +1,33 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package copy_mission
import (
"context"
"strings"
app "haixun-backend/internal/library/errors"
"haixun-backend/internal/library/errors/code"
"haixun-backend/internal/library/style8d"
jobdom "haixun-backend/internal/model/job/domain/usecase"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type StartCopyMissionCopyDraftJobLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewStartCopyMissionCopyDraftJobLogic(ctx context.Context, svcCtx *svc.ServiceContext) *StartCopyMissionCopyDraftJobLogic {
return &StartCopyMissionCopyDraftJobLogic{ctx: ctx, svcCtx: svcCtx}
return &StartCopyMissionCopyDraftJobLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *StartCopyMissionCopyDraftJobLogic) StartCopyMissionCopyDraftJob(
req *types.StartCopyMissionCopyDraftJobHandlerReq,
) (*types.StartCopyMissionCopyDraftJobData, error) {
tenantID, uid, err := actorFrom(l.ctx)
if err != nil {
return nil, err
}
personaID := strings.TrimSpace(req.PersonaID)
missionID := strings.TrimSpace(req.ID)
scanPostID := strings.TrimSpace(req.ScanPostID)
if scanPostID == "" {
return nil, app.For(code.Persona).InputMissingRequired("scan_post_id is required")
}
func (l *StartCopyMissionCopyDraftJobLogic) StartCopyMissionCopyDraftJob(req *types.StartCopyMissionCopyDraftJobHandlerReq) (resp *types.StartCopyMissionCopyDraftJobData, err error) {
// todo: add your logic here and delete this line
if _, err := l.svcCtx.CopyMission.Get(l.ctx, tenantID, uid, personaID, missionID); err != nil {
return nil, err
}
persona, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID)
if err != nil {
return nil, err
}
if !style8d.HasReady8D(persona.Persona, persona.StyleProfile) {
return nil, app.For(code.Persona).InputMissingRequired("請先完成人設 8D 對標分析")
}
if err := requireMemberAiReady(l.ctx, l.svcCtx, tenantID, uid); err != nil {
return nil, err
}
post, err := l.svcCtx.ScanPost.GetForPersona(l.ctx, tenantID, uid, personaID, scanPostID)
if err != nil {
return nil, err
}
if postMission := strings.TrimSpace(post.CopyMissionID); postMission != "" && postMission != missionID {
return nil, app.For(code.Persona).ResInvalidState("爆款不屬於此任務")
}
payload := map[string]any{
"persona_id": personaID,
"copy_mission_id": missionID,
"scan_post_id": scanPostID,
}
run, err := l.svcCtx.Job.CreateRun(l.ctx, jobdom.CreateRunRequest{
TemplateType: "generate-copy-draft",
Scope: "copy_mission",
ScopeID: missionID,
TenantID: tenantID,
OwnerUID: uid,
Payload: payload,
})
if err != nil {
return nil, err
}
return &types.StartCopyMissionCopyDraftJobData{
JobID: run.ID.Hex(),
Status: string(run.Status),
Message: "深仿寫已在背景執行",
}, nil
return
}

View File

@ -1,89 +1,33 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package copy_mission
import (
"context"
"strings"
app "haixun-backend/internal/library/errors"
"haixun-backend/internal/library/errors/code"
"haixun-backend/internal/library/style8d"
missionentity "haixun-backend/internal/model/copy_mission/domain/entity"
jobdom "haixun-backend/internal/model/job/domain/usecase"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type StartCopyMissionMatrixJobLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewStartCopyMissionMatrixJobLogic(ctx context.Context, svcCtx *svc.ServiceContext) *StartCopyMissionMatrixJobLogic {
return &StartCopyMissionMatrixJobLogic{ctx: ctx, svcCtx: svcCtx}
return &StartCopyMissionMatrixJobLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *StartCopyMissionMatrixJobLogic) StartCopyMissionMatrixJob(
req *types.StartCopyMissionMatrixJobHandlerReq,
) (*types.StartCopyMissionMatrixJobData, error) {
tenantID, uid, err := actorFrom(l.ctx)
if err != nil {
return nil, err
}
personaID := strings.TrimSpace(req.PersonaID)
missionID := strings.TrimSpace(req.ID)
mission, err := l.svcCtx.CopyMission.Get(l.ctx, tenantID, uid, personaID, missionID)
if err != nil {
return nil, err
}
if mission.Status != string(missionentity.StatusMapped) &&
mission.Status != string(missionentity.StatusScanned) &&
mission.Status != string(missionentity.StatusDrafted) {
return nil, app.For(code.Persona).ResInvalidState("請先完成研究地圖再產出內容矩陣")
}
if strings.TrimSpace(mission.ResearchMap.AudienceSummary) == "" {
return nil, app.For(code.Persona).InputMissingRequired("請先完成研究地圖")
}
func (l *StartCopyMissionMatrixJobLogic) StartCopyMissionMatrixJob(req *types.StartCopyMissionMatrixJobHandlerReq) (resp *types.StartCopyMissionMatrixJobData, err error) {
// todo: add your logic here and delete this line
persona, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID)
if err != nil {
return nil, err
}
if !style8d.HasReady8D(persona.Persona, persona.StyleProfile) {
return nil, app.For(code.Persona).InputMissingRequired("請先完成人設 8D 對標分析")
}
if err := requireMemberAiReady(l.ctx, l.svcCtx, tenantID, uid); err != nil {
return nil, err
}
count := req.Count
if count <= 0 {
count = 5
}
if count > 12 {
count = 12
}
payload := map[string]any{
"tenant_id": tenantID,
"owner_uid": uid,
"persona_id": personaID,
"copy_mission_id": missionID,
"count": count,
}
run, err := l.svcCtx.Job.CreateRun(l.ctx, jobdom.CreateRunRequest{
TemplateType: "generate-copy-matrix",
Scope: "copy_mission",
ScopeID: missionID,
TenantID: tenantID,
OwnerUID: uid,
Payload: payload,
})
if err != nil {
return nil, err
}
return &types.StartCopyMissionMatrixJobData{
JobID: run.ID.Hex(),
Status: string(run.Status),
Message: "內容矩陣產出已在背景執行",
}, nil
return
}

View File

@ -1,95 +1,33 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package copy_mission
import (
"context"
"strings"
app "haixun-backend/internal/library/errors"
"haixun-backend/internal/library/errors/code"
libviral "haixun-backend/internal/library/viral"
jobdom "haixun-backend/internal/model/job/domain/usecase"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type StartCopyMissionScanJobLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewStartCopyMissionScanJobLogic(ctx context.Context, svcCtx *svc.ServiceContext) *StartCopyMissionScanJobLogic {
return &StartCopyMissionScanJobLogic{ctx: ctx, svcCtx: svcCtx}
return &StartCopyMissionScanJobLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *StartCopyMissionScanJobLogic) StartCopyMissionScanJob(
req *types.CopyMissionPath,
) (*types.StartCopyMissionScanJobData, error) {
tenantID, uid, err := actorFrom(l.ctx)
if err != nil {
return nil, err
}
personaID := strings.TrimSpace(req.PersonaID)
missionID := strings.TrimSpace(req.ID)
if _, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID); err != nil {
return nil, err
}
mission, err := l.svcCtx.CopyMission.Get(l.ctx, tenantID, uid, personaID, missionID)
if err != nil {
return nil, err
}
if mission.ResearchMap.AudienceSummary == "" && len(mission.ResearchMap.SuggestedTags) == 0 {
return nil, app.For(code.Persona).InputMissingRequired("請先產生研究地圖")
}
if len(mission.SelectedTags) == 0 {
return nil, app.For(code.Persona).InputMissingRequired("請至少勾選一個搜尋標籤")
}
if len(mission.SelectedTags) > libviral.MaxScanTags {
return nil, app.For(code.Persona).InputMissingRequired("搜尋標籤最多 6 個,請減少勾選以節省搜尋配額")
}
func (l *StartCopyMissionScanJobLogic) StartCopyMissionScanJob(req *types.CopyMissionPath) (resp *types.StartCopyMissionScanJobData, err error) {
// todo: add your logic here and delete this line
research, err := l.svcCtx.Placement.ResearchSettings(l.ctx, tenantID, uid)
if err != nil {
return nil, err
}
memberCtx, err := l.svcCtx.ThreadsAccount.ResolveMemberPlacementContext(l.ctx, tenantID, uid, research)
if err != nil {
return nil, err
}
if !memberCtx.HasDiscoverPath() {
return nil, app.For(code.Setting).InputMissingRequired("爆款掃描需要 Threads API、Chrome Session 或 Web Search API請檢查連線模式與搜尋來源")
}
if err := l.svcCtx.ScanPost.ClearCopyMissionViralScan(l.ctx, tenantID, uid, personaID, missionID); err != nil {
return nil, err
}
if err := l.svcCtx.CopyDraft.ClearByMission(l.ctx, tenantID, uid, personaID, missionID); err != nil {
return nil, err
}
payload := map[string]any{
"persona_id": personaID,
"copy_mission_id": missionID,
"keywords": mission.SelectedTags,
"bootstrap": false,
}
for key, value := range memberCtx.PayloadFields() {
payload[key] = value
}
run, err := l.svcCtx.Job.CreateRun(l.ctx, jobdom.CreateRunRequest{
TemplateType: "scan-viral",
Scope: "copy_mission",
ScopeID: missionID,
TenantID: tenantID,
OwnerUID: uid,
Payload: payload,
})
if err != nil {
return nil, err
}
return &types.StartCopyMissionScanJobData{
JobID: run.ID.Hex(),
Status: string(run.Status),
Message: "已清除舊爆款與產文草稿,海巡已在背景執行",
}, nil
return
}

View File

@ -1,14 +1,11 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package copy_mission
import (
"context"
"strings"
app "haixun-backend/internal/library/errors"
"haixun-backend/internal/library/errors/code"
missionentity "haixun-backend/internal/model/copy_mission/domain/entity"
missiondomain "haixun-backend/internal/model/copy_mission/domain/usecase"
jobdom "haixun-backend/internal/model/job/domain/usecase"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
@ -29,39 +26,8 @@ func NewUpdateCopyMissionLogic(ctx context.Context, svcCtx *svc.ServiceContext)
}
}
func (l *UpdateCopyMissionLogic) UpdateCopyMission(req *types.UpdateCopyMissionHandlerReq) (*types.CopyMissionData, error) {
tenantID, uid, err := actorFrom(l.ctx)
if err != nil {
return nil, err
}
patch := toMissionPatch(&req.UpdateCopyMissionReq)
if req.Status != nil {
status := strings.TrimSpace(*req.Status)
if status != string(missionentity.StatusArchived) && status != string(missionentity.StatusOpen) {
return nil, app.For(code.Persona).InputMissingRequired("status 僅支援 archived 或 open")
}
st := missionentity.Status(status)
patch.Status = &st
}
item, err := l.svcCtx.CopyMission.Update(l.ctx, missiondomain.UpdateRequest{
TenantID: tenantID,
OwnerUID: uid,
PersonaID: strings.TrimSpace(req.PersonaID),
MissionID: strings.TrimSpace(req.ID),
Patch: patch,
})
if err != nil {
return nil, err
}
if req.Status != nil && strings.TrimSpace(*req.Status) == string(missionentity.StatusArchived) {
if schedule, err := findCopyMissionScanSchedule(l.ctx, l.svcCtx, strings.TrimSpace(req.ID)); err == nil && schedule != nil && schedule.Enabled {
disabled := false
_, _ = l.svcCtx.Job.UpdateSchedule(l.ctx, jobdom.UpdateScheduleRequest{
ID: schedule.ID.Hex(),
Enabled: &disabled,
})
}
}
data := toCopyMissionData(*item)
return &data, nil
func (l *UpdateCopyMissionLogic) UpdateCopyMission(req *types.UpdateCopyMissionHandlerReq) (resp *types.CopyMissionData, err error) {
// todo: add your logic here and delete this line
return
}

View File

@ -1,106 +1,33 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package copy_mission
import (
"context"
"strings"
app "haixun-backend/internal/library/errors"
"haixun-backend/internal/library/errors/code"
missionentity "haixun-backend/internal/model/copy_mission/domain/entity"
jobdom "haixun-backend/internal/model/job/domain/usecase"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type UpsertCopyMissionScanScheduleLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewUpsertCopyMissionScanScheduleLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpsertCopyMissionScanScheduleLogic {
return &UpsertCopyMissionScanScheduleLogic{ctx: ctx, svcCtx: svcCtx}
return &UpsertCopyMissionScanScheduleLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *UpsertCopyMissionScanScheduleLogic) UpsertCopyMissionScanSchedule(
req *types.UpsertCopyMissionScanScheduleHandlerReq,
) (*types.CopyMissionScanScheduleData, error) {
tenantID, uid, err := actorFrom(l.ctx)
if err != nil {
return nil, err
}
personaID := strings.TrimSpace(req.PersonaID)
missionID := strings.TrimSpace(req.ID)
if _, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID); err != nil {
return nil, err
}
mission, err := l.svcCtx.CopyMission.Get(l.ctx, tenantID, uid, personaID, missionID)
if err != nil {
return nil, err
}
if mission.Status == string(missionentity.StatusArchived) {
return nil, app.For(code.Persona).ResInvalidState("已封存的任務無法設定排程")
}
if req.Enabled && len(mission.SelectedTags) == 0 {
return nil, app.For(code.Persona).InputMissingRequired("啟用排程前請先勾選並儲存搜尋標籤")
}
func (l *UpsertCopyMissionScanScheduleLogic) UpsertCopyMissionScanSchedule(req *types.UpsertCopyMissionScanScheduleHandlerReq) (resp *types.CopyMissionScanScheduleData, err error) {
// todo: add your logic here and delete this line
cronExpr := "0 9 * * *"
timezone := "Asia/Taipei"
if strings.TrimSpace(req.Cron) != "" {
cronExpr = strings.TrimSpace(req.Cron)
}
if strings.TrimSpace(req.Timezone) != "" {
timezone = strings.TrimSpace(req.Timezone)
}
research, err := l.svcCtx.Placement.ResearchSettings(l.ctx, tenantID, uid)
if err != nil {
return nil, err
}
memberCtx, err := l.svcCtx.ThreadsAccount.ResolveMemberPlacementContext(l.ctx, tenantID, uid, research)
if err != nil {
return nil, err
}
payload := map[string]any{
"persona_id": personaID,
"copy_mission_id": missionID,
"bootstrap": false,
"scheduled": true,
}
for key, value := range memberCtx.PayloadFields() {
payload[key] = value
}
existing, err := findCopyMissionScanSchedule(l.ctx, l.svcCtx, missionID)
if err != nil {
return nil, err
}
if existing != nil {
updated, err := l.svcCtx.Job.UpdateSchedule(l.ctx, jobdom.UpdateScheduleRequest{
ID: existing.ID.Hex(),
Cron: cronExpr,
Timezone: timezone,
PayloadTemplate: payload,
Enabled: &req.Enabled,
})
if err != nil {
return nil, err
}
return toCopyMissionScanScheduleData(updated, personaID, missionID), nil
}
created, err := l.svcCtx.Job.CreateSchedule(l.ctx, jobdom.CreateScheduleRequest{
TemplateType: viralScanTemplate,
Scope: "copy_mission",
ScopeID: missionID,
Cron: cronExpr,
Timezone: timezone,
PayloadTemplate: payload,
Enabled: req.Enabled,
})
if err != nil {
return nil, err
}
return toCopyMissionScanScheduleData(created, personaID, missionID), nil
return
}

View File

@ -90,6 +90,11 @@ func (l *AnalyzeStyle8DFromWorkerLogic) AnalyzeStyle8DFromWorker(req *types.Anal
})
username := strings.TrimPrefix(strings.TrimSpace(req.Username), "@")
input := style8d.AnalyzeInput{
Mode: style8d.AnalyzeModeBenchmark,
Username: username,
Posts: posts,
}
systemPrompt, err := libprompt.Style8DSystem()
if err != nil {
return nil, app.For(code.AI).SysInternal("prompt config load failed")
@ -102,7 +107,7 @@ func (l *AnalyzeStyle8DFromWorkerLogic) AnalyzeStyle8DFromWorker(req *types.Anal
},
System: systemPrompt,
Messages: []domai.Message{
{Role: "user", Content: style8d.BuildUserPrompt(username, posts)},
{Role: "user", Content: input.BuildUserPrompt()},
},
})
if err != nil {
@ -119,7 +124,7 @@ func (l *AnalyzeStyle8DFromWorkerLogic) AnalyzeStyle8DFromWorker(req *types.Anal
if err != nil {
return nil, app.For(code.AI).SvcThirdParty("8D LLM 回傳無法解析:" + err.Error())
}
profile := style8d.BuildStoredProfile(username, posts, parsed)
profile := input.BuildStoredProfile(parsed)
profileJSON, err := profile.JSON()
if err != nil {
return nil, err
@ -137,9 +142,10 @@ func (l *AnalyzeStyle8DFromWorkerLogic) AnalyzeStyle8DFromWorker(req *types.Anal
})
personaDraft := strings.TrimSpace(profile.PersonaDraft)
benchmark := input.StyleBenchmark()
patch := personausecase.PersonaPatch{
StyleProfile: &profileJSON,
StyleBenchmark: &username,
StyleBenchmark: &benchmark,
}
if personaDraft != "" {
patch.Persona = &personaDraft

Some files were not shown because too many files have changed in this diff Show More