feat/rebone #3
File diff suppressed because it is too large
Load Diff
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -26,4 +26,4 @@ func GenerateFromContentFormulaHandler(svcCtx *svc.ServiceContext) http.HandlerF
|
|||
data, err := l.GenerateFromContentFormula(&req)
|
||||
response.Write(r.Context(), w, data, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -26,4 +26,4 @@ func ListPersonaContentInboxHandler(svcCtx *svc.ServiceContext) http.HandlerFunc
|
|||
data, err := l.ListPersonaContentInbox(&req)
|
||||
response.Write(r.Context(), w, data, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -26,4 +26,4 @@ func SchedulePersonaCopyDraftHandler(svcCtx *svc.ServiceContext) http.HandlerFun
|
|||
data, err := l.SchedulePersonaCopyDraft(&req)
|
||||
response.Write(r.Context(), w, data, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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 各 2~5 點字串陣列。`)
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() + `
|
||||
|
||||
規則:
|
||||
- 用台灣繁體中文,符合人設語氣。
|
||||
- 套用公式結構,但內容必須圍繞使用者主題,不可抄襲範例貼文。
|
||||
- 只回傳 JSON:text, hook, angle, rationale, structure_notes(繁體中文)。`)
|
||||
- 正文素材以【主題】、【補充】、【周邊知識參考】為準;人設不得改變題材。
|
||||
- 人設定位決定誰在說,語言指紋決定怎麼說;必須遵守段落節奏、標點換行、知識轉譯與 CTA 習慣。
|
||||
- text 必須是一篇完整自然的 Threads 貼文,可直接複製發布。
|
||||
- 不可以出現「公式、模板、Hook、情境、轉折、觀點、收尾、[括號]、步驟、結構」等後台分析字眼。
|
||||
- 不可以輸出填空骨架,不可以照抄參考原文。
|
||||
- 有參考原文時:只學資訊密度、情緒力度與可用結構;最後仍要換成人設會講出口的話。
|
||||
- text 排版:像真人手機發文;可使用自然標點、空行、列點與少量 emoji。不要完全無標點,也不要每句硬換行,除非人設樣本就是這樣。
|
||||
- topic_tag 請產生 1 個適合 Threads 主題標籤的短詞,不含 #,例如「AI工具」「內容創作」「職場」。
|
||||
- 只回傳 JSON:text, 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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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"]),
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 建議 80~220 字。",
|
||||
"每筆含 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 {
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 硬上限,含 #話題標籤)。
|
||||
- 爆款互動最佳 80~220 字:前 1~2 行強 hook,一句一重點;超過 300 字互動通常下降。
|
||||
- 長度依帳號樣本與主題決定:心情陪伴可中長,知識工具文可完整列點,互動短文才需要短。
|
||||
- text 排版要像真人手機發文:用自然換行分段,可使用標點、括號、頓號與少量 emoji;不要整段擠成一塊,也不要每句都硬換行。
|
||||
- 若要改寫參考貼文,只保留意思與資訊,句型、段落、收尾必須換成人設語言指紋,不可像同義詞替換。
|
||||
- 若使用外部知識,先轉譯成此人設會說的順序與語氣;不得直接貼百科、條列報告或 SEO 摘要。
|
||||
- 醫療、備孕、試管、保健品相關內容必須保留風險語氣:不可保證效果、不可替代醫師建議、不可用數字審判讀者。
|
||||
|
|
|
|||
|
|
@ -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。
|
||||
|
|
|
|||
|
|
@ -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":""}}
|
||||
|
|
|
|||
|
|
@ -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 味、過度模板、冒犯或高風險語句。
|
||||
|
|
|
|||
|
|
@ -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{}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,14 +44,29 @@ func HasReady8D(personaText, styleProfileJSON string) bool {
|
|||
return BuildStyle8DPromptBlock(styleProfileJSON) != ""
|
||||
}
|
||||
|
||||
var copyVoiceDimensionOrder = []string{"d1Tone", "d8Risk"}
|
||||
|
||||
// BuildStyle8DPromptBlock formats D1–D8 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"))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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), "@")
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"))
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 敘事分析師。拆解這篇貼文為什麼讓人想看完、怎麼寫出同樣的故事感(不是抄句型)。
|
||||
|
||||
只回傳 JSON:hookPattern, structurePattern, emotionalTrigger, replicationStrategy, keyTakeaways(字串陣列 3~5 項)。繁體中文。`)
|
||||
只回傳 JSON(繁體中文):
|
||||
- storyHook:讓人停下滑動的開場力量(描述手法,不要給可照抄的句子)
|
||||
- narrativeBeat:故事怎麼推進(經歷/轉折/感受,不是段落模板)
|
||||
- emotionalCore:核心情緒是什麼
|
||||
- sceneSeed:一個值得借鑑的「具體場景種子」(時間/地點/動作,供啟發勿照搬)
|
||||
- authenticityCues:3~5 個讓它像真人的小細節(口語、坦白、不完美、具體細節等)
|
||||
- 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 ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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:任務名稱,6~18 字,像企劃案標題,不要標點堆疊
|
||||
4. seedQuery:種子關鍵字/近期熱詞,2~6 個詞用頓號或空格分隔,適合當 Threads 搜尋起點
|
||||
5. brief:這次想找什麼,2~4 句,說明要海巡哪類爆款、語氣或格式偏好
|
||||
6. trendReason:1~2 句,為何選這個趨勢(可引用訊號來源大意,不要貼 URL)
|
||||
7. trendKeywords:3~6 個相關熱詞(字串陣列)
|
||||
8. 避開【近期已做過的任務】相同題材
|
||||
9. 繁體中文;只回傳 JSON:
|
||||
{"label":"","seedQuery":"","brief":"","trendReason":"","trendKeywords":[]}`)
|
||||
5. brief:這次內容計畫摘要,2~4 句,說明話題、受眾痛點、為何適合這個帳號
|
||||
6. trendReason:1~2 句,為何選這個趨勢(說明它和受眾討論、搜尋意圖或痛點的關係,不要貼 URL)
|
||||
7. trendKeywords:3~6 個相關熱詞(字串陣列),必須像 Threads 會搜尋/討論的關鍵字,不要像新聞標題
|
||||
8. angles:3~5 個可勾選切角(字串陣列),每個切角都要說明「這篇要怎麼切」,不要直接寫成完整貼文
|
||||
9. mission:內容任務,只能是「日常陪伴」「專業知識」「情緒共鳴」「工具清單」「熱門觀點」「互動提問」之一,除非使用者方向要求更精準
|
||||
10. targetAudience:這篇真正要對誰說,越具體越好
|
||||
11. openingType:開頭策略,例如「問題開場」「場景開場」「反直覺開場」「經驗開場」「清單開場」
|
||||
12. bodyType:中段結構,例如「先共鳴再整理」「問題拆解」「步驟清單」「案例對照」「觀點辯證」
|
||||
13. emotion:情緒槓桿,例如「焦慮被接住」「想被理解」「怕踩雷」「想準備好」「想被鼓勵」
|
||||
14. ctaType:互動/收藏/私訊等 CTA 型態,不要硬業配
|
||||
15. riskLevel:low / medium / high;醫療、財務、法律、政策、名人爭議等至少 medium
|
||||
16. avoid:3~6 個禁忌或不要寫偏的方向(字串陣列)
|
||||
17. 避開【近期已做過的任務】相同題材
|
||||
18. 起號優先:題目要能連續延伸 5~10 篇,不要只是一則熱搜心得
|
||||
19. 除非使用者關鍵字、人設或趨勢訊號明確要求,不要產出早餐、早安、日常飲食、純生活流水帳這類泛題
|
||||
20. 人設介紹只用來判斷語氣與受眾,不是題材庫。不可因人設寫 Y2K、咖啡、穿搭、音樂,就把靈感主題漂移成咖啡、穿搭或復古日常
|
||||
21. 若使用者有指定關鍵字,label、seedQuery、brief 都必須保留該方向,不可漂移成無關日常題
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 硬上限,含 #話題標籤)
|
||||
- 爆款互動最佳 80~220 字:前 1~2 行強 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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 或補上 Brave/Exa 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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in New Issue