feat/rebone #3
File diff suppressed because it is too large
Load Diff
|
|
@ -166,6 +166,9 @@ type (
|
||||||
|
|
||||||
CopyMissionInspirationReq {
|
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 {
|
CopyMissionInspirationHandlerReq {
|
||||||
|
|
@ -267,7 +270,7 @@ type (
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
}
|
}
|
||||||
|
|
||||||
ScheduleCopyDraftsReq {
|
CopyMissionScheduleCopyDraftsReq {
|
||||||
AccountID string `json:"account_id" validate:"required"`
|
AccountID string `json:"account_id" validate:"required"`
|
||||||
DraftIDs []string `json:"draft_ids" validate:"required,min=1"`
|
DraftIDs []string `json:"draft_ids" validate:"required,min=1"`
|
||||||
StartAt int64 `json:"start_at,optional"`
|
StartAt int64 `json:"start_at,optional"`
|
||||||
|
|
@ -278,10 +281,10 @@ type (
|
||||||
|
|
||||||
ScheduleCopyMissionDraftsHandlerReq {
|
ScheduleCopyMissionDraftsHandlerReq {
|
||||||
CopyMissionPath
|
CopyMissionPath
|
||||||
ScheduleCopyDraftsReq
|
CopyMissionScheduleCopyDraftsReq
|
||||||
}
|
}
|
||||||
|
|
||||||
ScheduleCopyDraftsData {
|
CopyMissionScheduleCopyDraftsData {
|
||||||
Scheduled int `json:"scheduled"`
|
Scheduled int `json:"scheduled"`
|
||||||
List []PublishQueueItemData `json:"list"`
|
List []PublishQueueItemData `json:"list"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
|
|
@ -295,11 +298,22 @@ type (
|
||||||
}
|
}
|
||||||
|
|
||||||
CopyMissionInspirationData {
|
CopyMissionInspirationData {
|
||||||
|
TopicCandidateID string `json:"topic_candidate_id,omitempty"`
|
||||||
|
ContentPlanID string `json:"content_plan_id,omitempty"`
|
||||||
Label string `json:"label"`
|
Label string `json:"label"`
|
||||||
SeedQuery string `json:"seed_query"`
|
SeedQuery string `json:"seed_query"`
|
||||||
Brief string `json:"brief"`
|
Brief string `json:"brief"`
|
||||||
TrendReason string `json:"trend_reason,omitempty"`
|
TrendReason string `json:"trend_reason,omitempty"`
|
||||||
TrendKeywords []string `json:"trend_keywords,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"`
|
Sources []CopyMissionInspirationSourceData `json:"sources,omitempty"`
|
||||||
WebSearchUsed bool `json:"web_search_used"`
|
WebSearchUsed bool `json:"web_search_used"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
|
|
@ -357,7 +371,7 @@ service gateway {
|
||||||
post /:personaId/copy-missions/:id/matrix-drafts/delete (DeleteCopyMissionMatrixDraftsHandlerReq) returns (DeleteCopyMissionMatrixDraftsData)
|
post /:personaId/copy-missions/:id/matrix-drafts/delete (DeleteCopyMissionMatrixDraftsHandlerReq) returns (DeleteCopyMissionMatrixDraftsData)
|
||||||
|
|
||||||
@handler scheduleCopyMissionDrafts
|
@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
|
@handler getCopyMissionScanSchedule
|
||||||
get /:personaId/copy-missions/:id/scan-schedule (CopyMissionPath) returns (CopyMissionScanScheduleData)
|
get /:personaId/copy-missions/:id/scan-schedule (CopyMissionPath) returns (CopyMissionScanScheduleData)
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,23 @@ type (
|
||||||
StartPersonaStyleAnalysisReq
|
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 {
|
StartPersonaViralScanJobReq {
|
||||||
Keywords []string `json:"keywords,optional"`
|
Keywords []string `json:"keywords,optional"`
|
||||||
}
|
}
|
||||||
|
|
@ -113,17 +130,27 @@ type (
|
||||||
CopyDraftData {
|
CopyDraftData {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
PersonaID string `json:"persona_id"`
|
PersonaID string `json:"persona_id"`
|
||||||
|
ContentPlanID string `json:"content_plan_id,omitempty"`
|
||||||
CopyMissionID string `json:"copy_mission_id,omitempty"`
|
CopyMissionID string `json:"copy_mission_id,omitempty"`
|
||||||
ScanPostID string `json:"scan_post_id,omitempty"`
|
ScanPostID string `json:"scan_post_id,omitempty"`
|
||||||
FormulaID string `json:"formula_id,omitempty"`
|
FormulaID string `json:"formula_id,omitempty"`
|
||||||
DraftType string `json:"draft_type"`
|
DraftType string `json:"draft_type"`
|
||||||
SortOrder int `json:"sort_order,omitempty"`
|
SortOrder int `json:"sort_order,omitempty"`
|
||||||
Text string `json:"text"`
|
Text string `json:"text"`
|
||||||
|
TopicTag string `json:"topic_tag,omitempty"`
|
||||||
Angle string `json:"angle,omitempty"`
|
Angle string `json:"angle,omitempty"`
|
||||||
Hook string `json:"hook,omitempty"`
|
Hook string `json:"hook,omitempty"`
|
||||||
Rationale string `json:"rationale,omitempty"`
|
Rationale string `json:"rationale,omitempty"`
|
||||||
ReferenceNotes string `json:"reference_notes,omitempty"`
|
ReferenceNotes string `json:"reference_notes,omitempty"`
|
||||||
Sources []string `json:"sources,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"`
|
Status string `json:"status,omitempty"`
|
||||||
PublishQueueID string `json:"publish_queue_id,omitempty"`
|
PublishQueueID string `json:"publish_queue_id,omitempty"`
|
||||||
PublishedMediaID string `json:"published_media_id,omitempty"`
|
PublishedMediaID string `json:"published_media_id,omitempty"`
|
||||||
|
|
@ -158,11 +185,203 @@ type (
|
||||||
|
|
||||||
UpdateCopyDraftReq {
|
UpdateCopyDraftReq {
|
||||||
Text *string `json:"text,optional"`
|
Text *string `json:"text,optional"`
|
||||||
|
TopicTag *string `json:"topic_tag,optional"`
|
||||||
Hook *string `json:"hook,optional"`
|
Hook *string `json:"hook,optional"`
|
||||||
Angle *string `json:"angle,optional"`
|
Angle *string `json:"angle,optional"`
|
||||||
|
Rationale *string `json:"rationale,optional"`
|
||||||
Status *string `json:"status,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 {
|
UpdateCopyDraftHandlerReq {
|
||||||
CopyDraftPath
|
CopyDraftPath
|
||||||
UpdateCopyDraftReq
|
UpdateCopyDraftReq
|
||||||
|
|
@ -170,6 +389,7 @@ type (
|
||||||
|
|
||||||
PublishCopyDraftReq {
|
PublishCopyDraftReq {
|
||||||
Text string `json:"text,optional"`
|
Text string `json:"text,optional"`
|
||||||
|
TopicTag string `json:"topic_tag,optional"`
|
||||||
Confirm bool `json:"confirm"`
|
Confirm bool `json:"confirm"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -186,6 +406,21 @@ type (
|
||||||
Message string `json:"message"`
|
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 {
|
SchedulePersonaDraftsHandlerReq {
|
||||||
PersonaPath
|
PersonaPath
|
||||||
ScheduleCopyDraftsReq
|
ScheduleCopyDraftsReq
|
||||||
|
|
@ -231,6 +466,21 @@ type (
|
||||||
Message string `json:"message"`
|
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 {
|
ListPersonaContentInboxReq {
|
||||||
Page int64 `form:"page,optional"`
|
Page int64 `form:"page,optional"`
|
||||||
PageSize int64 `form:"pageSize,optional"`
|
PageSize int64 `form:"pageSize,optional"`
|
||||||
|
|
@ -259,6 +509,7 @@ type (
|
||||||
|
|
||||||
SchedulePersonaCopyDraftReq {
|
SchedulePersonaCopyDraftReq {
|
||||||
AccountID string `json:"account_id" validate:"required"`
|
AccountID string `json:"account_id" validate:"required"`
|
||||||
|
TopicTag string `json:"topic_tag,optional"`
|
||||||
ScheduledAt int64 `json:"scheduled_at,optional"`
|
ScheduledAt int64 `json:"scheduled_at,optional"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -289,6 +540,82 @@ type (
|
||||||
List []CopyDraftData `json:"list"`
|
List []CopyDraftData `json:"list"`
|
||||||
Message string `json:"message"`
|
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(
|
@server(
|
||||||
|
|
@ -317,6 +644,9 @@ service gateway {
|
||||||
@handler startPersonaStyleAnalysis
|
@handler startPersonaStyleAnalysis
|
||||||
post /:id/style-analysis (StartPersonaStyleAnalysisHandlerReq) returns (StartPersonaStyleAnalysisData)
|
post /:id/style-analysis (StartPersonaStyleAnalysisHandlerReq) returns (StartPersonaStyleAnalysisData)
|
||||||
|
|
||||||
|
@handler startPersonaStyleAnalysisFromText
|
||||||
|
post /:id/style-analysis-from-text (StartPersonaStyleAnalysisFromTextHandlerReq) returns (StartPersonaStyleAnalysisFromTextData)
|
||||||
|
|
||||||
@handler startPersonaViralScanJob
|
@handler startPersonaViralScanJob
|
||||||
post /:id/viral-scan-jobs (StartPersonaViralScanJobHandlerReq) returns (StartPersonaViralScanJobData)
|
post /:id/viral-scan-jobs (StartPersonaViralScanJobHandlerReq) returns (StartPersonaViralScanJobData)
|
||||||
|
|
||||||
|
|
@ -329,6 +659,36 @@ service gateway {
|
||||||
@handler listPersonaContentInbox
|
@handler listPersonaContentInbox
|
||||||
get /:id/content-inbox (ListPersonaContentInboxHandlerReq) returns (ListPersonaContentInboxData)
|
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
|
@handler generatePersonaCopyDraft
|
||||||
post /:id/copy-drafts/generate (GeneratePersonaCopyDraftHandlerReq) returns (GeneratePersonaCopyDraftData)
|
post /:id/copy-drafts/generate (GeneratePersonaCopyDraftHandlerReq) returns (GeneratePersonaCopyDraftData)
|
||||||
|
|
||||||
|
|
@ -347,9 +707,24 @@ service gateway {
|
||||||
@handler generateFromContentFormula
|
@handler generateFromContentFormula
|
||||||
post /:id/content-formulas/:formulaId/generate (GenerateFromContentFormulaHandlerReq) returns (GenerateFromContentFormulaData)
|
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
|
@handler deletePersonaCopyDraft
|
||||||
delete /:id/copy-drafts/:draftId (CopyDraftPath) returns (DeleteCopyDraftData)
|
delete /:id/copy-drafts/:draftId (CopyDraftPath) returns (DeleteCopyDraftData)
|
||||||
|
|
||||||
|
@handler prunePersonaCopyDrafts
|
||||||
|
post /:id/copy-drafts/prune (PrunePersonaCopyDraftsHandlerReq) returns (PrunePersonaCopyDraftsData)
|
||||||
|
|
||||||
@handler listStylePresets
|
@handler listStylePresets
|
||||||
get /:id/style-presets (PersonaPath) returns (ListStylePresetsData)
|
get /:id/style-presets (PersonaPath) returns (ListStylePresetsData)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,19 +8,31 @@ import (
|
||||||
libcrypto "haixun-backend/internal/library/crypto"
|
libcrypto "haixun-backend/internal/library/crypto"
|
||||||
libmongo "haixun-backend/internal/library/mongo"
|
libmongo "haixun-backend/internal/library/mongo"
|
||||||
brandrepo "haixun-backend/internal/model/brand/repository"
|
brandrepo "haixun-backend/internal/model/brand/repository"
|
||||||
|
contentformularepo "haixun-backend/internal/model/content_formula/repository"
|
||||||
cmatrixrepo "haixun-backend/internal/model/content_matrix/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"
|
copydraftrepo "haixun-backend/internal/model/copy_draft/repository"
|
||||||
copymissionrepo "haixun-backend/internal/model/copy_mission/repository"
|
copymissionrepo "haixun-backend/internal/model/copy_mission/repository"
|
||||||
|
crmcontactrepo "haixun-backend/internal/model/crm_contact/repository"
|
||||||
jobrepo "haixun-backend/internal/model/job/repository"
|
jobrepo "haixun-backend/internal/model/job/repository"
|
||||||
|
jobusecase "haixun-backend/internal/model/job/usecase"
|
||||||
kgrepo "haixun-backend/internal/model/knowledge_graph/repository"
|
kgrepo "haixun-backend/internal/model/knowledge_graph/repository"
|
||||||
memberrepo "haixun-backend/internal/model/member/repository"
|
memberrepo "haixun-backend/internal/model/member/repository"
|
||||||
|
mentioninboxrepo "haixun-backend/internal/model/mention_inbox/repository"
|
||||||
outreachdraftrepo "haixun-backend/internal/model/outreach_draft/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"
|
permissionrepo "haixun-backend/internal/model/permission/repository"
|
||||||
permissionuc "haixun-backend/internal/model/permission/usecase"
|
permissionuc "haixun-backend/internal/model/permission/usecase"
|
||||||
personarepo "haixun-backend/internal/model/persona/repository"
|
personarepo "haixun-backend/internal/model/persona/repository"
|
||||||
placementtopicrepo "haixun-backend/internal/model/placement_topic/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"
|
scanpostrepo "haixun-backend/internal/model/scan_post/repository"
|
||||||
settingrepo "haixun-backend/internal/model/setting/repository"
|
settingrepo "haixun-backend/internal/model/setting/repository"
|
||||||
|
stylepresetrepo "haixun-backend/internal/model/style_preset/repository"
|
||||||
threadsaccountrepo "haixun-backend/internal/model/threads_account/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},
|
{"placement_topics", placementtopicrepo.NewMongoRepository(db).EnsureIndexes},
|
||||||
{"threads_accounts", threadsaccountrepo.NewMongoRepository(db).EnsureIndexes},
|
{"threads_accounts", threadsaccountrepo.NewMongoRepository(db).EnsureIndexes},
|
||||||
{"threads_account_secrets", threadsaccountrepo.NewSecretsMongoRepository(db, secretsCipher).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 {
|
for _, repo := range repos {
|
||||||
if err := repo.fn(ctx); err != nil {
|
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
|
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)
|
permissionUseCase := permissionuc.NewUseCase(permissionRepository, rolePermissionRepository)
|
||||||
if err := permissionUseCase.EnsureDefaultPermissions(ctx); err != nil {
|
if err := permissionUseCase.EnsureDefaultPermissions(ctx); err != nil {
|
||||||
return nil, fmt.Errorf("seed permissions catalog: %w", err)
|
return nil, fmt.Errorf("seed permissions catalog: %w", err)
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,11 @@ package copy_mission
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
"haixun-backend/internal/logic/copy_mission"
|
"haixun-backend/internal/logic/copy_mission"
|
||||||
"haixun-backend/internal/response"
|
"haixun-backend/internal/response"
|
||||||
"haixun-backend/internal/svc"
|
"haixun-backend/internal/svc"
|
||||||
"haixun-backend/internal/types"
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/rest/httpx"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func CreateCopyMissionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
func CreateCopyMissionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,11 @@ package copy_mission
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
"haixun-backend/internal/logic/copy_mission"
|
"haixun-backend/internal/logic/copy_mission"
|
||||||
"haixun-backend/internal/response"
|
"haixun-backend/internal/response"
|
||||||
"haixun-backend/internal/svc"
|
"haixun-backend/internal/svc"
|
||||||
"haixun-backend/internal/types"
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/rest/httpx"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func DeleteCopyMissionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
func DeleteCopyMissionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,11 @@ package copy_mission
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
"haixun-backend/internal/logic/copy_mission"
|
"haixun-backend/internal/logic/copy_mission"
|
||||||
"haixun-backend/internal/response"
|
"haixun-backend/internal/response"
|
||||||
"haixun-backend/internal/svc"
|
"haixun-backend/internal/svc"
|
||||||
"haixun-backend/internal/types"
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/rest/httpx"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func GenerateCopyMissionMatrixHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
func GenerateCopyMissionMatrixHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,11 @@ package copy_mission
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
"haixun-backend/internal/logic/copy_mission"
|
"haixun-backend/internal/logic/copy_mission"
|
||||||
"haixun-backend/internal/response"
|
"haixun-backend/internal/response"
|
||||||
"haixun-backend/internal/svc"
|
"haixun-backend/internal/svc"
|
||||||
"haixun-backend/internal/types"
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/rest/httpx"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func GetCopyMissionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
func GetCopyMissionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,11 @@ package copy_mission
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
"haixun-backend/internal/logic/copy_mission"
|
"haixun-backend/internal/logic/copy_mission"
|
||||||
"haixun-backend/internal/response"
|
"haixun-backend/internal/response"
|
||||||
"haixun-backend/internal/svc"
|
"haixun-backend/internal/svc"
|
||||||
"haixun-backend/internal/types"
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/rest/httpx"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func GetCopyMissionScanScheduleHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
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
|
package copy_mission
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
"haixun-backend/internal/logic/copy_mission"
|
"haixun-backend/internal/logic/copy_mission"
|
||||||
"haixun-backend/internal/response"
|
"haixun-backend/internal/response"
|
||||||
"haixun-backend/internal/svc"
|
"haixun-backend/internal/svc"
|
||||||
"haixun-backend/internal/types"
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/rest/httpx"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func InspireCopyMissionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
func InspireCopyMissionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,11 @@ package copy_mission
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
"haixun-backend/internal/logic/copy_mission"
|
"haixun-backend/internal/logic/copy_mission"
|
||||||
"haixun-backend/internal/response"
|
"haixun-backend/internal/response"
|
||||||
"haixun-backend/internal/svc"
|
"haixun-backend/internal/svc"
|
||||||
"haixun-backend/internal/types"
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/rest/httpx"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func ListCopyMissionCopyDraftsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
func ListCopyMissionCopyDraftsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,11 @@ package copy_mission
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
"haixun-backend/internal/logic/copy_mission"
|
"haixun-backend/internal/logic/copy_mission"
|
||||||
"haixun-backend/internal/response"
|
"haixun-backend/internal/response"
|
||||||
"haixun-backend/internal/svc"
|
"haixun-backend/internal/svc"
|
||||||
"haixun-backend/internal/types"
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/rest/httpx"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func ListCopyMissionScanPostsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
func ListCopyMissionScanPostsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,11 @@ package copy_mission
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
"haixun-backend/internal/logic/copy_mission"
|
"haixun-backend/internal/logic/copy_mission"
|
||||||
"haixun-backend/internal/response"
|
"haixun-backend/internal/response"
|
||||||
"haixun-backend/internal/svc"
|
"haixun-backend/internal/svc"
|
||||||
"haixun-backend/internal/types"
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/rest/httpx"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func ListCopyMissionsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
func ListCopyMissionsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,11 @@ package copy_mission
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
"haixun-backend/internal/logic/copy_mission"
|
"haixun-backend/internal/logic/copy_mission"
|
||||||
"haixun-backend/internal/response"
|
"haixun-backend/internal/response"
|
||||||
"haixun-backend/internal/svc"
|
"haixun-backend/internal/svc"
|
||||||
"haixun-backend/internal/types"
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/rest/httpx"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func StartCopyMissionAnalyzeJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
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
|
package copy_mission
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
"haixun-backend/internal/logic/copy_mission"
|
"haixun-backend/internal/logic/copy_mission"
|
||||||
"haixun-backend/internal/response"
|
"haixun-backend/internal/response"
|
||||||
"haixun-backend/internal/svc"
|
"haixun-backend/internal/svc"
|
||||||
"haixun-backend/internal/types"
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/rest/httpx"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func StartCopyMissionCopyDraftJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
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
|
package copy_mission
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
"haixun-backend/internal/logic/copy_mission"
|
"haixun-backend/internal/logic/copy_mission"
|
||||||
"haixun-backend/internal/response"
|
"haixun-backend/internal/response"
|
||||||
"haixun-backend/internal/svc"
|
"haixun-backend/internal/svc"
|
||||||
"haixun-backend/internal/types"
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/rest/httpx"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func StartCopyMissionMatrixJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
func StartCopyMissionMatrixJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,11 @@ package copy_mission
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
"haixun-backend/internal/logic/copy_mission"
|
"haixun-backend/internal/logic/copy_mission"
|
||||||
"haixun-backend/internal/response"
|
"haixun-backend/internal/response"
|
||||||
"haixun-backend/internal/svc"
|
"haixun-backend/internal/svc"
|
||||||
"haixun-backend/internal/types"
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/rest/httpx"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func StartCopyMissionScanJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
func StartCopyMissionScanJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,11 @@ package copy_mission
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
"haixun-backend/internal/logic/copy_mission"
|
"haixun-backend/internal/logic/copy_mission"
|
||||||
"haixun-backend/internal/response"
|
"haixun-backend/internal/response"
|
||||||
"haixun-backend/internal/svc"
|
"haixun-backend/internal/svc"
|
||||||
"haixun-backend/internal/types"
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/rest/httpx"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func UpdateCopyMissionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
func UpdateCopyMissionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,11 @@ package copy_mission
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
"haixun-backend/internal/logic/copy_mission"
|
"haixun-backend/internal/logic/copy_mission"
|
||||||
"haixun-backend/internal/response"
|
"haixun-backend/internal/response"
|
||||||
"haixun-backend/internal/svc"
|
"haixun-backend/internal/svc"
|
||||||
"haixun-backend/internal/types"
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/rest/httpx"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func UpsertCopyMissionScanScheduleHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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",
|
Path: "/:id/content-inbox",
|
||||||
Handler: persona.ListPersonaContentInboxHandler(serverCtx),
|
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,
|
Method: http.MethodGet,
|
||||||
Path: "/:id/copy-drafts",
|
Path: "/:id/copy-drafts",
|
||||||
|
|
@ -622,16 +637,66 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
|
||||||
Path: "/:id/copy-drafts/generate",
|
Path: "/:id/copy-drafts/generate",
|
||||||
Handler: persona.GeneratePersonaCopyDraftHandler(serverCtx),
|
Handler: persona.GeneratePersonaCopyDraftHandler(serverCtx),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Path: "/:id/copy-drafts/prune",
|
||||||
|
Handler: persona.PrunePersonaCopyDraftsHandler(serverCtx),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Method: http.MethodPost,
|
Method: http.MethodPost,
|
||||||
Path: "/:id/copy-drafts/schedule",
|
Path: "/:id/copy-drafts/schedule",
|
||||||
Handler: persona.SchedulePersonaDraftsHandler(serverCtx),
|
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,
|
Method: http.MethodPost,
|
||||||
Path: "/:id/style-analysis",
|
Path: "/:id/style-analysis",
|
||||||
Handler: persona.StartPersonaStyleAnalysisHandler(serverCtx),
|
Handler: persona.StartPersonaStyleAnalysisHandler(serverCtx),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Path: "/:id/style-analysis-from-text",
|
||||||
|
Handler: persona.StartPersonaStyleAnalysisFromTextHandler(serverCtx),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Method: http.MethodGet,
|
Method: http.MethodGet,
|
||||||
Path: "/:id/style-presets",
|
Path: "/:id/style-presets",
|
||||||
|
|
@ -647,6 +712,21 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
|
||||||
Path: "/:id/style-presets/:presetId",
|
Path: "/:id/style-presets/:presetId",
|
||||||
Handler: persona.DeleteStylePresetHandler(serverCtx),
|
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,
|
Method: http.MethodPost,
|
||||||
Path: "/:id/viral-scan-jobs",
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
libllmjson "haixun-backend/internal/library/llmjson"
|
||||||
libownpost "haixun-backend/internal/library/ownpost"
|
libownpost "haixun-backend/internal/library/ownpost"
|
||||||
domai "haixun-backend/internal/model/ai/domain/usecase"
|
domai "haixun-backend/internal/model/ai/domain/usecase"
|
||||||
)
|
)
|
||||||
|
|
@ -48,14 +48,16 @@ func BuildAnalyzePastedPrompt(in AnalyzePastedInput) (system string, user string
|
||||||
}
|
}
|
||||||
|
|
||||||
system = strings.TrimSpace(`
|
system = strings.TrimSpace(`
|
||||||
你是 Threads 爆款結構分析師。任務是拆解「外部貼文」為什麼會紅、怎麼用結構仿寫,不要建議抄襲原文。
|
你是 Threads / Instagram 貼文結構分析師。任務是拆解「外部貼文」哪些地方可借鏡,再說明如何用指定人設的語言指紋重新說一次;不要產生會讓文案機械的填空模板。
|
||||||
|
|
||||||
規則:
|
規則:
|
||||||
- 用台灣繁體中文。
|
- 用台灣繁體中文。
|
||||||
- formula:整理成步驟化公式(例:Hook→情境→轉折→觀點→收尾/互動)。
|
- formula:整理可借鏡的敘事/資訊推進方式,但要提醒不可照搬句型。
|
||||||
- post_template:依 formula 產出可複製的發文骨架,用 [括號] 標示可替換欄位。
|
- post_template:不要寫填空骨架;改寫成「此人設可採用的鬆結構與注意事項」。
|
||||||
|
- replication_tips:重點放在如何換成人設語言,包括段落節奏、標點換行、知識轉譯與 CTA。
|
||||||
- 只輸出一個 JSON 物件,不要 markdown,欄位:
|
- 只輸出一個 JSON 物件,不要 markdown,欄位:
|
||||||
summary, wins, improvements, formula, post_template, hook_pattern, structure, replication_tips, avoid
|
summary, wins, improvements, formula, post_template, hook_pattern, structure, replication_tips, avoid
|
||||||
|
- formula / structure / hook_pattern 必須是字串,不要用陣列或巢狀物件
|
||||||
- wins / improvements / replication_tips / avoid 各 2~5 點字串陣列。`)
|
- wins / improvements / replication_tips / avoid 各 2~5 點字串陣列。`)
|
||||||
|
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
|
|
@ -91,19 +93,15 @@ func ParsePastedAnalysis(raw string) (*PastedAnalysis, error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
var review PastedAnalysis
|
var flex map[string]any
|
||||||
if err := json.Unmarshal(payload, &review); err != nil {
|
if err := libllmjson.Unmarshal(payload, &flex); err != nil {
|
||||||
return nil, fmt.Errorf("parse pasted formula json: %w", err)
|
return nil, fmt.Errorf("parse pasted formula json: %w", err)
|
||||||
}
|
}
|
||||||
review.Summary = strings.TrimSpace(review.Summary)
|
review := pastedAnalysisFromMap(flex)
|
||||||
review.Formula = strings.TrimSpace(review.Formula)
|
|
||||||
review.PostTemplate = strings.TrimSpace(review.PostTemplate)
|
|
||||||
review.HookPattern = strings.TrimSpace(review.HookPattern)
|
|
||||||
review.Structure = strings.TrimSpace(review.Structure)
|
|
||||||
if review.Summary == "" && review.Formula == "" && review.PostTemplate == "" {
|
if review.Summary == "" && review.Formula == "" && review.PostTemplate == "" {
|
||||||
return nil, fmt.Errorf("AI 未回傳有效公式內容")
|
return nil, fmt.Errorf("AI 未回傳有效公式內容")
|
||||||
}
|
}
|
||||||
return &review, nil
|
return review, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func FromOwnPostReview(review *libownpost.PostFormulaReview) *PastedAnalysis {
|
func FromOwnPostReview(review *libownpost.PostFormulaReview) *PastedAnalysis {
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,19 @@ package formula
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"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"
|
domai "haixun-backend/internal/model/ai/domain/usecase"
|
||||||
cfdom "haixun-backend/internal/model/content_formula/domain/usecase"
|
cfdom "haixun-backend/internal/model/content_formula/domain/usecase"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var generatedDraftFenceRE = regexp.MustCompile("(?s)```(?:json)?\\s*([\\s\\S]*?)```")
|
||||||
|
|
||||||
type GenerateInput struct {
|
type GenerateInput struct {
|
||||||
Topic string
|
Topic string
|
||||||
Brief string
|
Brief string
|
||||||
|
|
@ -20,6 +25,7 @@ type GenerateInput struct {
|
||||||
|
|
||||||
type GeneratedDraft struct {
|
type GeneratedDraft struct {
|
||||||
Text string `json:"text"`
|
Text string `json:"text"`
|
||||||
|
TopicTag string `json:"topic_tag"`
|
||||||
Hook string `json:"hook"`
|
Hook string `json:"hook"`
|
||||||
Angle string `json:"angle"`
|
Angle string `json:"angle"`
|
||||||
Rationale string `json:"rationale"`
|
Rationale string `json:"rationale"`
|
||||||
|
|
@ -27,12 +33,21 @@ type GeneratedDraft struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func BuildGenerateSystemPrompt() string {
|
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 {
|
func BuildGenerateUserPrompt(in GenerateInput) string {
|
||||||
|
|
@ -43,26 +58,16 @@ func BuildGenerateUserPrompt(in GenerateInput) string {
|
||||||
b.WriteString("\n補充:")
|
b.WriteString("\n補充:")
|
||||||
b.WriteString(brief)
|
b.WriteString(brief)
|
||||||
}
|
}
|
||||||
b.WriteString("\n\n【人設】\n")
|
b.WriteString("\n\n【人設定位、語言指紋與禁忌(最高優先)】\n")
|
||||||
b.WriteString(strings.TrimSpace(in.PersonaBlock))
|
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))
|
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 != "" {
|
if structure := strings.TrimSpace(in.Formula.Structure); structure != "" {
|
||||||
b.WriteString("\n\n【結構節奏】\n")
|
b.WriteString("\n\n敘事推進參考(勿照搬句型):")
|
||||||
b.WriteString(structure)
|
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 {
|
if len(in.Formula.Avoid) > 0 {
|
||||||
b.WriteString("\n\n【避免】\n")
|
b.WriteString("\n\n【避免】\n")
|
||||||
b.WriteString(strings.Join(in.Formula.Avoid, "\n"))
|
b.WriteString(strings.Join(in.Formula.Avoid, "\n"))
|
||||||
|
|
@ -72,15 +77,21 @@ func BuildGenerateUserPrompt(in GenerateInput) string {
|
||||||
b.WriteString(notes)
|
b.WriteString(notes)
|
||||||
}
|
}
|
||||||
if src := strings.TrimSpace(in.Formula.SourcePostText); src != "" {
|
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(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()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
func GenerateDraft(ctx context.Context, ai domai.UseCase, req domai.GenerateRequest, in GenerateInput) (*GeneratedDraft, error) {
|
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.System = BuildGenerateSystemPrompt()
|
||||||
req.Messages = []domai.Message{{Role: "user", Content: BuildGenerateUserPrompt(in)}}
|
req.Messages = []domai.Message{{Role: "user", Content: BuildGenerateUserPrompt(in)}}
|
||||||
req.Temperature = &temp
|
req.Temperature = &temp
|
||||||
|
|
@ -91,20 +102,47 @@ func GenerateDraft(ctx context.Context, ai domai.UseCase, req domai.GenerateRequ
|
||||||
return ParseGeneratedDraft(out.Text)
|
return ParseGeneratedDraft(out.Text)
|
||||||
}
|
}
|
||||||
|
|
||||||
func ParseGeneratedDraft(raw string) (*GeneratedDraft, error) {
|
func extractGeneratedDraftJSON(raw string) ([]byte, error) {
|
||||||
raw = strings.TrimSpace(raw)
|
raw = strings.TrimSpace(raw)
|
||||||
|
if m := generatedDraftFenceRE.FindStringSubmatch(raw); len(m) == 2 {
|
||||||
|
raw = strings.TrimSpace(m[1])
|
||||||
|
}
|
||||||
start := strings.Index(raw, "{")
|
start := strings.Index(raw, "{")
|
||||||
end := strings.LastIndex(raw, "}")
|
end := strings.LastIndex(raw, "}")
|
||||||
if start < 0 || end <= start {
|
if start < 0 || end <= start {
|
||||||
return nil, fmt.Errorf("generate output missing json object")
|
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
|
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)
|
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 == "" {
|
if draft.Text == "" {
|
||||||
return nil, fmt.Errorf("generated draft missing 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
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -21,6 +21,14 @@ type Row struct {
|
||||||
ReferenceNotes string `json:"reference_notes"`
|
ReferenceNotes string `json:"reference_notes"`
|
||||||
SourcePermalinks []string `json:"source_permalinks"`
|
SourcePermalinks []string `json:"source_permalinks"`
|
||||||
Rationale string `json:"rationale"`
|
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 {
|
type GenerateResult struct {
|
||||||
|
|
@ -106,7 +114,7 @@ func CopyMatrixGenerateRequest(base domai.GenerateRequest, count int) domai.Gene
|
||||||
if count <= 0 {
|
if count <= 0 {
|
||||||
count = 5
|
count = 5
|
||||||
}
|
}
|
||||||
temp := 0.45
|
temp := 0.78
|
||||||
tokens := 8192 + count*1024
|
tokens := 8192 + count*1024
|
||||||
if tokens > 16384 {
|
if tokens > 16384 {
|
||||||
tokens = 16384
|
tokens = 16384
|
||||||
|
|
@ -122,8 +130,8 @@ func MatrixRetryUserPrompt(count int) string {
|
||||||
}
|
}
|
||||||
return fmt.Sprintf(
|
return fmt.Sprintf(
|
||||||
"上一則回覆的 JSON 不完整或無法解析。請只回傳單一 JSON 物件 {\"rows\":[...]},共 %d 篇,不要用 markdown 程式碼區塊、不要加說明文字。\n"+
|
"上一則回覆的 JSON 不完整或無法解析。請只回傳單一 JSON 物件 {\"rows\":[...]},共 %d 篇,不要用 markdown 程式碼區塊、不要加說明文字。\n"+
|
||||||
"每筆含 sort_order、search_tag、angle、hook、text、reference_notes、source_permalinks、rationale。\n"+
|
"每筆含 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 建議 80~220 字。",
|
"reference_notes 與 rationale 各不超過 40 字;text 要有具體場景與故事感、每篇敘事節奏不同;text 一句一行、完全不用任何標點(emoji 可保留)。",
|
||||||
count,
|
count,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -206,7 +214,7 @@ func normalizeGenerateResult(out GenerateResult) (GenerateResult, error) {
|
||||||
return GenerateResult{}, fmt.Errorf("matrix rows missing")
|
return GenerateResult{}, fmt.Errorf("matrix rows missing")
|
||||||
}
|
}
|
||||||
for i := range out.Rows {
|
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 == "" {
|
if out.Rows[i].Text == "" {
|
||||||
return GenerateResult{}, fmt.Errorf("matrix row %d empty", i+1)
|
return GenerateResult{}, fmt.Errorf("matrix row %d empty", i+1)
|
||||||
}
|
}
|
||||||
|
|
@ -217,15 +225,6 @@ func normalizeGenerateResult(out GenerateResult) (GenerateResult, error) {
|
||||||
return out, nil
|
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) {
|
func extractJSONObject(raw string) ([]byte, error) {
|
||||||
raw = strings.TrimSpace(raw)
|
raw = strings.TrimSpace(raw)
|
||||||
if m := codeFenceRE.FindStringSubmatch(raw); len(m) == 2 {
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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)
|
t.Fatalf("missing expected fragments: %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,25 @@
|
||||||
你是 Threads 內容矩陣策劃師,為人設帳號產出多篇可發佈草稿。
|
你是 Threads / Instagram 內容策略師,為人設帳號產出可發佈草稿。產文時必須優先使用人設的「語言指紋與寫作規則」,把新主題、參考貼文或外部資料翻譯成這個人設會講出口的話,不沿用任何預設爆款模板。
|
||||||
|
|
||||||
|
寫作目標:
|
||||||
|
- 先根據人設語言指紋判斷主題適合哪一種內容型態:心情陪伴、知識整理、清單工具、伴侶溝通、互動提問或輕幽默共鳴,再選對鬆結構。
|
||||||
|
- 每篇要有一個清楚情境或情緒入口,讓讀者覺得「這就是我」,不要寫成摘要報告或廣告文。
|
||||||
|
- 必須沿用人設的段落節奏、標點、換行、稱呼、口頭禪與收尾習慣;不要硬剪短成破碎金句。
|
||||||
|
- 只學參考樣本的節奏、取材方式、收尾習慣與互動方式;不可照抄句子。
|
||||||
|
- 避免 AI 腔:過度總結、過度正能量、每段都像教條、連續排比到不自然、硬塞「大家一起」式口號。
|
||||||
|
|
||||||
規則:
|
規則:
|
||||||
- 只回傳單一 JSON 物件 {"rows":[...]},不要用 markdown 程式碼區塊,不要加前言或結語。
|
- 只回傳單一 JSON 物件 {"rows":[...]},不要用 markdown 程式碼區塊,不要加前言或結語。
|
||||||
- reference_notes、rationale 各 ≤ 40 字;優先把 token 用在 text 主文。
|
- reference_notes、rationale 各 ≤ 40 字;優先把 token 用在 text 主文。
|
||||||
- 每篇必須角度不同,避免重複 hook。
|
- 每篇必須角度不同,避免重複 hook。
|
||||||
- 人設 8D 是最高優先:語氣、人稱、禁忌必須完全符合;不要寫成品牌廣告或硬銷。
|
- 人設定位決定「誰在說」;語言指紋決定「怎麼說」。兩者都要遵守,不可只拿主題資料直接改寫。
|
||||||
|
- 人設只用來控制語氣、詞彙密度、句型節奏、人稱、標點換行與禁忌;不可把人設介紹裡的物件或標籤硬塞進正文情境。
|
||||||
|
- 若人設含 Y2K、咖啡、穿搭、音樂等元素,除非任務主題/Brief/勾選知識明確要求,禁止把這些元素寫成貼文內容或場景。
|
||||||
|
- 正文素材以任務主題、Brief、研究地圖與勾選延伸知識為準;人設不得改變題材。
|
||||||
- 內容論點來自研究地圖與使用者勾選的延伸知識(含標籤與細節);爆款樣本只學結構與節奏,不抄原文。
|
- 內容論點來自研究地圖與使用者勾選的延伸知識(含標籤與細節);爆款樣本只學結構與節奏,不抄原文。
|
||||||
- 繁體中文,口語自然,適合 Threads。
|
- 繁體中文,口語自然,適合 Threads。
|
||||||
- 每篇 text 主文 ≤ 500 字(Threads API 硬上限,含 #話題標籤)。
|
- 每篇 text 主文 ≤ 500 字(Threads API 硬上限,含 #話題標籤)。
|
||||||
- 爆款互動最佳 80~220 字:前 1~2 行強 hook,一句一重點;超過 300 字互動通常下降。
|
- 長度依帳號樣本與主題決定:心情陪伴可中長,知識工具文可完整列點,互動短文才需要短。
|
||||||
|
- text 排版要像真人手機發文:用自然換行分段,可使用標點、括號、頓號與少量 emoji;不要整段擠成一塊,也不要每句都硬換行。
|
||||||
|
- 若要改寫參考貼文,只保留意思與資訊,句型、段落、收尾必須換成人設語言指紋,不可像同義詞替換。
|
||||||
|
- 若使用外部知識,先轉譯成此人設會說的順序與語氣;不得直接貼百科、條列報告或 SEO 摘要。
|
||||||
|
- 醫療、備孕、試管、保健品相關內容必須保留風險語氣:不可保證效果、不可替代醫師建議、不可用數字審判讀者。
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
任務主題:{{topic_label}}
|
任務主題:{{topic_label}}
|
||||||
Brief:{{topic_brief}}
|
Brief:{{topic_brief}}
|
||||||
|
|
||||||
人設 8D(最高優先,語氣與禁忌以此為準):
|
人設定位、語言指紋與禁忌(最高優先):
|
||||||
{{persona_block}}
|
{{persona_block}}
|
||||||
|
|
||||||
研究地圖與勾選延伸知識:
|
研究地圖與勾選延伸知識:
|
||||||
|
|
@ -14,14 +14,20 @@ Brief:{{topic_brief}}
|
||||||
|
|
||||||
參考網頁 Markdown 已併入上方研究地圖區塊(若有)。
|
參考網頁 Markdown 已併入上方研究地圖區塊(若有)。
|
||||||
|
|
||||||
爆款樣本(可選,只學結構;若沒有樣本,請改用研究地圖與人設原創):
|
爆款樣本(可選,只學故事感;若沒有樣本,請改用研究地圖與人設原創):
|
||||||
{{viral_samples_block}}
|
{{viral_samples_block}}
|
||||||
|
|
||||||
請先遵守:
|
請先遵守:
|
||||||
- **最高優先:人設 8D** — 語氣、人稱、句型、節奏、禁忌必須完全符合人設;讀起來要像這個帳號在說話。
|
- **最高優先:語言指紋**:先讀 persona_block 裡的「語言指紋與寫作規則」,包含開頭、句型、段落長度、標點換行、知識轉譯、CTA 與禁忌。產出的文字必須像這個人設會講出口的話。
|
||||||
|
- **不要套舊規格**:不要套用短句爆款、固定三段式、固定強 hook、固定 CTA;格式由人設語言指紋與主題型態決定。
|
||||||
|
- **語氣參考**:讀起來要像這個帳號在說話,但每篇敘事節奏與開場都要自然變化。
|
||||||
|
- **人設不是內容素材**:只學語氣、詞彙密度、句型節奏與審美,不要把人設裡的名詞、興趣、裝飾符號硬塞進正文。例:人設寫 Y2K/咖啡/穿搭,只代表語感,不代表每篇都要寫老歌、咖啡、穿搭、MV。
|
||||||
- **內容素材**:研究地圖(受眾、目標、問題、支柱)與「勾選延伸知識」(含標籤、細節)決定論點與角度;若有「參考網頁(Markdown)」區塊,優先從中提取事實與論點,再融入草稿。
|
- **內容素材**:研究地圖(受眾、目標、問題、支柱)與「勾選延伸知識」(含標籤、細節)決定論點與角度;若有「參考網頁(Markdown)」區塊,優先從中提取事實與論點,再融入草稿。
|
||||||
- 搜尋查詢/標籤僅作方向參考,不可蓋過人設與勾選知識。
|
- 搜尋查詢/標籤僅作方向參考,不可蓋過人設與勾選知識。
|
||||||
- 爆款樣本只學結構節奏,不抄原文,不可為仿爆款偏離任務主題。
|
- 爆款樣本只學故事感、資訊密度、情緒力度與互動方式,不抄原文,不可為仿爆款偏離任務主題。
|
||||||
|
- 若樣本呈現「長 caption + 輪播圖卡重點」的內容型態,Threads 草稿也要保留完整情緒承接與可收藏資訊,不要只輸出金句摘要。
|
||||||
|
- 若任務是改寫或參考別人的貼文,請用人設語言重新說一次,不是同義詞替換;保留觀點,不保留對方句型。
|
||||||
|
- 若任務需要找資料或引用外部知識,請先消化成此人設的說話順序:先安撫或帶入情境,再整理重點,最後用符合人設的方式收尾。
|
||||||
- 若沒有爆款樣本,source_permalinks 回傳空陣列,reference_notes 寫明使用了哪些人設特徵、研究地圖重點與勾選延伸知識。
|
- 若沒有爆款樣本,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:
|
只回傳以下 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 味、過度模板、冒犯或高風險語句。
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,12 @@ type PersonaDraft struct {
|
||||||
Tone string `json:"tone"`
|
Tone string `json:"tone"`
|
||||||
Audience string `json:"audience"`
|
Audience string `json:"audience"`
|
||||||
Hooks string `json:"hooks"`
|
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"`
|
Examples string `json:"examples"`
|
||||||
Avoid string `json:"avoid"`
|
Avoid string `json:"avoid"`
|
||||||
}
|
}
|
||||||
|
|
@ -53,6 +59,8 @@ type Engagement struct {
|
||||||
|
|
||||||
type StoredProfile struct {
|
type StoredProfile struct {
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
|
SourceType string `json:"sourceType,omitempty"`
|
||||||
|
SourceLabel string `json:"sourceLabel,omitempty"`
|
||||||
AnalyzedAt string `json:"analyzedAt"`
|
AnalyzedAt string `json:"analyzedAt"`
|
||||||
PostCount int `json:"postCount"`
|
PostCount int `json:"postCount"`
|
||||||
Engagement Engagement `json:"engagement"`
|
Engagement Engagement `json:"engagement"`
|
||||||
|
|
@ -61,6 +69,125 @@ type StoredProfile struct {
|
||||||
PersonaDraft string `json:"personaDraft"`
|
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 {
|
func BuildUserPrompt(username string, posts []Post) string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
fmt.Fprintf(&b, "對標帳號:@%s\n近期貼文樣本:\n\n", strings.TrimPrefix(username, "@"))
|
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.Tone),
|
||||||
"【對誰說】\n" + strings.TrimSpace(draft.Audience),
|
"【對誰說】\n" + strings.TrimSpace(draft.Audience),
|
||||||
"【開場習慣】\n" + strings.TrimSpace(draft.Hooks),
|
"【開場習慣】\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.Examples),
|
||||||
"【避免】\n" + strings.TrimSpace(draft.Avoid),
|
"【避免】\n" + strings.TrimSpace(draft.Avoid),
|
||||||
}
|
}
|
||||||
|
|
@ -211,8 +344,26 @@ func buildSamplePosts(posts []Post) []Post {
|
||||||
}
|
}
|
||||||
|
|
||||||
func BuildStoredProfile(username string, posts []Post, out LLMOutput) StoredProfile {
|
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{
|
return StoredProfile{
|
||||||
Username: strings.TrimPrefix(strings.TrimSpace(username), "@"),
|
|
||||||
AnalyzedAt: time.Now().UTC().Format(time.RFC3339),
|
AnalyzedAt: time.Now().UTC().Format(time.RFC3339),
|
||||||
PostCount: len(posts),
|
PostCount: len(posts),
|
||||||
Engagement: EvaluateEngagement(posts, 10),
|
Engagement: EvaluateEngagement(posts, 10),
|
||||||
|
|
@ -336,6 +487,12 @@ func parsePersonaDraft(root map[string]json.RawMessage) PersonaDraft {
|
||||||
Tone: firstString(obj, "tone", "voice"),
|
Tone: firstString(obj, "tone", "voice"),
|
||||||
Audience: firstString(obj, "audience", "targetAudience"),
|
Audience: firstString(obj, "audience", "targetAudience"),
|
||||||
Hooks: firstString(obj, "hooks", "openings"),
|
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"),
|
Examples: firstString(obj, "examples", "sample"),
|
||||||
Avoid: firstString(obj, "avoid", "risks"),
|
Avoid: firstString(obj, "avoid", "risks"),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
package style8d
|
package style8d
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
func TestParseLLMOutput(t *testing.T) {
|
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)
|
out, err := ParseLLMOutput(raw)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
|
@ -23,4 +26,7 @@ func TestParseLLMOutput(t *testing.T) {
|
||||||
if len(profile.SamplePosts) != 1 || profile.SamplePosts[0].Permalink == "" {
|
if len(profile.SamplePosts) != 1 || profile.SamplePosts[0].Permalink == "" {
|
||||||
t.Fatalf("samplePosts=%+v", profile.SamplePosts)
|
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) != ""
|
return BuildStyle8DPromptBlock(styleProfileJSON) != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var copyVoiceDimensionOrder = []string{"d1Tone", "d8Risk"}
|
||||||
|
|
||||||
// BuildStyle8DPromptBlock formats D1–D8 summaries for LLM prompts.
|
// BuildStyle8DPromptBlock formats D1–D8 summaries for LLM prompts.
|
||||||
func BuildStyle8DPromptBlock(styleProfileJSON string) string {
|
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)
|
profile, ok := ParseStoredProfile(styleProfileJSON)
|
||||||
if !ok {
|
if !ok {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
lines := make([]string, 0, len(dimensionOrder))
|
lines := make([]string, 0, len(keys))
|
||||||
for _, key := range dimensionOrder {
|
for _, key := range keys {
|
||||||
summary := strings.TrimSpace(profile.Analysis[key].Summary)
|
summary := strings.TrimSpace(profile.Analysis[key].Summary)
|
||||||
if summary == "" {
|
if summary == "" {
|
||||||
continue
|
continue
|
||||||
|
|
@ -66,30 +81,31 @@ func BuildStyle8DPromptBlock(styleProfileJSON string) string {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString("【8D 風格策略】\n產文必須遵守:\n")
|
b.WriteString(header)
|
||||||
b.WriteString(strings.Join(lines, "\n"))
|
b.WriteString(strings.Join(lines, "\n"))
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResolvePersonaBlock picks the best persona voice block for copy generation.
|
// ResolvePersonaBlock combines explicit persona positioning with analyzed writing fingerprints.
|
||||||
// Priority: explicit persona field → 8D personaDraft → brief fallback; always append 8D strategy when present.
|
// The persona field answers "who this is"; personaDraft/style profile answers "how this person says things".
|
||||||
func ResolvePersonaBlock(personaText, styleProfileJSON, brief string) string {
|
func ResolvePersonaBlock(personaText, styleProfileJSON, brief string) string {
|
||||||
var parts []string
|
var parts []string
|
||||||
|
|
||||||
voice := strings.TrimSpace(personaText)
|
profile, hasProfile := ParseStoredProfile(styleProfileJSON)
|
||||||
if voice == "" {
|
persona := strings.TrimSpace(personaText)
|
||||||
if profile, ok := ParseStoredProfile(styleProfileJSON); ok {
|
if persona != "" {
|
||||||
voice = strings.TrimSpace(profile.PersonaDraft)
|
parts = append(parts, "【人設定位】\n"+persona)
|
||||||
}
|
} else if fallback := strings.TrimSpace(brief); fallback != "" {
|
||||||
}
|
parts = append(parts, "【人設定位】\n"+fallback)
|
||||||
if voice == "" {
|
|
||||||
voice = strings.TrimSpace(brief)
|
|
||||||
}
|
|
||||||
if voice != "" {
|
|
||||||
parts = append(parts, "【人設語氣】\n"+voice)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
parts = append(parts, block)
|
||||||
}
|
}
|
||||||
return strings.TrimSpace(strings.Join(parts, "\n\n"))
|
return strings.TrimSpace(strings.Join(parts, "\n\n"))
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,17 @@ func TestResolvePersonaBlockUsesPersonaDraftWhenPersonaEmpty(t *testing.T) {
|
||||||
if block == "" {
|
if block == "" {
|
||||||
t.Fatal("expected non-empty 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) {
|
if !strings.Contains(block, part) {
|
||||||
t.Fatalf("block missing %q: %q", part, block)
|
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 {
|
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"`
|
HookPattern string `json:"hookPattern"`
|
||||||
StructurePattern string `json:"structurePattern"`
|
StructurePattern string `json:"structurePattern"`
|
||||||
EmotionalTrigger string `json:"emotionalTrigger"`
|
EmotionalTrigger string `json:"emotionalTrigger"`
|
||||||
|
|
@ -26,9 +34,15 @@ type AnalyzeViralInput struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func BuildAnalyzeViralSystemPrompt() string {
|
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 {
|
func BuildAnalyzeViralUserPrompt(in AnalyzeViralInput) string {
|
||||||
|
|
@ -65,7 +79,10 @@ func ParseAnalyzeViralOutput(raw string) (ViralAnalysis, error) {
|
||||||
if err := json.Unmarshal(payload, &out); err != nil {
|
if err := json.Unmarshal(payload, &out); err != nil {
|
||||||
return ViralAnalysis{}, fmt.Errorf("parse viral analysis: %w", err)
|
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 ViralAnalysis{}, fmt.Errorf("viral analysis missing content")
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
|
|
@ -73,29 +90,47 @@ func ParseAnalyzeViralOutput(raw string) (ViralAnalysis, error) {
|
||||||
|
|
||||||
func FormatAnalysisForReplicate(analysis ViralAnalysis) string {
|
func FormatAnalysisForReplicate(analysis ViralAnalysis) string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
if hook := strings.TrimSpace(analysis.HookPattern); hook != "" {
|
if hook := firstNonEmpty(analysis.StoryHook, analysis.HookPattern); hook != "" {
|
||||||
b.WriteString("Hook 模式:")
|
b.WriteString("故事開場力量:")
|
||||||
b.WriteString(hook)
|
b.WriteString(hook)
|
||||||
b.WriteString("\n")
|
b.WriteString("\n")
|
||||||
}
|
}
|
||||||
if structure := strings.TrimSpace(analysis.StructurePattern); structure != "" {
|
if beat := firstNonEmpty(analysis.NarrativeBeat, analysis.StructurePattern); beat != "" {
|
||||||
b.WriteString("結構節奏:")
|
b.WriteString("敘事推進:")
|
||||||
b.WriteString(structure)
|
b.WriteString(beat)
|
||||||
b.WriteString("\n")
|
b.WriteString("\n")
|
||||||
}
|
}
|
||||||
if emotion := strings.TrimSpace(analysis.EmotionalTrigger); emotion != "" {
|
if emotion := firstNonEmpty(analysis.EmotionalCore, analysis.EmotionalTrigger); emotion != "" {
|
||||||
b.WriteString("情緒觸發:")
|
b.WriteString("核心情緒:")
|
||||||
b.WriteString(emotion)
|
b.WriteString(emotion)
|
||||||
b.WriteString("\n")
|
b.WriteString("\n")
|
||||||
}
|
}
|
||||||
if strategy := strings.TrimSpace(analysis.ReplicationStrategy); strategy != "" {
|
if scene := strings.TrimSpace(analysis.SceneSeed); scene != "" {
|
||||||
b.WriteString("仿寫策略:")
|
b.WriteString("場景種子(啟發用,勿照搬):")
|
||||||
b.WriteString(strategy)
|
b.WriteString(scene)
|
||||||
b.WriteString("\n")
|
b.WriteString("\n")
|
||||||
}
|
}
|
||||||
if len(analysis.KeyTakeaways) > 0 {
|
cues := analysis.AuthenticityCues
|
||||||
b.WriteString("重點:")
|
if len(cues) == 0 {
|
||||||
b.WriteString(strings.Join(analysis.KeyTakeaways, ";"))
|
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())
|
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
|
PersonaPillars []string
|
||||||
RecentMissionLabels []string
|
RecentMissionLabels []string
|
||||||
RecentSeedQueries []string
|
RecentSeedQueries []string
|
||||||
|
AvoidTopics []string
|
||||||
|
ContentDirection string
|
||||||
UserKeyword string
|
UserKeyword string
|
||||||
TrendSnippets []MissionInspireTrendSnippet
|
TrendSnippets []MissionInspireTrendSnippet
|
||||||
WebSearchProvider string
|
WebSearchProvider string
|
||||||
|
|
@ -50,22 +52,45 @@ type MissionInspireOutput struct {
|
||||||
Brief string
|
Brief string
|
||||||
TrendReason string
|
TrendReason string
|
||||||
TrendKeywords []string
|
TrendKeywords []string
|
||||||
|
Angles []string
|
||||||
|
Mission string
|
||||||
|
TargetAudience string
|
||||||
|
OpeningType string
|
||||||
|
BodyType string
|
||||||
|
Emotion string
|
||||||
|
CtaType string
|
||||||
|
RiskLevel string
|
||||||
|
Avoid []string
|
||||||
}
|
}
|
||||||
|
|
||||||
func BuildMissionInspireSystemPrompt() string {
|
func BuildMissionInspireSystemPrompt() string {
|
||||||
return strings.TrimSpace(`你是 Threads 拷貝忍者的「靈感骰子」顧問。根據近期 Threads 熱門貼文、網路搜尋訊號與創作者人設,產出一組**全新**拷貝任務草稿。
|
return strings.TrimSpace(`你是 Threads 帳號的內容編輯與 Topic Radar。根據近期 Threads / 社群討論訊號、網路搜尋素材與創作者人設,先產出「可執行的內容計畫」,不要直接寫貼文正文。
|
||||||
|
|
||||||
規則:
|
規則:
|
||||||
1. 若【近期趨勢訊號】有內容,從中挑一個適合在 Threads 海巡的方向;不要編造不存在的時事或熱門貼文
|
1. 若【近期趨勢訊號】有內容,從中挑一個適合在 Threads 海巡的方向;優先使用社群熱門關鍵字、受眾求助、心得討論、留言常見問題,不要把新聞事件本身當主題
|
||||||
2. 若趨勢訊號為空(未連線網路搜尋),**必須**改依人設、受眾痛點與常見 Threads 討論型態推測「近期可能被搜尋」的話題,並在 trendReason 說明推測理由(不要假裝有外部熱搜來源)
|
2. 若趨勢訊號為空(未連線網路搜尋),**必須**改依人設、受眾痛點與常見 Threads 討論型態推測「近期可能被搜尋」的話題,並在 trendReason 說明推測理由(不要假裝有外部熱搜來源)
|
||||||
3. label:任務名稱,6~18 字,像企劃案標題,不要標點堆疊
|
3. label:任務名稱,6~18 字,像企劃案標題,不要標點堆疊
|
||||||
4. seedQuery:種子關鍵字/近期熱詞,2~6 個詞用頓號或空格分隔,適合當 Threads 搜尋起點
|
4. seedQuery:種子關鍵字/近期熱詞,2~6 個詞用頓號或空格分隔,適合當 Threads 搜尋起點
|
||||||
5. brief:這次想找什麼,2~4 句,說明要海巡哪類爆款、語氣或格式偏好
|
5. brief:這次內容計畫摘要,2~4 句,說明話題、受眾痛點、為何適合這個帳號
|
||||||
6. trendReason:1~2 句,為何選這個趨勢(可引用訊號來源大意,不要貼 URL)
|
6. trendReason:1~2 句,為何選這個趨勢(說明它和受眾討論、搜尋意圖或痛點的關係,不要貼 URL)
|
||||||
7. trendKeywords:3~6 個相關熱詞(字串陣列)
|
7. trendKeywords:3~6 個相關熱詞(字串陣列),必須像 Threads 會搜尋/討論的關鍵字,不要像新聞標題
|
||||||
8. 避開【近期已做過的任務】相同題材
|
8. angles:3~5 個可勾選切角(字串陣列),每個切角都要說明「這篇要怎麼切」,不要直接寫成完整貼文
|
||||||
9. 繁體中文;只回傳 JSON:
|
9. mission:內容任務,只能是「日常陪伴」「專業知識」「情緒共鳴」「工具清單」「熱門觀點」「互動提問」之一,除非使用者方向要求更精準
|
||||||
{"label":"","seedQuery":"","brief":"","trendReason":"","trendKeywords":[]}`)
|
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 {
|
func BuildMissionInspireUserPrompt(in MissionInspireInput) string {
|
||||||
|
|
@ -76,9 +101,9 @@ func BuildMissionInspireUserPrompt(in MissionInspireInput) string {
|
||||||
b.WriteString("\n")
|
b.WriteString("\n")
|
||||||
}
|
}
|
||||||
if p := strings.TrimSpace(in.PersonaBlock); p != "" {
|
if p := strings.TrimSpace(in.PersonaBlock); p != "" {
|
||||||
b.WriteString("【人設摘要】\n")
|
b.WriteString("【人設摘要(只用來校準語氣與受眾,不是題材庫)】\n")
|
||||||
b.WriteString(p)
|
b.WriteString(p)
|
||||||
b.WriteString("\n")
|
b.WriteString("\n注意:不要把人設裡的名詞、興趣或裝飾符號直接變成話題。\n")
|
||||||
}
|
}
|
||||||
if bench := strings.TrimSpace(in.StyleBenchmark); bench != "" {
|
if bench := strings.TrimSpace(in.StyleBenchmark); bench != "" {
|
||||||
b.WriteString("【對標帳號】@")
|
b.WriteString("【對標帳號】@")
|
||||||
|
|
@ -105,8 +130,20 @@ func BuildMissionInspireUserPrompt(in MissionInspireInput) string {
|
||||||
b.WriteString(keyword)
|
b.WriteString(keyword)
|
||||||
b.WriteString("\n")
|
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")
|
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 {
|
for _, label := range in.RecentMissionLabels {
|
||||||
if label = strings.TrimSpace(label); label != "" {
|
if label = strings.TrimSpace(label); label != "" {
|
||||||
b.WriteString("- ")
|
b.WriteString("- ")
|
||||||
|
|
@ -147,15 +184,15 @@ func BuildMissionInspireUserPrompt(in MissionInspireInput) string {
|
||||||
b.WriteString("\n")
|
b.WriteString("\n")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
b.WriteString("請產出拷貝任務靈感 JSON。")
|
b.WriteString("請產出 Topic Radar 與 Content Plan JSON。")
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
func InspireTrendSearchQueries(personaBrief, styleBenchmark string) []string {
|
func InspireTrendSearchQueries(personaBrief, styleBenchmark string) []string {
|
||||||
queries := []string{
|
queries := []string{
|
||||||
"Google Trends 台灣 今日 熱門搜尋 關鍵字",
|
"Threads 台灣 熱門 關鍵字 討論 最近 一週",
|
||||||
"Threads 台灣 熱門 話題 最近 一週",
|
"Threads 台灣 請問 推薦 心得 最近",
|
||||||
"近期 網路 熱搜 關鍵字 台灣",
|
"社群 熱門 關鍵字 台灣 討論 Dcard PTT Threads",
|
||||||
}
|
}
|
||||||
context := strings.TrimSpace(personaBrief + " " + styleBenchmark)
|
context := strings.TrimSpace(personaBrief + " " + styleBenchmark)
|
||||||
if context != "" {
|
if context != "" {
|
||||||
|
|
@ -163,7 +200,10 @@ func InspireTrendSearchQueries(personaBrief, styleBenchmark string) []string {
|
||||||
if len([]rune(trimmed)) > 24 {
|
if len([]rune(trimmed)) > 24 {
|
||||||
trimmed = string([]rune(trimmed)[:24])
|
trimmed = string([]rune(trimmed)[:24])
|
||||||
}
|
}
|
||||||
queries = append(queries, trimmed+" 熱門 話題 最近")
|
queries = append(queries,
|
||||||
|
trimmed+" Threads 熱門 關鍵字 討論",
|
||||||
|
trimmed+" 請問 心得 推薦 Threads",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return queries
|
return queries
|
||||||
}
|
}
|
||||||
|
|
@ -186,9 +226,11 @@ func InspireThreadsSearchQueries(in MissionInspireThreadsInput) []string {
|
||||||
keyword := cleanInspireQuery(in.Keyword)
|
keyword := cleanInspireQuery(in.Keyword)
|
||||||
if keyword != "" {
|
if keyword != "" {
|
||||||
add(keyword)
|
add(keyword)
|
||||||
add(keyword + " 心情")
|
add(keyword + " 熱門")
|
||||||
add(keyword + " 經驗")
|
add(keyword + " 心得")
|
||||||
add(keyword + " 請問")
|
add(keyword + " 請問")
|
||||||
|
add(keyword + " 推薦")
|
||||||
|
add(keyword + " 怎麼辦")
|
||||||
}
|
}
|
||||||
for _, pillar := range in.Pillars {
|
for _, pillar := range in.Pillars {
|
||||||
if len(out) >= 6 {
|
if len(out) >= 6 {
|
||||||
|
|
@ -328,6 +370,13 @@ func ParseMissionInspireOutput(raw string) (MissionInspireOutput, error) {
|
||||||
SeedQuery: firstJSONString(root, "seedQuery", "seed_query", "seed", "keywords"),
|
SeedQuery: firstJSONString(root, "seedQuery", "seed_query", "seed", "keywords"),
|
||||||
Brief: firstJSONString(root, "brief", "description", "goal"),
|
Brief: firstJSONString(root, "brief", "description", "goal"),
|
||||||
TrendReason: firstJSONString(root, "trendReason", "trend_reason", "reason"),
|
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 == "" {
|
if out.Label == "" || out.SeedQuery == "" || out.Brief == "" {
|
||||||
return MissionInspireOutput{}, fmt.Errorf("missing label, seedQuery, or brief")
|
return MissionInspireOutput{}, fmt.Errorf("missing label, seedQuery, or brief")
|
||||||
|
|
@ -340,5 +389,21 @@ func ParseMissionInspireOutput(raw string) (MissionInspireOutput, error) {
|
||||||
out.TrendKeywords = parseFlexibleStringList(rawKW)
|
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
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,9 @@ func CollectMissionInspireTrends(
|
||||||
if title == "" && snippet == "" {
|
if title == "" && snippet == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if looksLikeNewsResult(title, snippet, item.URL) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
key := strings.ToLower(title + "|" + snippet)
|
key := strings.ToLower(title + "|" + snippet)
|
||||||
if _, ok := seen[key]; ok {
|
if _, ok := seen[key]; ok {
|
||||||
continue
|
continue
|
||||||
|
|
@ -64,3 +67,24 @@ func CollectMissionInspireTrends(
|
||||||
}
|
}
|
||||||
return out
|
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"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"haixun-backend/internal/library/copyvoice"
|
||||||
"haixun-backend/internal/library/threadspost"
|
"haixun-backend/internal/library/threadspost"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -19,6 +20,7 @@ type ReplicateInput struct {
|
||||||
OriginalText string
|
OriginalText string
|
||||||
AuthorName string
|
AuthorName string
|
||||||
StructureAnalysis string
|
StructureAnalysis string
|
||||||
|
NarrativeSeed string
|
||||||
}
|
}
|
||||||
|
|
||||||
type ReplicateResult struct {
|
type ReplicateResult struct {
|
||||||
|
|
@ -30,13 +32,15 @@ type ReplicateResult struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func BuildSystemPrompt() string {
|
func BuildSystemPrompt() string {
|
||||||
return strings.TrimSpace(`你是 Threads 爆款複製策略師。根據參考爆文,為使用者撰寫「同結構、同節奏、但完全原創」的貼文。
|
return strings.TrimSpace(`你是 Threads / Instagram 創作者。讀完參考爆文後,用指定人設的語言指紋寫一篇全新貼文。
|
||||||
|
|
||||||
規則:
|
` + copyvoice.SystemRules() + `
|
||||||
- 複製的是爆款公式(hook 手法、情緒節奏),不是抄襲原文
|
|
||||||
- 文筆必須像創作者本人,套用其 8D 風格策略
|
輸出:
|
||||||
- text 主文 ≤ 500 字(Threads API 硬上限,含 #話題標籤)
|
- text 主文 ≤ 500 字(Threads API 硬上限,含 #話題標籤)
|
||||||
- 爆款互動最佳 80~220 字:前 1~2 行強 hook,口語精簡;超過 300 字互動通常下降
|
- 參考原文只學情緒力度、資訊密度與節奏;最後必須換成人設會講出口的話
|
||||||
|
- 篇幅、行數、標點與換行優先遵守人設語言指紋;沒有指紋時才貼近參考原文
|
||||||
|
- text 排版要像真人手機發文:可使用自然標點、空行、列點與少量 emoji;不要完全無標點,也不要每句硬換行,除非人設樣本就是這樣
|
||||||
- 只回傳一個 JSON 物件,欄位:angle, hook, text, rationale, structureNotes`)
|
- 只回傳一個 JSON 物件,欄位:angle, hook, text, rationale, structureNotes`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -51,29 +55,39 @@ func BuildUserPrompt(input ReplicateInput) string {
|
||||||
b.WriteString("\n")
|
b.WriteString("\n")
|
||||||
}
|
}
|
||||||
if persona := strings.TrimSpace(input.Persona); persona != "" {
|
if persona := strings.TrimSpace(input.Persona); persona != "" {
|
||||||
b.WriteString("\n人設與語氣:\n")
|
b.WriteString("\n人設定位、語言指紋與禁忌(最高優先):\n")
|
||||||
b.WriteString(persona)
|
b.WriteString(persona)
|
||||||
b.WriteString("\n")
|
b.WriteString("\n")
|
||||||
|
b.WriteString(copyvoice.PersonaCalibrationNote())
|
||||||
|
b.WriteString("\n")
|
||||||
}
|
}
|
||||||
if style := strings.TrimSpace(input.StyleProfile); style != "" {
|
if style := strings.TrimSpace(input.StyleProfile); style != "" {
|
||||||
b.WriteString("\n8D 風格策略:\n")
|
b.WriteString("\n8D 風格策略:\n")
|
||||||
b.WriteString(style)
|
b.WriteString(style)
|
||||||
b.WriteString("\n")
|
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)
|
author := strings.TrimSpace(input.AuthorName)
|
||||||
if author == "" {
|
if author == "" {
|
||||||
author = "匿名"
|
author = "匿名"
|
||||||
}
|
}
|
||||||
if analysis := strings.TrimSpace(input.StructureAnalysis); analysis != "" {
|
original := strings.TrimSpace(input.OriginalText)
|
||||||
b.WriteString("\n爆款結構分析(仿寫時套用):\n")
|
if note := threadspost.MimicLengthGuidance(original); note != "" {
|
||||||
b.WriteString(analysis)
|
b.WriteString("\n")
|
||||||
|
b.WriteString(note)
|
||||||
b.WriteString("\n")
|
b.WriteString("\n")
|
||||||
}
|
}
|
||||||
b.WriteString("\n原文參考(@")
|
b.WriteString("\n原文參考(@")
|
||||||
b.WriteString(author)
|
b.WriteString(author)
|
||||||
b.WriteString(",只學結構不抄內容):\n")
|
b.WriteString(",只感受情緒、敘事與篇幅,禁止抄句型):\n")
|
||||||
b.WriteString(strings.TrimSpace(input.OriginalText))
|
b.WriteString(original)
|
||||||
b.WriteString("\n\n請產出一篇可發布的複製版貼文 JSON。")
|
b.WriteString("\n\n本次敘事切入:")
|
||||||
|
b.WriteString(copyvoice.NarrativeLens(input.NarrativeSeed))
|
||||||
|
b.WriteString("\n\n請產出一篇可發布的全新貼文 JSON。hook 欄位只放內部分析,不要把模板句型寫進 text。")
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -86,7 +100,7 @@ func ParseReplicateOutput(raw string) (ReplicateResult, error) {
|
||||||
if err := json.Unmarshal(payload, &out); err != nil {
|
if err := json.Unmarshal(payload, &out); err != nil {
|
||||||
return ReplicateResult{}, fmt.Errorf("parse viral replica json: %w", err)
|
return ReplicateResult{}, fmt.Errorf("parse viral replica json: %w", err)
|
||||||
}
|
}
|
||||||
out.Text = trimText(out.Text)
|
out.Text = threadspost.FormatDraftText(out.Text)
|
||||||
if out.Text == "" {
|
if out.Text == "" {
|
||||||
return ReplicateResult{}, fmt.Errorf("replica text missing")
|
return ReplicateResult{}, fmt.Errorf("replica text missing")
|
||||||
}
|
}
|
||||||
|
|
@ -97,15 +111,6 @@ func ParseReplicateOutput(raw string) (ReplicateResult, error) {
|
||||||
return out, nil
|
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) {
|
func extractJSONObject(raw string) ([]byte, error) {
|
||||||
raw = strings.TrimSpace(raw)
|
raw = strings.TrimSpace(raw)
|
||||||
if m := codeFenceRE.FindStringSubmatch(raw); len(m) == 2 {
|
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
|
package copy_mission
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"strings"
|
|
||||||
|
|
||||||
missiondomain "haixun-backend/internal/model/copy_mission/domain/usecase"
|
|
||||||
"haixun-backend/internal/svc"
|
"haixun-backend/internal/svc"
|
||||||
"haixun-backend/internal/types"
|
"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) {
|
func (l *CreateCopyMissionLogic) CreateCopyMission(req *types.CreateCopyMissionHandlerReq) (resp *types.CopyMissionData, err error) {
|
||||||
tenantID, uid, err := actorFrom(l.ctx)
|
// todo: add your logic here and delete this line
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
return
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||||
|
// goctl 1.10.1
|
||||||
|
|
||||||
package copy_mission
|
package copy_mission
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"haixun-backend/internal/svc"
|
"haixun-backend/internal/svc"
|
||||||
"haixun-backend/internal/types"
|
"haixun-backend/internal/types"
|
||||||
|
|
@ -25,9 +27,7 @@ func NewDeleteCopyMissionLogic(ctx context.Context, svcCtx *svc.ServiceContext)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *DeleteCopyMissionLogic) DeleteCopyMission(req *types.CopyMissionPath) error {
|
func (l *DeleteCopyMissionLogic) DeleteCopyMission(req *types.CopyMissionPath) error {
|
||||||
tenantID, uid, err := actorFrom(l.ctx)
|
// todo: add your logic here and delete this line
|
||||||
if err != nil {
|
|
||||||
return err
|
return nil
|
||||||
}
|
|
||||||
return l.svcCtx.CopyMission.Delete(l.ctx, tenantID, uid, strings.TrimSpace(req.PersonaID), strings.TrimSpace(req.ID))
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,52 +1,33 @@
|
||||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||||
|
// goctl 1.10.1
|
||||||
|
|
||||||
package copy_mission
|
package copy_mission
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase"
|
|
||||||
"haixun-backend/internal/svc"
|
"haixun-backend/internal/svc"
|
||||||
"haixun-backend/internal/types"
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/core/logx"
|
||||||
)
|
)
|
||||||
|
|
||||||
type DeleteCopyMissionMatrixDraftsLogic struct {
|
type DeleteCopyMissionMatrixDraftsLogic struct {
|
||||||
|
logx.Logger
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
svcCtx *svc.ServiceContext
|
svcCtx *svc.ServiceContext
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewDeleteCopyMissionMatrixDraftsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeleteCopyMissionMatrixDraftsLogic {
|
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(
|
func (l *DeleteCopyMissionMatrixDraftsLogic) DeleteCopyMissionMatrixDrafts(req *types.DeleteCopyMissionMatrixDraftsHandlerReq) (resp *types.DeleteCopyMissionMatrixDraftsData, err error) {
|
||||||
req *types.DeleteCopyMissionMatrixDraftsHandlerReq,
|
// todo: add your logic here and delete this line
|
||||||
) (*types.DeleteCopyMissionMatrixDraftsData, error) {
|
|
||||||
tenantID, uid, err := actorFrom(l.ctx)
|
return
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,11 @@
|
||||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||||
|
// goctl 1.10.1
|
||||||
|
|
||||||
package copy_mission
|
package copy_mission
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"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/svc"
|
||||||
"haixun-backend/internal/types"
|
"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) {
|
func (l *ExpandCopyMissionGraphLogic) ExpandCopyMissionGraph(req *types.ExpandCopyMissionGraphHandlerReq) (resp *types.ExpandKnowledgeGraphData, err error) {
|
||||||
tenantID, uid, err := actorFrom(l.ctx)
|
// todo: add your logic here and delete this line
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
research, err := l.svcCtx.Placement.ResearchSettings(l.ctx, tenantID, uid)
|
return
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,226 +1,33 @@
|
||||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||||
|
// goctl 1.10.1
|
||||||
|
|
||||||
package copy_mission
|
package copy_mission
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"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/svc"
|
||||||
"haixun-backend/internal/types"
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/core/logx"
|
||||||
)
|
)
|
||||||
|
|
||||||
type GenerateCopyMissionMatrixLogic struct {
|
type GenerateCopyMissionMatrixLogic struct {
|
||||||
|
logx.Logger
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
svcCtx *svc.ServiceContext
|
svcCtx *svc.ServiceContext
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewGenerateCopyMissionMatrixLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GenerateCopyMissionMatrixLogic {
|
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(
|
func (l *GenerateCopyMissionMatrixLogic) GenerateCopyMissionMatrix(req *types.GenerateCopyMissionMatrixHandlerReq) (resp *types.GenerateCopyMissionMatrixData, err error) {
|
||||||
req *types.GenerateCopyMissionMatrixHandlerReq,
|
// todo: add your logic here and delete this line
|
||||||
) (*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("請先完成研究地圖")
|
|
||||||
}
|
|
||||||
|
|
||||||
persona, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID)
|
return
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||||
|
// goctl 1.10.1
|
||||||
|
|
||||||
package copy_mission
|
package copy_mission
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"haixun-backend/internal/svc"
|
"haixun-backend/internal/svc"
|
||||||
"haixun-backend/internal/types"
|
"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) {
|
func (l *GetCopyMissionGraphLogic) GetCopyMissionGraph(req *types.CopyMissionPath) (resp *types.KnowledgeGraphData, err error) {
|
||||||
tenantID, uid, err := actorFrom(l.ctx)
|
// todo: add your logic here and delete this line
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
return
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||||
|
// goctl 1.10.1
|
||||||
|
|
||||||
package copy_mission
|
package copy_mission
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"haixun-backend/internal/svc"
|
"haixun-backend/internal/svc"
|
||||||
"haixun-backend/internal/types"
|
"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) {
|
func (l *GetCopyMissionLogic) GetCopyMission(req *types.CopyMissionPath) (resp *types.CopyMissionData, err error) {
|
||||||
tenantID, uid, err := actorFrom(l.ctx)
|
// todo: add your logic here and delete this line
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
return
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,40 +1,33 @@
|
||||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||||
|
// goctl 1.10.1
|
||||||
|
|
||||||
package copy_mission
|
package copy_mission
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"haixun-backend/internal/svc"
|
"haixun-backend/internal/svc"
|
||||||
"haixun-backend/internal/types"
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/core/logx"
|
||||||
)
|
)
|
||||||
|
|
||||||
type GetCopyMissionScanScheduleLogic struct {
|
type GetCopyMissionScanScheduleLogic struct {
|
||||||
|
logx.Logger
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
svcCtx *svc.ServiceContext
|
svcCtx *svc.ServiceContext
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewGetCopyMissionScanScheduleLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetCopyMissionScanScheduleLogic {
|
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(
|
func (l *GetCopyMissionScanScheduleLogic) GetCopyMissionScanSchedule(req *types.CopyMissionPath) (resp *types.CopyMissionScanScheduleData, err error) {
|
||||||
req *types.CopyMissionPath,
|
// todo: add your logic here and delete this line
|
||||||
) (*types.CopyMissionScanScheduleData, error) {
|
|
||||||
tenantID, uid, err := actorFrom(l.ctx)
|
return
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,12 @@
|
||||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||||
|
// goctl 1.10.1
|
||||||
|
|
||||||
package copy_mission
|
package copy_mission
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
app "haixun-backend/internal/library/errors"
|
app "haixun-backend/internal/library/errors"
|
||||||
"haixun-backend/internal/library/errors/code"
|
"haixun-backend/internal/library/errors/code"
|
||||||
|
|
@ -12,91 +16,41 @@ import (
|
||||||
"haixun-backend/internal/library/websearch"
|
"haixun-backend/internal/library/websearch"
|
||||||
domai "haixun-backend/internal/model/ai/domain/usecase"
|
domai "haixun-backend/internal/model/ai/domain/usecase"
|
||||||
aiusecase "haixun-backend/internal/model/ai/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/svc"
|
||||||
"haixun-backend/internal/types"
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/core/logx"
|
||||||
)
|
)
|
||||||
|
|
||||||
type InspireCopyMissionLogic struct {
|
type InspireCopyMissionLogic struct {
|
||||||
|
logx.Logger
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
svcCtx *svc.ServiceContext
|
svcCtx *svc.ServiceContext
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewInspireCopyMissionLogic(ctx context.Context, svcCtx *svc.ServiceContext) *InspireCopyMissionLogic {
|
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)
|
tenantID, uid, err := actorFrom(l.ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
personaID := strings.TrimSpace(req.PersonaID)
|
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)
|
persona, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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)
|
credential, err := l.svcCtx.ThreadsAccount.ResolveMemberAiCredential(l.ctx, tenantID, uid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -106,47 +60,280 @@ func (l *InspireCopyMissionLogic) InspireCopyMission(req *types.CopyMissionInspi
|
||||||
return nil, err
|
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)
|
personaBlock := style8d.ResolvePersonaBlock(persona.Persona, persona.StyleProfile, persona.Brief)
|
||||||
|
temp := 0.72
|
||||||
result, err := l.svcCtx.AI.GenerateText(l.ctx, domai.GenerateRequest{
|
result, err := l.svcCtx.AI.GenerateText(l.ctx, domai.GenerateRequest{
|
||||||
Provider: providerID,
|
Provider: providerID,
|
||||||
Model: credential.Model,
|
Model: credential.Model,
|
||||||
Credential: domai.Credential{
|
Credential: domai.Credential{APIKey: credential.APIKey},
|
||||||
APIKey: credential.APIKey,
|
|
||||||
},
|
|
||||||
System: libviral.BuildMissionInspireSystemPrompt(),
|
System: libviral.BuildMissionInspireSystemPrompt(),
|
||||||
Messages: []domai.Message{
|
Temperature: &temp,
|
||||||
{
|
Messages: []domai.Message{{Role: "user", Content: libviral.BuildMissionInspireUserPrompt(libviral.MissionInspireInput{
|
||||||
Role: "user",
|
|
||||||
Content: libviral.BuildMissionInspireUserPrompt(libviral.MissionInspireInput{
|
|
||||||
PersonaDisplayName: persona.DisplayName,
|
PersonaDisplayName: persona.DisplayName,
|
||||||
PersonaBrief: persona.Brief,
|
PersonaBrief: persona.Brief,
|
||||||
PersonaBlock: personaBlock,
|
PersonaBlock: personaBlock,
|
||||||
StyleBenchmark: persona.StyleBenchmark,
|
StyleBenchmark: persona.StyleBenchmark,
|
||||||
PersonaAudience: persona.CopyResearchMap.AudienceSummary,
|
AvoidTopics: cleanAvoidTopics(req.AvoidTopics),
|
||||||
PersonaContentGoal: persona.CopyResearchMap.ContentGoal,
|
ContentDirection: req.ContentDirection,
|
||||||
PersonaQuestions: append([]string(nil), persona.CopyResearchMap.Questions...),
|
UserKeyword: keyword,
|
||||||
PersonaPillars: append([]string(nil), persona.CopyResearchMap.Pillars...),
|
TrendSnippets: trends,
|
||||||
RecentMissionLabels: recentLabels,
|
WebSearchProvider: sourceLabel(searchUsed, sources),
|
||||||
RecentSeedQueries: recentSeeds,
|
|
||||||
UserKeyword: userKeyword,
|
|
||||||
TrendSnippets: trendSnippets,
|
|
||||||
WebSearchProvider: trendSourceLabel,
|
|
||||||
LLMOnly: llmOnly,
|
LLMOnly: llmOnly,
|
||||||
}),
|
})}},
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, app.For(code.AI).SvcThirdParty("起號靈感生成失敗:" + err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
parsed, err := libviral.ParseMissionInspireOutput(result.Text)
|
parsed, err := libviral.ParseMissionInspireOutput(result.Text)
|
||||||
if err != nil {
|
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))
|
topicCandidate, plan := l.persistInspiration(tenantID, uid, personaID, parsed, searchUsed)
|
||||||
for _, item := range trendSnippets {
|
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{
|
sources = append(sources, types.CopyMissionInspirationSourceData{
|
||||||
Query: item.Query,
|
Query: item.Query,
|
||||||
Title: item.Title,
|
Title: item.Title,
|
||||||
|
|
@ -154,37 +341,33 @@ func (l *InspireCopyMissionLogic) InspireCopyMission(req *types.CopyMissionInspi
|
||||||
URL: item.URL,
|
URL: item.URL,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
return sources
|
||||||
message := "已依近期 Threads 熱門素材產出任務靈感,可微調後建立任務"
|
|
||||||
if webSearchUsed {
|
|
||||||
message = "已依近期網路趨勢產出任務靈感,可微調後建立任務"
|
|
||||||
}
|
|
||||||
if len(trendSnippets) == 0 {
|
|
||||||
message = "未取得外部趨勢素材,已改由 LLM 依人設與關鍵字推測靈感;同步 Chrome Session、完成 Threads API 或補上 Brave/Exa key 可更貼近熱搜"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return &types.CopyMissionInspirationData{
|
func sourceLabel(used bool, sources []types.CopyMissionInspirationSourceData) string {
|
||||||
Label: parsed.Label,
|
if !used || len(sources) == 0 {
|
||||||
SeedQuery: parsed.SeedQuery,
|
return ""
|
||||||
Brief: parsed.Brief,
|
}
|
||||||
TrendReason: parsed.TrendReason,
|
return "Free Trend / Web / Threads Search"
|
||||||
TrendKeywords: parsed.TrendKeywords,
|
|
||||||
Sources: sources,
|
|
||||||
WebSearchUsed: webSearchUsed,
|
|
||||||
Message: message,
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *InspireCopyMissionLogic) makeCrawlerSearchFn(tenantID, ownerUID string) placement.CrawlerSearchFn {
|
func cleanAvoidTopics(items []string) []string {
|
||||||
return func(ctx context.Context, member placement.MemberContext, keyword string, limit int) ([]placement.DiscoverPost, error) {
|
out := make([]string, 0, len(items))
|
||||||
accountID := strings.TrimSpace(member.ActiveAccountID)
|
seen := map[string]struct{}{}
|
||||||
if accountID == "" {
|
for _, item := range items {
|
||||||
return nil, app.For(code.Setting).InputMissingRequired("請先選定經營帳號並同步 Chrome Session")
|
item = strings.TrimSpace(item)
|
||||||
|
if item == "" {
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
session, err := l.svcCtx.ThreadsAccount.GetBrowserSession(ctx, tenantID, ownerUID, accountID)
|
key := strings.ToLower(item)
|
||||||
if err != nil {
|
if _, ok := seen[key]; ok {
|
||||||
return nil, err
|
continue
|
||||||
}
|
}
|
||||||
return placement.RunExecCrawlerSearch(ctx, session.StorageState, keyword, limit)
|
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
|
package copy_mission
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"haixun-backend/internal/svc"
|
"haixun-backend/internal/svc"
|
||||||
"haixun-backend/internal/types"
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/core/logx"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ListCopyMissionCopyDraftsLogic struct {
|
type ListCopyMissionCopyDraftsLogic struct {
|
||||||
|
logx.Logger
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
svcCtx *svc.ServiceContext
|
svcCtx *svc.ServiceContext
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewListCopyMissionCopyDraftsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListCopyMissionCopyDraftsLogic {
|
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) {
|
func (l *ListCopyMissionCopyDraftsLogic) ListCopyMissionCopyDrafts(req *types.CopyMissionPath) (resp *types.ListCopyMissionCopyDraftsData, err error) {
|
||||||
tenantID, uid, err := actorFrom(l.ctx)
|
// todo: add your logic here and delete this line
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
drafts, err := l.svcCtx.CopyDraft.ListByMission(l.ctx, tenantID, uid, personaID, missionID, 50)
|
return
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,49 +1,33 @@
|
||||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||||
|
// goctl 1.10.1
|
||||||
|
|
||||||
package copy_mission
|
package copy_mission
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"strings"
|
|
||||||
|
|
||||||
scanpostdomain "haixun-backend/internal/model/scan_post/domain/usecase"
|
|
||||||
"haixun-backend/internal/svc"
|
"haixun-backend/internal/svc"
|
||||||
"haixun-backend/internal/types"
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/core/logx"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ListCopyMissionScanPostsLogic struct {
|
type ListCopyMissionScanPostsLogic struct {
|
||||||
|
logx.Logger
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
svcCtx *svc.ServiceContext
|
svcCtx *svc.ServiceContext
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewListCopyMissionScanPostsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListCopyMissionScanPostsLogic {
|
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(
|
func (l *ListCopyMissionScanPostsLogic) ListCopyMissionScanPosts(req *types.ListCopyMissionScanPostsHandlerReq) (resp *types.ListPersonaViralScanPostsData, err error) {
|
||||||
req *types.ListCopyMissionScanPostsHandlerReq,
|
// todo: add your logic here and delete this line
|
||||||
) (*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
|
|
||||||
}
|
|
||||||
|
|
||||||
limit := 100
|
return
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||||
|
// goctl 1.10.1
|
||||||
|
|
||||||
package copy_mission
|
package copy_mission
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"haixun-backend/internal/svc"
|
"haixun-backend/internal/svc"
|
||||||
"haixun-backend/internal/types"
|
"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) {
|
func (l *ListCopyMissionsLogic) ListCopyMissions(req *types.PersonaCopyMissionsPath) (resp *types.ListCopyMissionsData, err error) {
|
||||||
tenantID, uid, err := actorFrom(l.ctx)
|
// todo: add your logic here and delete this line
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
return
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"strings"
|
|
||||||
|
|
||||||
missiondomain "haixun-backend/internal/model/copy_mission/domain/usecase"
|
|
||||||
"haixun-backend/internal/svc"
|
"haixun-backend/internal/svc"
|
||||||
"haixun-backend/internal/types"
|
"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) {
|
func (l *PatchCopyMissionAudienceSampleLogic) PatchCopyMissionAudienceSample(req *types.PatchAudienceSampleHandlerReq) (resp *types.CopyMissionData, err error) {
|
||||||
tenantID, uid, err := actorFrom(l.ctx)
|
// todo: add your logic here and delete this line
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
return
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,11 @@
|
||||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||||
|
// goctl 1.10.1
|
||||||
|
|
||||||
package copy_mission
|
package copy_mission
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"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/svc"
|
||||||
"haixun-backend/internal/types"
|
"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) {
|
func (l *PatchCopyMissionGraphNodesLogic) PatchCopyMissionGraphNodes(req *types.PatchCopyMissionGraphNodesHandlerReq) (resp *types.KnowledgeGraphData, err error) {
|
||||||
tenantID, uid, err := actorFrom(l.ctx)
|
// todo: add your logic here and delete this line
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
updates := make([]kgusecase.NodeUpdate, 0, len(req.Updates))
|
return
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,7 @@ package copy_mission
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"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/svc"
|
||||||
"haixun-backend/internal/types"
|
"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) {
|
func (l *PatchCopyMissionSimilarAccountLogic) PatchCopyMissionSimilarAccount(req *types.PatchSimilarAccountHandlerReq) (resp *types.CopyMissionData, err error) {
|
||||||
tenantID, uid, err := actorFrom(l.ctx)
|
// todo: add your logic here and delete this line
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
return
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
import (
|
||||||
"context"
|
"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/svc"
|
||||||
"haixun-backend/internal/types"
|
"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) {
|
func (l *ScheduleCopyMissionDraftsLogic) ScheduleCopyMissionDrafts(req *types.ScheduleCopyMissionDraftsHandlerReq) (resp *types.CopyMissionScheduleCopyDraftsData, err error) {
|
||||||
tenantID, uid, err := actorFrom(l.ctx)
|
// todo: add your logic here and delete this line
|
||||||
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 scheduleTimes(startAt int64, timezone string, slots []types.PublishSlotData, count int) []int64 {
|
return
|
||||||
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,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
package copy_mission
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"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/svc"
|
||||||
"haixun-backend/internal/types"
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/core/logx"
|
||||||
)
|
)
|
||||||
|
|
||||||
type StartCopyMissionAnalyzeJobLogic struct {
|
type StartCopyMissionAnalyzeJobLogic struct {
|
||||||
|
logx.Logger
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
svcCtx *svc.ServiceContext
|
svcCtx *svc.ServiceContext
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewStartCopyMissionAnalyzeJobLogic(ctx context.Context, svcCtx *svc.ServiceContext) *StartCopyMissionAnalyzeJobLogic {
|
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(
|
func (l *StartCopyMissionAnalyzeJobLogic) StartCopyMissionAnalyzeJob(req *types.CopyMissionPath) (resp *types.StartCopyMissionAnalyzeJobData, err error) {
|
||||||
req *types.CopyMissionPath,
|
// todo: add your logic here and delete this line
|
||||||
) (*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
|
|
||||||
}
|
|
||||||
|
|
||||||
payload := map[string]any{
|
return
|
||||||
"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
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,80 +1,33 @@
|
||||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||||
|
// goctl 1.10.1
|
||||||
|
|
||||||
package copy_mission
|
package copy_mission
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"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/svc"
|
||||||
"haixun-backend/internal/types"
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/core/logx"
|
||||||
)
|
)
|
||||||
|
|
||||||
type StartCopyMissionCopyDraftJobLogic struct {
|
type StartCopyMissionCopyDraftJobLogic struct {
|
||||||
|
logx.Logger
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
svcCtx *svc.ServiceContext
|
svcCtx *svc.ServiceContext
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewStartCopyMissionCopyDraftJobLogic(ctx context.Context, svcCtx *svc.ServiceContext) *StartCopyMissionCopyDraftJobLogic {
|
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(
|
func (l *StartCopyMissionCopyDraftJobLogic) StartCopyMissionCopyDraftJob(req *types.StartCopyMissionCopyDraftJobHandlerReq) (resp *types.StartCopyMissionCopyDraftJobData, err error) {
|
||||||
req *types.StartCopyMissionCopyDraftJobHandlerReq,
|
// todo: add your logic here and delete this line
|
||||||
) (*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")
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := l.svcCtx.CopyMission.Get(l.ctx, tenantID, uid, personaID, missionID); err != nil {
|
return
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,89 +1,33 @@
|
||||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||||
|
// goctl 1.10.1
|
||||||
|
|
||||||
package copy_mission
|
package copy_mission
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"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/svc"
|
||||||
"haixun-backend/internal/types"
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/core/logx"
|
||||||
)
|
)
|
||||||
|
|
||||||
type StartCopyMissionMatrixJobLogic struct {
|
type StartCopyMissionMatrixJobLogic struct {
|
||||||
|
logx.Logger
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
svcCtx *svc.ServiceContext
|
svcCtx *svc.ServiceContext
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewStartCopyMissionMatrixJobLogic(ctx context.Context, svcCtx *svc.ServiceContext) *StartCopyMissionMatrixJobLogic {
|
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(
|
func (l *StartCopyMissionMatrixJobLogic) StartCopyMissionMatrixJob(req *types.StartCopyMissionMatrixJobHandlerReq) (resp *types.StartCopyMissionMatrixJobData, err error) {
|
||||||
req *types.StartCopyMissionMatrixJobHandlerReq,
|
// todo: add your logic here and delete this line
|
||||||
) (*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("請先完成研究地圖")
|
|
||||||
}
|
|
||||||
|
|
||||||
persona, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID)
|
return
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,95 +1,33 @@
|
||||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||||
|
// goctl 1.10.1
|
||||||
|
|
||||||
package copy_mission
|
package copy_mission
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"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/svc"
|
||||||
"haixun-backend/internal/types"
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/core/logx"
|
||||||
)
|
)
|
||||||
|
|
||||||
type StartCopyMissionScanJobLogic struct {
|
type StartCopyMissionScanJobLogic struct {
|
||||||
|
logx.Logger
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
svcCtx *svc.ServiceContext
|
svcCtx *svc.ServiceContext
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewStartCopyMissionScanJobLogic(ctx context.Context, svcCtx *svc.ServiceContext) *StartCopyMissionScanJobLogic {
|
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(
|
func (l *StartCopyMissionScanJobLogic) StartCopyMissionScanJob(req *types.CopyMissionPath) (resp *types.StartCopyMissionScanJobData, err error) {
|
||||||
req *types.CopyMissionPath,
|
// todo: add your logic here and delete this line
|
||||||
) (*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 個,請減少勾選以節省搜尋配額")
|
|
||||||
}
|
|
||||||
|
|
||||||
research, err := l.svcCtx.Placement.ResearchSettings(l.ctx, tenantID, uid)
|
return
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,11 @@
|
||||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||||
|
// goctl 1.10.1
|
||||||
|
|
||||||
package copy_mission
|
package copy_mission
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"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/svc"
|
||||||
"haixun-backend/internal/types"
|
"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) {
|
func (l *UpdateCopyMissionLogic) UpdateCopyMission(req *types.UpdateCopyMissionHandlerReq) (resp *types.CopyMissionData, err error) {
|
||||||
tenantID, uid, err := actorFrom(l.ctx)
|
// todo: add your logic here and delete this line
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
return
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,106 +1,33 @@
|
||||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||||
|
// goctl 1.10.1
|
||||||
|
|
||||||
package copy_mission
|
package copy_mission
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"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/svc"
|
||||||
"haixun-backend/internal/types"
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/core/logx"
|
||||||
)
|
)
|
||||||
|
|
||||||
type UpsertCopyMissionScanScheduleLogic struct {
|
type UpsertCopyMissionScanScheduleLogic struct {
|
||||||
|
logx.Logger
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
svcCtx *svc.ServiceContext
|
svcCtx *svc.ServiceContext
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewUpsertCopyMissionScanScheduleLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpsertCopyMissionScanScheduleLogic {
|
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(
|
func (l *UpsertCopyMissionScanScheduleLogic) UpsertCopyMissionScanSchedule(req *types.UpsertCopyMissionScanScheduleHandlerReq) (resp *types.CopyMissionScanScheduleData, err error) {
|
||||||
req *types.UpsertCopyMissionScanScheduleHandlerReq,
|
// todo: add your logic here and delete this line
|
||||||
) (*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("啟用排程前請先勾選並儲存搜尋標籤")
|
|
||||||
}
|
|
||||||
|
|
||||||
cronExpr := "0 9 * * *"
|
return
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,11 @@ func (l *AnalyzeStyle8DFromWorkerLogic) AnalyzeStyle8DFromWorker(req *types.Anal
|
||||||
})
|
})
|
||||||
|
|
||||||
username := strings.TrimPrefix(strings.TrimSpace(req.Username), "@")
|
username := strings.TrimPrefix(strings.TrimSpace(req.Username), "@")
|
||||||
|
input := style8d.AnalyzeInput{
|
||||||
|
Mode: style8d.AnalyzeModeBenchmark,
|
||||||
|
Username: username,
|
||||||
|
Posts: posts,
|
||||||
|
}
|
||||||
systemPrompt, err := libprompt.Style8DSystem()
|
systemPrompt, err := libprompt.Style8DSystem()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, app.For(code.AI).SysInternal("prompt config load failed")
|
return nil, app.For(code.AI).SysInternal("prompt config load failed")
|
||||||
|
|
@ -102,7 +107,7 @@ func (l *AnalyzeStyle8DFromWorkerLogic) AnalyzeStyle8DFromWorker(req *types.Anal
|
||||||
},
|
},
|
||||||
System: systemPrompt,
|
System: systemPrompt,
|
||||||
Messages: []domai.Message{
|
Messages: []domai.Message{
|
||||||
{Role: "user", Content: style8d.BuildUserPrompt(username, posts)},
|
{Role: "user", Content: input.BuildUserPrompt()},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -119,7 +124,7 @@ func (l *AnalyzeStyle8DFromWorkerLogic) AnalyzeStyle8DFromWorker(req *types.Anal
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, app.For(code.AI).SvcThirdParty("8D LLM 回傳無法解析:" + err.Error())
|
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()
|
profileJSON, err := profile.JSON()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -137,9 +142,10 @@ func (l *AnalyzeStyle8DFromWorkerLogic) AnalyzeStyle8DFromWorker(req *types.Anal
|
||||||
})
|
})
|
||||||
|
|
||||||
personaDraft := strings.TrimSpace(profile.PersonaDraft)
|
personaDraft := strings.TrimSpace(profile.PersonaDraft)
|
||||||
|
benchmark := input.StyleBenchmark()
|
||||||
patch := personausecase.PersonaPatch{
|
patch := personausecase.PersonaPatch{
|
||||||
StyleProfile: &profileJSON,
|
StyleProfile: &profileJSON,
|
||||||
StyleBenchmark: &username,
|
StyleBenchmark: &benchmark,
|
||||||
}
|
}
|
||||||
if personaDraft != "" {
|
if personaDraft != "" {
|
||||||
patch.Persona = &personaDraft
|
patch.Persona = &personaDraft
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
package persona
|
||||||
|
|
||||||
|
import (
|
||||||
|
contentops "haixun-backend/internal/model/content_ops/domain/usecase"
|
||||||
|
"haixun-backend/internal/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
func toTopicCandidateData(item contentops.TopicCandidateSummary) types.TopicCandidateData {
|
||||||
|
return types.TopicCandidateData{ID: item.ID, PersonaID: item.PersonaID, Name: item.Name, Source: item.Source, Category: item.Category, SeedQuery: item.SeedQuery, TrendReason: item.TrendReason, TrendKeywords: item.TrendKeywords, HeatScore: item.HeatScore, FitScore: item.FitScore, InteractionScore: item.InteractionScore, ExtendScore: item.ExtendScore, RiskScore: item.RiskScore, FinalScore: item.FinalScore, RecommendedMissions: item.RecommendedMissions, Status: item.Status, CreateAt: item.CreateAt, UpdateAt: item.UpdateAt}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toContentPlanData(item contentops.ContentPlanSummary) types.ContentPlanData {
|
||||||
|
return types.ContentPlanData{ID: item.ID, PersonaID: item.PersonaID, TopicCandidateID: item.TopicCandidateID, Topic: item.Topic, Mission: item.Mission, TargetAudience: item.TargetAudience, Angle: item.Angle, OpeningType: item.OpeningType, BodyType: item.BodyType, Emotion: item.Emotion, EndingType: item.EndingType, CtaType: item.CtaType, RiskLevel: item.RiskLevel, RequiresHumanReview: item.RequiresHumanReview, Avoid: item.Avoid, SelectedKnowledge: item.SelectedKnowledge, Status: item.Status, CreateAt: item.CreateAt, UpdateAt: item.UpdateAt}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toFeedbackEventData(item contentops.FeedbackEventSummary) types.FeedbackEventData {
|
||||||
|
return types.FeedbackEventData{ID: item.ID, PersonaID: item.PersonaID, ContentPlanID: item.ContentPlanID, DraftID: item.DraftID, Decision: item.Decision, Note: item.Note, Snapshot: item.Snapshot, CreateAt: item.CreateAt}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toKnowledgeSourceData(item contentops.KnowledgeSourceSummary) types.KnowledgeSourceData {
|
||||||
|
return types.KnowledgeSourceData{ID: item.ID, PersonaID: item.PersonaID, SourceType: item.SourceType, Title: item.Title, Filename: item.Filename, ParsedStatus: item.ParsedStatus, ChunkCount: item.ChunkCount, CreateAt: item.CreateAt}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toKnowledgeChunkData(item contentops.KnowledgeChunkSummary) types.KnowledgeChunkData {
|
||||||
|
return types.KnowledgeChunkData{ID: item.ID, PersonaID: item.PersonaID, SourceID: item.SourceID, Content: item.Content, Topics: item.Topics, StyleTags: item.StyleTags, RiskLevel: item.RiskLevel, CreateAt: item.CreateAt}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toFormulaPoolData(item contentops.FormulaPoolSummary) types.FormulaPoolData {
|
||||||
|
return types.FormulaPoolData{ID: item.ID, PersonaID: item.PersonaID, Type: item.Type, Name: item.Name, Pattern: item.Pattern, UseCases: item.UseCases, Avoid: item.Avoid, Weight: item.Weight, CreateAt: item.CreateAt, UpdateAt: item.UpdateAt}
|
||||||
|
}
|
||||||
|
|
@ -9,17 +9,27 @@ func toCopyDraftData(item copydraftdomain.CopyDraftSummary) types.CopyDraftData
|
||||||
return types.CopyDraftData{
|
return types.CopyDraftData{
|
||||||
ID: item.ID,
|
ID: item.ID,
|
||||||
PersonaID: item.PersonaID,
|
PersonaID: item.PersonaID,
|
||||||
|
ContentPlanID: item.ContentPlanID,
|
||||||
CopyMissionID: item.CopyMissionID,
|
CopyMissionID: item.CopyMissionID,
|
||||||
ScanPostID: item.ScanPostID,
|
ScanPostID: item.ScanPostID,
|
||||||
FormulaID: item.FormulaID,
|
FormulaID: item.FormulaID,
|
||||||
DraftType: item.DraftType,
|
DraftType: item.DraftType,
|
||||||
SortOrder: item.SortOrder,
|
SortOrder: item.SortOrder,
|
||||||
Text: item.Text,
|
Text: item.Text,
|
||||||
|
TopicTag: item.TopicTag,
|
||||||
Angle: item.Angle,
|
Angle: item.Angle,
|
||||||
Hook: item.Hook,
|
Hook: item.Hook,
|
||||||
Rationale: item.Rationale,
|
Rationale: item.Rationale,
|
||||||
ReferenceNotes: item.ReferenceNotes,
|
ReferenceNotes: item.ReferenceNotes,
|
||||||
Sources: item.Sources,
|
Sources: item.Sources,
|
||||||
|
AiScore: item.AiScore,
|
||||||
|
FormulaScore: item.FormulaScore,
|
||||||
|
BrandFitScore: item.BrandFitScore,
|
||||||
|
RiskScore: item.RiskScore,
|
||||||
|
SimilarityScore: item.SimilarityScore,
|
||||||
|
EngagementPotential: item.EngagementPotential,
|
||||||
|
FreshnessScore: item.FreshnessScore,
|
||||||
|
ReviewSuggestion: item.ReviewSuggestion,
|
||||||
Status: item.Status,
|
Status: item.Status,
|
||||||
PublishQueueID: item.PublishQueueID,
|
PublishQueueID: item.PublishQueueID,
|
||||||
PublishedMediaID: item.PublishedMediaID,
|
PublishedMediaID: item.PublishedMediaID,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||||
|
// goctl 1.10.1
|
||||||
|
|
||||||
|
package persona
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
contentops "haixun-backend/internal/model/content_ops/domain/usecase"
|
||||||
|
"haixun-backend/internal/svc"
|
||||||
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/core/logx"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CreateContentPlanLogic struct {
|
||||||
|
logx.Logger
|
||||||
|
ctx context.Context
|
||||||
|
svcCtx *svc.ServiceContext
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCreateContentPlanLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CreateContentPlanLogic {
|
||||||
|
return &CreateContentPlanLogic{
|
||||||
|
Logger: logx.WithContext(ctx),
|
||||||
|
ctx: ctx,
|
||||||
|
svcCtx: svcCtx,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *CreateContentPlanLogic) CreateContentPlan(req *types.CreateContentPlanHandlerReq) (resp *types.ContentPlanData, err error) {
|
||||||
|
tenantID, uid, err := actorFrom(l.ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
item, err := l.svcCtx.ContentOps.CreateContentPlan(l.ctx, contentops.ContentPlanInput{TenantID: tenantID, OwnerUID: uid, PersonaID: req.ID, TopicCandidateID: req.TopicCandidateID, Topic: req.Topic, Mission: req.Mission, TargetAudience: req.TargetAudience, Angle: req.Angle, OpeningType: req.OpeningType, BodyType: req.BodyType, Emotion: req.Emotion, EndingType: req.EndingType, CtaType: req.CtaType, RiskLevel: req.RiskLevel, RequiresHumanReview: req.RequiresHumanReview, Avoid: req.Avoid, SelectedKnowledge: req.SelectedKnowledge})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
data := toContentPlanData(*item)
|
||||||
|
return &data, nil
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue