feat(questions): make brand questions the primary monitoring & template axis
- add /questions/combination-fill endpoint with AI-driven, IP-region-aware matrix fill - extract ip2region resolver from ops/app into shared/ipregion for cross-service use - thread question_id filter through dashboard composite, citation summary, and collect-now - switch template wizard from keyword inputs to brand-question selection (primary + supplemental) - pass brand_question and supplemental_questions through assist/title/outline prompts - add AiWaitingModal + 45s client timeout and request_timeout error mapping for long AI flows Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,7 @@ import (
|
||||
sharedbootstrap "github.com/geo-platform/tenant-api/internal/shared/bootstrap"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/cache"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/config"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/ipregion"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/llm"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/messaging/rabbitmq"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/middleware"
|
||||
@@ -55,6 +56,7 @@ type App struct {
|
||||
DesktopTaskStreams *stream.DesktopTaskHub
|
||||
DesktopDispatch *stream.DesktopDispatchHub
|
||||
Cache cache.Cache
|
||||
IPRegions *ipregion.Resolver
|
||||
BrandService *tenantapp.BrandService
|
||||
QuestionExpansion *tenantapp.QuestionExpansionService
|
||||
MonitoringService *tenantapp.MonitoringService
|
||||
@@ -145,8 +147,14 @@ func New(configPath string) (*App, error) {
|
||||
L1TTL: cfg.Cache.L1TTL,
|
||||
DeleteScanCount: cfg.Cache.DeleteScanCount,
|
||||
})
|
||||
ipRegions, err := ipregion.NewResolver(cfg.IPRegion.V4XDBPath, cfg.IPRegion.V6XDBPath, logger)
|
||||
if err != nil {
|
||||
logger.Warn("ip region resolver unavailable; location-aware defaults will be degraded", zap.Error(err))
|
||||
}
|
||||
brandService := tenantapp.NewBrandService(pool, monitoringPool, auditLogs, cfg.BrandLibrary).WithCache(appCache)
|
||||
questionExpansion := tenantapp.NewQuestionExpansionService(pool, llmClient, brandService).WithCache(appCache)
|
||||
questionExpansion := tenantapp.NewQuestionExpansionService(pool, llmClient, brandService).
|
||||
WithCache(appCache).
|
||||
WithIPRegionResolver(ipRegions)
|
||||
monitoringService := tenantapp.NewMonitoringService(pool, monitoringPool, mqClient, cfg.MonitoringDispatch, cfg.BrandLibrary, logger).WithRedis(rdb)
|
||||
kolProfiles := repository.NewKolProfileRepository(pool)
|
||||
kolPackages := repository.NewKolPackageRepository(pool)
|
||||
@@ -215,6 +223,7 @@ func New(configPath string) (*App, error) {
|
||||
DesktopTaskStreams: desktopTaskStreams,
|
||||
DesktopDispatch: desktopDispatch,
|
||||
Cache: appCache,
|
||||
IPRegions: ipRegions,
|
||||
BrandService: brandService,
|
||||
QuestionExpansion: questionExpansion,
|
||||
MonitoringService: monitoringService,
|
||||
@@ -309,6 +318,9 @@ func (a *App) Close() {
|
||||
}
|
||||
cancel()
|
||||
}
|
||||
if a.IPRegions != nil {
|
||||
a.IPRegions.Close()
|
||||
}
|
||||
a.DB.Close()
|
||||
if a.MonitoringDB != nil {
|
||||
a.MonitoringDB.Close()
|
||||
|
||||
@@ -7,19 +7,20 @@ import (
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/ops/domain"
|
||||
"github.com/geo-platform/tenant-api/internal/ops/repository"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/ipregion"
|
||||
)
|
||||
|
||||
type AuditService struct {
|
||||
repo *repository.AuditRepository
|
||||
logger *zap.Logger
|
||||
ipRegions *IPRegionResolver
|
||||
ipRegions *ipregion.Resolver
|
||||
}
|
||||
|
||||
func NewAuditService(repo *repository.AuditRepository, logger *zap.Logger) *AuditService {
|
||||
return &AuditService{repo: repo, logger: logger}
|
||||
}
|
||||
|
||||
func (s *AuditService) WithIPRegionResolver(resolver *IPRegionResolver) *AuditService {
|
||||
func (s *AuditService) WithIPRegionResolver(resolver *ipregion.Resolver) *AuditService {
|
||||
s.ipRegions = resolver
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ type Config struct {
|
||||
MonitoringDispatch MonitoringDispatchConfig `mapstructure:"monitoring_dispatch"`
|
||||
Membership MembershipConfig `mapstructure:"membership"`
|
||||
BrandLibrary BrandLibraryConfig `mapstructure:"brand_library"`
|
||||
IPRegion IPRegionConfig `mapstructure:"ip_region"`
|
||||
Redis RedisConfig `mapstructure:"redis"`
|
||||
Qdrant QdrantConfig `mapstructure:"qdrant"`
|
||||
ObjectStorage ObjectStorageConfig `mapstructure:"object_storage"`
|
||||
@@ -244,6 +245,11 @@ type BrandLibraryConfig struct {
|
||||
QuestionLimitsByPlan map[string]int `mapstructure:"question_limits_by_plan"`
|
||||
}
|
||||
|
||||
type IPRegionConfig struct {
|
||||
V4XDBPath string `mapstructure:"v4_xdb_path"`
|
||||
V6XDBPath string `mapstructure:"v6_xdb_path"`
|
||||
}
|
||||
|
||||
func (c BrandLibraryConfig) BrandLimitForPlan(planCode string) int {
|
||||
if strings.EqualFold(strings.TrimSpace(planCode), "free") {
|
||||
return c.FreeBrandLimit
|
||||
@@ -958,6 +964,12 @@ func applyEnvOverrides(cfg *Config) {
|
||||
if trustedProxies, ok := lookupNonEmptyEnv("SERVER_TRUSTED_PROXIES"); ok {
|
||||
cfg.Server.TrustedProxies = strings.Split(trustedProxies, ",")
|
||||
}
|
||||
if path, ok := lookupNonEmptyEnv("IP_REGION_V4_XDB_PATH"); ok {
|
||||
cfg.IPRegion.V4XDBPath = path
|
||||
}
|
||||
if path, ok := lookupNonEmptyEnv("IP_REGION_V6_XDB_PATH"); ok {
|
||||
cfg.IPRegion.V6XDBPath = path
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeRabbitMQConfig(cfg *RabbitMQConfig) {
|
||||
|
||||
@@ -65,6 +65,9 @@ func Diff(previous, current *Config) []FieldChange {
|
||||
if !reflect.DeepEqual(previous.BrandLibrary, current.BrandLibrary) {
|
||||
addChange("brand_library", true)
|
||||
}
|
||||
if previous.IPRegion != current.IPRegion {
|
||||
addChange("ip_region", false)
|
||||
}
|
||||
if previous.Qdrant != current.Qdrant {
|
||||
addChange("qdrant", true)
|
||||
}
|
||||
|
||||
+28
-28
@@ -1,4 +1,4 @@
|
||||
package app
|
||||
package ipregion
|
||||
|
||||
import (
|
||||
"embed"
|
||||
@@ -22,35 +22,35 @@ const (
|
||||
embeddedV6XDBPath = "ipregiondata/ip2region_v6.xdb"
|
||||
)
|
||||
|
||||
type IPRegionResolver struct {
|
||||
searcher ipRegionSearcher
|
||||
type Resolver struct {
|
||||
searcher searcher
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
type ipRegionSearcher interface {
|
||||
type searcher interface {
|
||||
Search(ip any) (string, error)
|
||||
Close()
|
||||
}
|
||||
|
||||
func NewIPRegionResolver(v4XDBPath, v6XDBPath string, logger *zap.Logger) (*IPRegionResolver, error) {
|
||||
searcher, err := newIPRegionSearcher(v4XDBPath, v6XDBPath)
|
||||
func NewResolver(v4XDBPath, v6XDBPath string, logger *zap.Logger) (*Resolver, error) {
|
||||
searcher, err := newSearcher(v4XDBPath, v6XDBPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &IPRegionResolver{searcher: searcher, logger: logger}, nil
|
||||
return &Resolver{searcher: searcher, logger: logger}, nil
|
||||
}
|
||||
|
||||
func newIPRegionSearcher(v4XDBPath, v6XDBPath string) (*bufferIPRegionSearcher, error) {
|
||||
v4Content, err := loadIPRegionContent(strings.TrimSpace(v4XDBPath), embeddedV4XDBPath, xdb.IPv4)
|
||||
func newSearcher(v4XDBPath, v6XDBPath string) (*bufferSearcher, error) {
|
||||
v4Content, err := loadContent(strings.TrimSpace(v4XDBPath), embeddedV4XDBPath, xdb.IPv4)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load ipv4 xdb: %w", err)
|
||||
}
|
||||
v6Content, err := loadIPRegionContent(strings.TrimSpace(v6XDBPath), embeddedV6XDBPath, xdb.IPv6)
|
||||
v6Content, err := loadContent(strings.TrimSpace(v6XDBPath), embeddedV6XDBPath, xdb.IPv6)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load ipv6 xdb: %w", err)
|
||||
}
|
||||
|
||||
searcher := &bufferIPRegionSearcher{}
|
||||
searcher := &bufferSearcher{}
|
||||
if len(v4Content) > 0 {
|
||||
searcher.v4, err = xdb.NewWithBuffer(xdb.IPv4, v4Content)
|
||||
if err != nil {
|
||||
@@ -66,9 +66,9 @@ func newIPRegionSearcher(v4XDBPath, v6XDBPath string) (*bufferIPRegionSearcher,
|
||||
return searcher, nil
|
||||
}
|
||||
|
||||
func loadIPRegionContent(externalPath, embeddedPath string, version *xdb.Version) ([]byte, error) {
|
||||
func loadContent(externalPath, embeddedPath string, version *xdb.Version) ([]byte, error) {
|
||||
if externalPath != "" {
|
||||
content, err := loadXDBContentFromFile(externalPath, version)
|
||||
content, err := loadContentFromFile(externalPath, version)
|
||||
if err == nil {
|
||||
return content, nil
|
||||
}
|
||||
@@ -81,24 +81,24 @@ func loadIPRegionContent(externalPath, embeddedPath string, version *xdb.Version
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := verifyXDBVersion(content, version); err != nil {
|
||||
if err := verifyVersion(content, version); err != nil {
|
||||
return nil, fmt.Errorf("verify embedded xdb %s: %w", embeddedPath, err)
|
||||
}
|
||||
return content, nil
|
||||
}
|
||||
|
||||
func loadXDBContentFromFile(path string, version *xdb.Version) ([]byte, error) {
|
||||
func loadContentFromFile(path string, version *xdb.Version) ([]byte, error) {
|
||||
content, err := xdb.LoadContentFromFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := verifyXDBVersion(content, version); err != nil {
|
||||
if err := verifyVersion(content, version); err != nil {
|
||||
return nil, fmt.Errorf("verify external xdb %s: %w", path, err)
|
||||
}
|
||||
return content, nil
|
||||
}
|
||||
|
||||
func verifyXDBVersion(content []byte, expected *xdb.Version) error {
|
||||
func verifyVersion(content []byte, expected *xdb.Version) error {
|
||||
if len(content) < xdb.HeaderInfoLength {
|
||||
return fmt.Errorf("xdb content too short: got %d bytes", len(content))
|
||||
}
|
||||
@@ -116,7 +116,7 @@ func verifyXDBVersion(content []byte, expected *xdb.Version) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *IPRegionResolver) Lookup(rawIP string) string {
|
||||
func (r *Resolver) Lookup(rawIP string) string {
|
||||
ipText := strings.TrimSpace(rawIP)
|
||||
if ipText == "" {
|
||||
return ""
|
||||
@@ -142,7 +142,7 @@ func (r *IPRegionResolver) Lookup(rawIP string) string {
|
||||
region, err := r.searcher.Search(ip.String())
|
||||
if err != nil {
|
||||
if r.logger != nil {
|
||||
r.logger.Warn("ops audit ip2region lookup failed",
|
||||
r.logger.Warn("ip2region lookup failed",
|
||||
zap.String("ip", ip.String()),
|
||||
zap.Error(err),
|
||||
)
|
||||
@@ -152,14 +152,20 @@ func (r *IPRegionResolver) Lookup(rawIP string) string {
|
||||
return strings.TrimSpace(region)
|
||||
}
|
||||
|
||||
type bufferIPRegionSearcher struct {
|
||||
func (r *Resolver) Close() {
|
||||
if r != nil && r.searcher != nil {
|
||||
r.searcher.Close()
|
||||
}
|
||||
}
|
||||
|
||||
type bufferSearcher struct {
|
||||
v4 *xdb.Searcher
|
||||
v4Mu sync.Mutex
|
||||
v6 *xdb.Searcher
|
||||
v6Mu sync.Mutex
|
||||
}
|
||||
|
||||
func (s *bufferIPRegionSearcher) Search(ip any) (string, error) {
|
||||
func (s *bufferSearcher) Search(ip any) (string, error) {
|
||||
ipBytes, err := parseIPBytes(ip)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -184,7 +190,7 @@ func (s *bufferIPRegionSearcher) Search(ip any) (string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *bufferIPRegionSearcher) Close() {
|
||||
func (s *bufferSearcher) Close() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
@@ -206,9 +212,3 @@ func parseIPBytes(ip any) ([]byte, error) {
|
||||
return nil, fmt.Errorf("invalid ip value type %T", v)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *IPRegionResolver) Close() {
|
||||
if r != nil && r.searcher != nil {
|
||||
r.searcher.Close()
|
||||
}
|
||||
}
|
||||
+9
-9
@@ -1,9 +1,9 @@
|
||||
package app
|
||||
package ipregion
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIPRegionResolverLookupSpecialAddresses(t *testing.T) {
|
||||
resolver := &IPRegionResolver{}
|
||||
func TestResolverLookupSpecialAddresses(t *testing.T) {
|
||||
resolver := &Resolver{}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -27,10 +27,10 @@ func TestIPRegionResolverLookupSpecialAddresses(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIPRegionResolverLookupFromXDB(t *testing.T) {
|
||||
resolver, err := NewIPRegionResolver("", "", nil)
|
||||
func TestResolverLookupFromXDB(t *testing.T) {
|
||||
resolver, err := NewResolver("", "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewIPRegionResolver() error = %v", err)
|
||||
t.Fatalf("NewResolver() error = %v", err)
|
||||
}
|
||||
defer resolver.Close()
|
||||
|
||||
@@ -39,10 +39,10 @@ func TestIPRegionResolverLookupFromXDB(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIPRegionResolverExternalPathFallsBackToEmbedWhenMissing(t *testing.T) {
|
||||
resolver, err := NewIPRegionResolver("/path/not/exist/ip2region_v4.xdb", "/path/not/exist/ip2region_v6.xdb", nil)
|
||||
func TestResolverExternalPathFallsBackToEmbedWhenMissing(t *testing.T) {
|
||||
resolver, err := NewResolver("/path/not/exist/ip2region_v4.xdb", "/path/not/exist/ip2region_v6.xdb", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewIPRegionResolver() error = %v", err)
|
||||
t.Fatalf("NewResolver() error = %v", err)
|
||||
}
|
||||
defer resolver.Close()
|
||||
|
||||
@@ -178,6 +178,7 @@ var routeDocs = map[string]routeDoc{
|
||||
"GET /api/tenant/brands/:id/questions": {"品牌问题列表", "返回品牌下的监控问题,可按 keyword_id 过滤。"},
|
||||
"POST /api/tenant/brands/:id/questions": {"新增监控问题", "为品牌添加一条 GEO 监控问题。"},
|
||||
"POST /api/tenant/brands/:id/questions/combination-preview": {"拓词工具预览", "按地域词、前缀词、核心词、行业词、后缀词组合生成问题候选,仅预览不入库。"},
|
||||
"POST /api/tenant/brands/:id/questions/combination-fill": {"拓词工具 AI 填词", "根据用户 IP 本地识别地域,并结合品牌信息补全拓词工具的五列词组。"},
|
||||
"POST /api/tenant/brands/:id/questions/ai-distill": {"AI 扩展问题", "围绕品牌和主题生成问题候选,使用结构化输出并按 AI 点计费。"},
|
||||
"POST /api/tenant/brands/:id/questions/classify-metadata": {"问题元数据分类", "批量为问题文本推断 layer 和 intent 元数据。"},
|
||||
"POST /api/tenant/brands/:id/questions/materialize": {"保存问题候选", "将用户选中的问题候选保存到当前品牌问题集,执行配额、去重和审计。"},
|
||||
|
||||
@@ -320,9 +320,9 @@ func queryParameterNames(route gin.RouteInfo) []string {
|
||||
case strings.Contains(path, "/tenant/brands") && strings.Contains(path, "/questions"):
|
||||
add("keyword_id")
|
||||
case strings.Contains(path, "/tenant/monitoring/dashboard/composite"):
|
||||
add("brand_id", "keyword_id", "days", "business_date", "ai_platform_id")
|
||||
add("brand_id", "keyword_id", "question_id", "days", "business_date", "ai_platform_id")
|
||||
case strings.Contains(path, "/tenant/monitoring/citation-summary"):
|
||||
add("days")
|
||||
add("days", "brand_id", "keyword_id", "question_id", "business_date", "ai_platform_id")
|
||||
case strings.Contains(path, "/tenant/monitoring/brands/"):
|
||||
add("ai_platform_id", "date_from", "date_to", "question_hash")
|
||||
case strings.Contains(path, "/tenant/kol/marketplace/packages"):
|
||||
@@ -359,7 +359,7 @@ func schemaForQueryParameter(name string) map[string]any {
|
||||
switch name {
|
||||
case "page", "page_size", "limit", "offset", "days", "period_days":
|
||||
return map[string]any{"type": "integer"}
|
||||
case "brand_id", "keyword_id", "template_id", "kol_prompt_id", "prompt_rule_id", "group_id", "folder_id", "if_sync_version":
|
||||
case "brand_id", "keyword_id", "question_id", "template_id", "kol_prompt_id", "prompt_rule_id", "group_id", "folder_id", "if_sync_version":
|
||||
return map[string]any{"type": "integer", "format": "int64"}
|
||||
case "force":
|
||||
return map[string]any{"type": "string", "enum": []string{"1"}}
|
||||
|
||||
@@ -24,6 +24,7 @@ const (
|
||||
AIUsageTypeKolPromptGenerate = "kol_prompt_generate"
|
||||
AIUsageTypeKolPromptOptimize = "kol_prompt_optimize"
|
||||
AIUsageTypeQuestionDistill = "question_distill"
|
||||
AIUsageTypeQuestionCombinationFill = "question_combination_fill"
|
||||
|
||||
aiPointsQuotaType = "ai_points"
|
||||
)
|
||||
|
||||
@@ -356,6 +356,7 @@ func (s *MonitoringService) DashboardComposite(
|
||||
ctx context.Context,
|
||||
brandID int64,
|
||||
keywordID *int64,
|
||||
questionID *int64,
|
||||
days int,
|
||||
businessDate string,
|
||||
aiPlatformID *string,
|
||||
@@ -388,6 +389,10 @@ func (s *MonitoringService) DashboardComposite(
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
configuredQuestions, err = filterConfiguredQuestionsByQuestionID(configuredQuestions, questionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
platforms := defaultMonitoringPlatformMetadata()
|
||||
selectedPlatformID := normalizedOptionalMonitoringPlatformPointer(aiPlatformID)
|
||||
@@ -396,11 +401,12 @@ func (s *MonitoringService) DashboardComposite(
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
derivedMetrics, err := s.loadDerivedParseMetrics(ctx, actor.TenantID, brand.ID, brand.Name, startDate, endDate, selectedPlatformID)
|
||||
questionIDs := configuredQuestionIDs(configuredQuestions)
|
||||
derivedMetrics, err := s.loadDerivedParseMetrics(ctx, actor.TenantID, brand.ID, questionIDs, brand.Name, startDate, endDate, selectedPlatformID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
overview, _, err := s.loadOverview(ctx, actor.TenantID, brand.ID, startDate, endDate, quota.CollectionMode, selectedPlatformID, derivedMetrics)
|
||||
overview, _, err := s.loadOverview(ctx, actor.TenantID, brand.ID, questionIDs, startDate, endDate, quota.CollectionMode, selectedPlatformID, derivedMetrics)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -410,7 +416,7 @@ func (s *MonitoringService) DashboardComposite(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
platformBreakdown, err := s.loadPlatformBreakdown(ctx, actor.TenantID, brand.ID, endDate, quota.CollectionMode, filteredPlatforms, accessStates, derivedMetrics)
|
||||
platformBreakdown, err := s.loadPlatformBreakdown(ctx, actor.TenantID, brand.ID, questionIDs, endDate, quota.CollectionMode, filteredPlatforms, accessStates, derivedMetrics)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -440,6 +446,7 @@ func (s *MonitoringService) CitationSummary(
|
||||
days int,
|
||||
brandID int64,
|
||||
keywordID *int64,
|
||||
questionID *int64,
|
||||
businessDate string,
|
||||
aiPlatformID *string,
|
||||
) (*MonitoringCitationSummaryResponse, error) {
|
||||
@@ -461,6 +468,10 @@ func (s *MonitoringService) CitationSummary(
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
configuredQuestions, err = filterConfiguredQuestionsByQuestionID(configuredQuestions, questionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
questionIDs = configuredQuestionIDs(configuredQuestions)
|
||||
}
|
||||
|
||||
@@ -641,6 +652,7 @@ func (s *MonitoringService) CollectNow(
|
||||
ctx context.Context,
|
||||
brandID int64,
|
||||
keywordID *int64,
|
||||
questionID *int64,
|
||||
options MonitoringCollectNowOptions,
|
||||
) (*MonitoringCollectNowResponse, error) {
|
||||
actor := auth.MustActor(ctx)
|
||||
@@ -666,13 +678,24 @@ func (s *MonitoringService) CollectNow(
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
configuredQuestions, err = filterConfiguredQuestionsByQuestionID(configuredQuestions, questionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
limitApplied := false
|
||||
if len(configuredQuestions) > monitoringCollectNowQuestionLimit {
|
||||
if questionID == nil && len(configuredQuestions) > monitoringCollectNowQuestionLimit {
|
||||
configuredQuestions = configuredQuestions[:monitoringCollectNowQuestionLimit]
|
||||
limitApplied = true
|
||||
}
|
||||
if len(configuredQuestions) == 0 {
|
||||
message := "当前品牌下暂无品牌库问题,未创建采集任务"
|
||||
if keywordID != nil {
|
||||
message = "当前问题集下暂无品牌库问题,未创建采集任务"
|
||||
}
|
||||
if questionID != nil {
|
||||
message = "当前问题不可用,未创建采集任务"
|
||||
}
|
||||
return &MonitoringCollectNowResponse{
|
||||
CollectionMode: quota.CollectionMode,
|
||||
RefreshedTaskCount: 0,
|
||||
@@ -680,7 +703,7 @@ func (s *MonitoringService) CollectNow(
|
||||
LeasedTaskCount: 0,
|
||||
CompletedTaskCount: 0,
|
||||
HasEffectiveSnapshot: true,
|
||||
Message: "当前关键词下暂无品牌库问题,未创建采集任务",
|
||||
Message: message,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -800,7 +823,7 @@ func (s *MonitoringService) CollectNow(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := s.syncMonitoringQuestionSnapshots(ctx, tx, actor.TenantID, brand.ID, configuredQuestions, keywordID == nil); err != nil {
|
||||
if err := s.syncMonitoringQuestionSnapshots(ctx, tx, actor.TenantID, brand.ID, configuredQuestions, keywordID == nil && questionID == nil); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -1487,6 +1510,21 @@ func configuredQuestionIDs(questions []monitoringConfiguredQuestion) []int64 {
|
||||
return ids
|
||||
}
|
||||
|
||||
func filterConfiguredQuestionsByQuestionID(questions []monitoringConfiguredQuestion, questionID *int64) ([]monitoringConfiguredQuestion, error) {
|
||||
if questionID == nil {
|
||||
return questions, nil
|
||||
}
|
||||
if *questionID <= 0 {
|
||||
return nil, response.ErrBadRequest(40031, "invalid_question_id", "question_id must be a positive number")
|
||||
}
|
||||
for _, question := range questions {
|
||||
if question.ID == *questionID {
|
||||
return []monitoringConfiguredQuestion{question}, nil
|
||||
}
|
||||
}
|
||||
return nil, response.ErrBadRequest(40041, "invalid_question", "question does not belong to the selected brand or question set")
|
||||
}
|
||||
|
||||
func (s *MonitoringService) syncMonitoringQuestionSnapshots(ctx context.Context, tx pgx.Tx, tenantID, brandID int64, questions []monitoringConfiguredQuestion, pruneMissing bool) error {
|
||||
if pruneMissing {
|
||||
if len(questions) == 0 {
|
||||
@@ -2195,11 +2233,15 @@ func (stats monitoringDerivedRateStats) positiveMentionRate() *float64 {
|
||||
func (s *MonitoringService) loadDerivedParseMetrics(
|
||||
ctx context.Context,
|
||||
tenantID, brandID int64,
|
||||
questionIDs []int64,
|
||||
brandName string,
|
||||
startDate, endDate time.Time,
|
||||
aiPlatformID *string,
|
||||
) (monitoringDerivedMetrics, error) {
|
||||
metrics := newMonitoringDerivedMetrics()
|
||||
if questionIDs != nil && len(questionIDs) == 0 {
|
||||
return metrics, nil
|
||||
}
|
||||
platformQueryIDs := monitoringPlatformQueryIDs(aiPlatformID)
|
||||
|
||||
rows, err := s.monitoringPool.Query(ctx, `
|
||||
@@ -2221,8 +2263,9 @@ func (s *MonitoringService) loadDerivedParseMetrics(
|
||||
AND r.collector_type = $3
|
||||
AND r.status = 'succeeded'
|
||||
AND r.business_date BETWEEN $4::date AND $5::date
|
||||
AND ($6::text[] IS NULL OR r.ai_platform_id = ANY($6))
|
||||
`, tenantID, brandID, monitoringCollectorType, startDate.Format("2006-01-02"), endDate.Format("2006-01-02"), nullableStringArray(platformQueryIDs))
|
||||
AND ($6::bigint[] IS NULL OR r.question_id = ANY($6))
|
||||
AND ($7::text[] IS NULL OR r.ai_platform_id = ANY($7))
|
||||
`, tenantID, brandID, monitoringCollectorType, startDate.Format("2006-01-02"), endDate.Format("2006-01-02"), nullableInt64Array(questionIDs), nullableStringArray(platformQueryIDs))
|
||||
if err != nil {
|
||||
return metrics, response.ErrInternal(50041, "query_failed", "failed to load monitoring derived parse metrics")
|
||||
}
|
||||
@@ -2318,11 +2361,15 @@ func decodeMonitoringMatchedBrandTerms(raw []byte) []string {
|
||||
func (s *MonitoringService) loadOverview(
|
||||
ctx context.Context,
|
||||
tenantID, brandID int64,
|
||||
questionIDs []int64,
|
||||
startDate, endDate time.Time,
|
||||
collectionMode string,
|
||||
aiPlatformID *string,
|
||||
derived monitoringDerivedMetrics,
|
||||
) (MonitoringOverview, time.Time, error) {
|
||||
if questionIDs != nil {
|
||||
return s.loadOverviewFromRuns(ctx, tenantID, brandID, questionIDs, startDate, endDate, collectionMode, aiPlatformID, derived)
|
||||
}
|
||||
if platformID := normalizedOptionalMonitoringPlatformID(aiPlatformID); platformID != "" {
|
||||
return s.loadOverviewForPlatform(ctx, tenantID, brandID, startDate, endDate, collectionMode, platformID, derived)
|
||||
}
|
||||
@@ -2364,6 +2411,56 @@ func (s *MonitoringService) loadOverview(
|
||||
return overview, latestDate, nil
|
||||
}
|
||||
|
||||
func (s *MonitoringService) loadOverviewFromRuns(
|
||||
ctx context.Context,
|
||||
tenantID, brandID int64,
|
||||
questionIDs []int64,
|
||||
startDate, endDate time.Time,
|
||||
collectionMode string,
|
||||
aiPlatformID *string,
|
||||
derived monitoringDerivedMetrics,
|
||||
) (MonitoringOverview, time.Time, error) {
|
||||
overview := MonitoringOverview{
|
||||
CollectionMode: collectionMode,
|
||||
ConfidenceLevel: "low",
|
||||
}
|
||||
latestDate := endDate
|
||||
if len(questionIDs) == 0 {
|
||||
return overview, latestDate, nil
|
||||
}
|
||||
|
||||
platformQueryIDs := monitoringPlatformQueryIDs(aiPlatformID)
|
||||
current, found, err := s.loadOverviewRunAggregateForDate(ctx, tenantID, brandID, questionIDs, endDate, platformQueryIDs)
|
||||
if err != nil {
|
||||
return overview, latestDate, err
|
||||
}
|
||||
if !found {
|
||||
return overview, latestDate, nil
|
||||
}
|
||||
|
||||
applyOverviewSnapshot(&overview, current, derived)
|
||||
if current.ActualSampleCount <= 0 {
|
||||
return overview, latestDate, nil
|
||||
}
|
||||
|
||||
latestDate = current.Date
|
||||
previous, foundPrevious, err := s.loadLatestOverviewRunAggregate(ctx, tenantID, brandID, questionIDs, startDate, current.Date.AddDate(0, 0, -1), platformQueryIDs)
|
||||
if err != nil {
|
||||
return overview, latestDate, err
|
||||
}
|
||||
if !foundPrevious {
|
||||
return overview, latestDate, nil
|
||||
}
|
||||
|
||||
applyDerivedRatesToOverviewSnapshot(&previous, derived)
|
||||
overview.PrevSnapshotMentionRate = floatPointer(previous.MentionRate)
|
||||
overview.PrevSnapshotTop1MentionRate = floatPointer(previous.Top1MentionRate)
|
||||
overview.PrevSnapshotFirstRecommendRate = floatPointer(previous.FirstRecommendRate)
|
||||
overview.PrevSnapshotPositiveMentionRate = floatPointer(previous.PositiveMentionRate)
|
||||
|
||||
return overview, latestDate, nil
|
||||
}
|
||||
|
||||
func (s *MonitoringService) loadOverviewForPlatform(
|
||||
ctx context.Context,
|
||||
tenantID, brandID int64,
|
||||
@@ -2434,6 +2531,140 @@ type monitoringOverviewSnapshot struct {
|
||||
CitationRate sql.NullFloat64
|
||||
}
|
||||
|
||||
func (s *MonitoringService) loadOverviewRunAggregateForDate(
|
||||
ctx context.Context,
|
||||
tenantID, brandID int64,
|
||||
questionIDs []int64,
|
||||
businessDate time.Time,
|
||||
platformIDs []string,
|
||||
) (monitoringOverviewSnapshot, bool, error) {
|
||||
return s.loadOverviewRunAggregate(ctx, tenantID, brandID, questionIDs, businessDate, businessDate, platformIDs, false)
|
||||
}
|
||||
|
||||
func (s *MonitoringService) loadLatestOverviewRunAggregate(
|
||||
ctx context.Context,
|
||||
tenantID, brandID int64,
|
||||
questionIDs []int64,
|
||||
startDate, endDate time.Time,
|
||||
platformIDs []string,
|
||||
) (monitoringOverviewSnapshot, bool, error) {
|
||||
if endDate.Before(startDate) {
|
||||
return monitoringOverviewSnapshot{}, false, nil
|
||||
}
|
||||
return s.loadOverviewRunAggregate(ctx, tenantID, brandID, questionIDs, startDate, endDate, platformIDs, true)
|
||||
}
|
||||
|
||||
func (s *MonitoringService) loadOverviewRunAggregate(
|
||||
ctx context.Context,
|
||||
tenantID, brandID int64,
|
||||
questionIDs []int64,
|
||||
startDate, endDate time.Time,
|
||||
platformIDs []string,
|
||||
requireSamples bool,
|
||||
) (monitoringOverviewSnapshot, bool, error) {
|
||||
var item monitoringOverviewSnapshot
|
||||
if len(questionIDs) == 0 {
|
||||
return item, false, nil
|
||||
}
|
||||
|
||||
rows, err := s.monitoringPool.Query(ctx, `
|
||||
SELECT
|
||||
r.business_date,
|
||||
COUNT(*)::bigint AS actual_sample_count,
|
||||
COUNT(*) FILTER (WHERE p.brand_mentioned IS TRUE)::bigint AS mentioned_count,
|
||||
COUNT(*) FILTER (WHERE p.brand_mention_position = 'top1')::bigint AS top1_mentioned_count,
|
||||
COUNT(*) FILTER (WHERE p.first_recommended IS TRUE)::bigint AS first_recommended_count,
|
||||
COUNT(*) FILTER (WHERE p.sentiment_label = 'positive')::bigint AS positive_mentioned_count,
|
||||
MAX(r.completed_at) AS last_sampled_at
|
||||
FROM question_monitor_runs r
|
||||
LEFT JOIN question_monitor_parse_results p
|
||||
ON p.tenant_id = r.tenant_id AND p.run_id = r.id
|
||||
WHERE r.tenant_id = $1
|
||||
AND r.brand_id = $2
|
||||
AND r.collector_type = $3
|
||||
AND r.status = 'succeeded'
|
||||
AND r.business_date BETWEEN $4::date AND $5::date
|
||||
AND r.question_id = ANY($6)
|
||||
AND ($7::text[] IS NULL OR r.ai_platform_id = ANY($7))
|
||||
GROUP BY r.business_date
|
||||
ORDER BY r.business_date DESC
|
||||
`, tenantID, brandID, monitoringCollectorType, startDate.Format("2006-01-02"), endDate.Format("2006-01-02"), questionIDs, nullableStringArray(platformIDs))
|
||||
if err != nil {
|
||||
return item, false, response.ErrInternal(50041, "query_failed", "failed to load monitoring overview")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var mentionedCount int64
|
||||
var top1MentionedCount int64
|
||||
var firstRecommendedCount int64
|
||||
var positiveMentionedCount int64
|
||||
if scanErr := rows.Scan(
|
||||
&item.Date,
|
||||
&item.ActualSampleCount,
|
||||
&mentionedCount,
|
||||
&top1MentionedCount,
|
||||
&firstRecommendedCount,
|
||||
&positiveMentionedCount,
|
||||
&item.SnapshotUpdatedAt,
|
||||
); scanErr != nil {
|
||||
return monitoringOverviewSnapshot{}, false, response.ErrInternal(50041, "scan_failed", "failed to parse monitoring overview")
|
||||
}
|
||||
if requireSamples && item.ActualSampleCount <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
plannedSampleCount, planErr := s.loadOverviewRunPlannedCount(ctx, tenantID, brandID, questionIDs, item.Date, platformIDs)
|
||||
if planErr != nil {
|
||||
return monitoringOverviewSnapshot{}, false, planErr
|
||||
}
|
||||
item.DesiredSampleCount = plannedSampleCount
|
||||
item.PlannedSampleCount = plannedSampleCount
|
||||
item.CoverageRate = pointerFloat64ToNull(divideAsPointer(item.ActualSampleCount, plannedSampleCount))
|
||||
item.ConfidenceLevel = deriveMonitoringConfidenceLevel(item.ActualSampleCount, plannedSampleCount, floatPointer(item.CoverageRate))
|
||||
item.MentionRate = pointerFloat64ToNull(divideAsPointer(mentionedCount, item.ActualSampleCount))
|
||||
item.Top1MentionRate = pointerFloat64ToNull(divideAsPointer(top1MentionedCount, item.ActualSampleCount))
|
||||
item.FirstRecommendRate = pointerFloat64ToNull(divideAsPointer(firstRecommendedCount, item.ActualSampleCount))
|
||||
item.PositiveMentionRate = pointerFloat64ToNull(divideAsPointer(positiveMentionedCount, item.ActualSampleCount))
|
||||
return item, true, nil
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return monitoringOverviewSnapshot{}, false, response.ErrInternal(50041, "scan_failed", "failed to iterate monitoring overview")
|
||||
}
|
||||
|
||||
return monitoringOverviewSnapshot{}, false, nil
|
||||
}
|
||||
|
||||
func (s *MonitoringService) loadOverviewRunPlannedCount(
|
||||
ctx context.Context,
|
||||
tenantID, brandID int64,
|
||||
questionIDs []int64,
|
||||
businessDate time.Time,
|
||||
platformIDs []string,
|
||||
) (int64, error) {
|
||||
var plannedSampleCount int64
|
||||
if err := s.monitoringPool.QueryRow(ctx, `
|
||||
SELECT COUNT(*)::bigint
|
||||
FROM monitoring_collect_tasks
|
||||
WHERE tenant_id = $1
|
||||
AND brand_id = $2
|
||||
AND collector_type = $3
|
||||
AND business_date = $4::date
|
||||
AND question_id = ANY($5)
|
||||
AND ($6::text[] IS NULL OR ai_platform_id = ANY($6))
|
||||
AND NOT (status = 'skipped' AND skip_reason = ANY($7::text[]))
|
||||
`, tenantID, brandID, monitoringCollectorType, businessDate.Format("2006-01-02"), questionIDs, nullableStringArray(platformIDs), monitoringProjectionExcludedSkipReasons).Scan(&plannedSampleCount); err != nil {
|
||||
return 0, response.ErrInternal(50041, "query_failed", "failed to load monitoring overview")
|
||||
}
|
||||
if plannedSampleCount > 0 {
|
||||
return plannedSampleCount, nil
|
||||
}
|
||||
if platformIDs != nil {
|
||||
return int64(len(questionIDs) * len(platformIDs)), nil
|
||||
}
|
||||
return int64(len(questionIDs) * len(defaultMonitoringPlatforms)), nil
|
||||
}
|
||||
|
||||
func (s *MonitoringService) loadOverviewSnapshotForDate(ctx context.Context, tenantID, brandID int64, collectionMode string, businessDate time.Time) (monitoringOverviewSnapshot, bool, error) {
|
||||
var item monitoringOverviewSnapshot
|
||||
err := s.monitoringPool.QueryRow(ctx, `
|
||||
@@ -2765,7 +2996,11 @@ func (s *MonitoringService) monitoringClientBelongsToWorkspace(ctx context.Conte
|
||||
return exists, nil
|
||||
}
|
||||
|
||||
func (s *MonitoringService) loadPlatformBreakdown(ctx context.Context, tenantID, brandID int64, businessDate time.Time, collectionMode string, platforms []monitoringPlatformMetadata, accessStates map[string]monitoringAccessState, derived monitoringDerivedMetrics) ([]MonitoringPlatformDaily, error) {
|
||||
func (s *MonitoringService) loadPlatformBreakdown(ctx context.Context, tenantID, brandID int64, questionIDs []int64, businessDate time.Time, collectionMode string, platforms []monitoringPlatformMetadata, accessStates map[string]monitoringAccessState, derived monitoringDerivedMetrics) ([]MonitoringPlatformDaily, error) {
|
||||
if questionIDs != nil {
|
||||
return s.loadPlatformBreakdownFromRuns(ctx, tenantID, brandID, questionIDs, businessDate, platforms, accessStates, derived)
|
||||
}
|
||||
|
||||
rows, err := s.monitoringPool.Query(ctx, `
|
||||
SELECT
|
||||
ai_platform_id,
|
||||
@@ -2854,6 +3089,102 @@ func (s *MonitoringService) loadPlatformBreakdown(ctx context.Context, tenantID,
|
||||
return breakdown, nil
|
||||
}
|
||||
|
||||
func (s *MonitoringService) loadPlatformBreakdownFromRuns(ctx context.Context, tenantID, brandID int64, questionIDs []int64, businessDate time.Time, platforms []monitoringPlatformMetadata, accessStates map[string]monitoringAccessState, derived monitoringDerivedMetrics) ([]MonitoringPlatformDaily, error) {
|
||||
if len(questionIDs) == 0 {
|
||||
return emptyPlatformBreakdown(platforms, accessStates), nil
|
||||
}
|
||||
|
||||
dateKey := businessDate.Format("2006-01-02")
|
||||
rows, err := s.monitoringPool.Query(ctx, `
|
||||
SELECT
|
||||
r.ai_platform_id,
|
||||
COUNT(*)::bigint AS actual_sample_count,
|
||||
COUNT(*) FILTER (WHERE p.brand_mentioned IS TRUE)::bigint AS mentioned_count,
|
||||
COUNT(*) FILTER (WHERE p.brand_mention_position = 'top1')::bigint AS top1_mentioned_count,
|
||||
MAX(r.completed_at) AS last_sampled_at
|
||||
FROM question_monitor_runs r
|
||||
LEFT JOIN question_monitor_parse_results p
|
||||
ON p.tenant_id = r.tenant_id AND p.run_id = r.id
|
||||
WHERE r.tenant_id = $1
|
||||
AND r.brand_id = $2
|
||||
AND r.collector_type = $3
|
||||
AND r.business_date = $4::date
|
||||
AND r.status = 'succeeded'
|
||||
AND r.question_id = ANY($5)
|
||||
GROUP BY r.ai_platform_id
|
||||
`, tenantID, brandID, monitoringCollectorType, dateKey, questionIDs)
|
||||
if err != nil {
|
||||
return nil, response.ErrInternal(50041, "query_failed", "failed to load platform breakdown")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type row struct {
|
||||
MentionRate sql.NullFloat64
|
||||
Top1MentionRate sql.NullFloat64
|
||||
ActualSampleCount int64
|
||||
LastSampledAt sql.NullTime
|
||||
}
|
||||
|
||||
items := make(map[string]row)
|
||||
for rows.Next() {
|
||||
var platformID string
|
||||
var mentionedCount int64
|
||||
var top1MentionedCount int64
|
||||
var item row
|
||||
if scanErr := rows.Scan(
|
||||
&platformID,
|
||||
&item.ActualSampleCount,
|
||||
&mentionedCount,
|
||||
&top1MentionedCount,
|
||||
&item.LastSampledAt,
|
||||
); scanErr != nil {
|
||||
return nil, response.ErrInternal(50041, "scan_failed", "failed to parse platform breakdown")
|
||||
}
|
||||
platformID = normalizeMonitoringPlatformID(platformID)
|
||||
if platformID == "" {
|
||||
continue
|
||||
}
|
||||
item.MentionRate = pointerFloat64ToNull(divideAsPointer(mentionedCount, item.ActualSampleCount))
|
||||
item.Top1MentionRate = pointerFloat64ToNull(divideAsPointer(top1MentionedCount, item.ActualSampleCount))
|
||||
items[platformID] = item
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, response.ErrInternal(50041, "scan_failed", "failed to iterate platform breakdown")
|
||||
}
|
||||
|
||||
breakdown := make([]MonitoringPlatformDaily, 0, len(platforms))
|
||||
for _, platform := range platforms {
|
||||
item := items[platform.ID]
|
||||
if platformStats, ok := derived.ByDatePlatform[dateKey][platform.ID]; ok && platformStats.hasSamples() {
|
||||
item.MentionRate = pointerFloat64ToNull(platformStats.mentionRate())
|
||||
item.Top1MentionRate = pointerFloat64ToNull(platformStats.top1MentionRate())
|
||||
}
|
||||
breakdown = append(breakdown, MonitoringPlatformDaily{
|
||||
AIPlatformID: platform.ID,
|
||||
PlatformName: platform.Name,
|
||||
MentionRate: floatPointer(item.MentionRate),
|
||||
Top1MentionRate: floatPointer(item.Top1MentionRate),
|
||||
PlatformSampleStatus: deriveMonitoringPlatformSampleStatus(item.ActualSampleCount, item.ActualSampleCount, accessStates[platform.ID].AccessStatus),
|
||||
ActualSampleCount: item.ActualSampleCount,
|
||||
LastSampledAt: formatNullTime(item.LastSampledAt),
|
||||
})
|
||||
}
|
||||
|
||||
return breakdown, nil
|
||||
}
|
||||
|
||||
func emptyPlatformBreakdown(platforms []monitoringPlatformMetadata, accessStates map[string]monitoringAccessState) []MonitoringPlatformDaily {
|
||||
breakdown := make([]MonitoringPlatformDaily, 0, len(platforms))
|
||||
for _, platform := range platforms {
|
||||
breakdown = append(breakdown, MonitoringPlatformDaily{
|
||||
AIPlatformID: platform.ID,
|
||||
PlatformName: platform.Name,
|
||||
PlatformSampleStatus: derivePlatformSampleStatus("", accessStates[platform.ID]),
|
||||
})
|
||||
}
|
||||
return breakdown
|
||||
}
|
||||
|
||||
func (s *MonitoringService) loadHotQuestions(
|
||||
ctx context.Context,
|
||||
tenantID, brandID int64,
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var questionCombinationFillSchema = []byte(`{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"region": {
|
||||
"type": "array",
|
||||
"maxItems": 4,
|
||||
"items": { "type": "string", "minLength": 1, "maxLength": 16 }
|
||||
},
|
||||
"prefix": {
|
||||
"type": "array",
|
||||
"maxItems": 4,
|
||||
"items": { "type": "string", "minLength": 1, "maxLength": 16 }
|
||||
},
|
||||
"core": {
|
||||
"type": "array",
|
||||
"maxItems": 4,
|
||||
"items": { "type": "string", "minLength": 1, "maxLength": 18 }
|
||||
},
|
||||
"industry": {
|
||||
"type": "array",
|
||||
"maxItems": 4,
|
||||
"items": { "type": "string", "minLength": 1, "maxLength": 18 }
|
||||
},
|
||||
"suffix": {
|
||||
"type": "array",
|
||||
"maxItems": 4,
|
||||
"items": { "type": "string", "minLength": 1, "maxLength": 16 }
|
||||
}
|
||||
},
|
||||
"required": ["region", "prefix", "core", "industry", "suffix"]
|
||||
}`)
|
||||
|
||||
func buildQuestionCombinationFillPrompt(brandCtx *questionBrandContext, regionTerms []string, req QuestionCombinationFillRequest) string {
|
||||
competitors := "无"
|
||||
if len(brandCtx.CompetitorNames) > 0 {
|
||||
encoded, _ := json.Marshal(brandCtx.CompetitorNames)
|
||||
competitors = string(encoded)
|
||||
}
|
||||
hints, _ := json.Marshal(req)
|
||||
return fmt.Sprintf(`你是中国 GEO 搜索问题拓展策略师。请为“拓词工具”的 5 个词列生成高频、核心、可组合的问题词组。
|
||||
|
||||
【品牌信息】
|
||||
- 品牌: %s
|
||||
- 官网: %s
|
||||
- 描述: %s
|
||||
- 竞品: %s
|
||||
|
||||
【地域优先词】
|
||||
%s
|
||||
|
||||
【当前输入,仅作为语义参考,可改写】
|
||||
%s
|
||||
|
||||
【输出要求】
|
||||
1. region 使用地域优先词,不要编造不存在的城市;没有有效地域时可输出全国、本地、华东等泛地域词。
|
||||
2. prefix/core/industry/suffix 各输出 3 到 4 个词,必须短、热、可直接拼成搜索问题。
|
||||
3. core 要贴近品牌最核心的搜索需求;industry 要贴近行业/品类;suffix 偏“哪家好、怎么选、推荐、多少钱”等高转化问法。
|
||||
4. 不要输出完整问题,不要带标点,不要解释,严格按 JSON Schema 输出。`,
|
||||
strings.TrimSpace(brandCtx.BrandName),
|
||||
strings.TrimSpace(nilToString(brandCtx.Website)),
|
||||
strings.TrimSpace(nilToString(brandCtx.Description)),
|
||||
competitors,
|
||||
strings.Join(regionTerms, "、"),
|
||||
string(hints),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestQuestionCombinationRegionTermsFromIPRegion(t *testing.T) {
|
||||
got := questionCombinationRegionTermsFromIPRegion("中国|0|安徽省|合肥市|电信")
|
||||
want := []string{"合肥", "华东"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("region terms = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildQuestionCombinationFillFallback(t *testing.T) {
|
||||
description := "GEO 与 AI 搜索优化平台"
|
||||
got := buildQuestionCombinationFillFallback(&questionBrandContext{
|
||||
BrandName: "GeoRankly",
|
||||
Description: &description,
|
||||
}, []string{"上海", "华东"}, QuestionCombinationFillRequest{})
|
||||
|
||||
if !reflect.DeepEqual(got.Region, []string{"上海", "华东"}) {
|
||||
t.Fatalf("region = %#v", got.Region)
|
||||
}
|
||||
if len(got.Core) < 3 || got.Core[0] != "GEO" {
|
||||
t.Fatalf("core fallback = %#v", got.Core)
|
||||
}
|
||||
if len(got.Prefix) != 4 || len(got.Industry) != 4 || len(got.Suffix) != 4 {
|
||||
t.Fatalf("expected four hot terms for non-region columns, got %#v", got)
|
||||
}
|
||||
}
|
||||
@@ -17,16 +17,20 @@ import (
|
||||
"github.com/geo-platform/tenant-api/internal/shared/auditlog"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/auth"
|
||||
sharedcache "github.com/geo-platform/tenant-api/internal/shared/cache"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/ipregion"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/llm"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/middleware"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/response"
|
||||
)
|
||||
|
||||
const (
|
||||
questionCombinationMaxItems = 500
|
||||
questionAIDistillMaxItems = 20
|
||||
questionDistillCacheTTL = 5 * time.Minute
|
||||
questionDistillTimeout = 30 * time.Second
|
||||
questionCombinationMaxItems = 500
|
||||
questionCombinationFillMax = 4
|
||||
questionAIDistillMaxItems = 20
|
||||
questionCombinationFillTTL = 10 * time.Minute
|
||||
questionCombinationFillTimeout = 15 * time.Second
|
||||
questionDistillCacheTTL = 5 * time.Minute
|
||||
questionDistillTimeout = 30 * time.Second
|
||||
)
|
||||
|
||||
type QuestionExpansionService struct {
|
||||
@@ -34,6 +38,7 @@ type QuestionExpansionService struct {
|
||||
llm llm.Client
|
||||
brand *BrandService
|
||||
cache sharedcache.Cache
|
||||
ipGeo *ipregion.Resolver
|
||||
}
|
||||
|
||||
func NewQuestionExpansionService(pool *pgxpool.Pool, llmClient llm.Client, brand *BrandService) *QuestionExpansionService {
|
||||
@@ -49,6 +54,11 @@ func (s *QuestionExpansionService) WithCache(c sharedcache.Cache) *QuestionExpan
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *QuestionExpansionService) WithIPRegionResolver(resolver *ipregion.Resolver) *QuestionExpansionService {
|
||||
s.ipGeo = resolver
|
||||
return s
|
||||
}
|
||||
|
||||
type QuestionCombinationRequest struct {
|
||||
Region []string `json:"region"`
|
||||
Prefix []string `json:"prefix"`
|
||||
@@ -62,6 +72,24 @@ type QuestionDistillRequest struct {
|
||||
SeedTopic string `json:"seed_topic" binding:"required,min=2"`
|
||||
}
|
||||
|
||||
type QuestionCombinationFillRequest struct {
|
||||
Region []string `json:"region"`
|
||||
Prefix []string `json:"prefix"`
|
||||
Core []string `json:"core"`
|
||||
Industry []string `json:"industry"`
|
||||
Suffix []string `json:"suffix"`
|
||||
}
|
||||
|
||||
type QuestionCombinationFillResult struct {
|
||||
Region []string `json:"region"`
|
||||
Prefix []string `json:"prefix"`
|
||||
Core []string `json:"core"`
|
||||
Industry []string `json:"industry"`
|
||||
Suffix []string `json:"suffix"`
|
||||
AIPointsCharged int `json:"ai_points_charged,omitempty"`
|
||||
CacheHit bool `json:"cache_hit,omitempty"`
|
||||
}
|
||||
|
||||
type QuestionCandidate struct {
|
||||
Text string `json:"text"`
|
||||
Layer string `json:"layer"`
|
||||
@@ -103,6 +131,8 @@ type questionBrandContext struct {
|
||||
TenantID int64
|
||||
BrandID int64
|
||||
BrandName string
|
||||
Website *string
|
||||
Description *string
|
||||
PlanCode string
|
||||
CompetitorNames []string
|
||||
}
|
||||
@@ -115,6 +145,14 @@ type aiDistillPayload struct {
|
||||
} `json:"candidates"`
|
||||
}
|
||||
|
||||
type aiCombinationFillPayload struct {
|
||||
Region []string `json:"region"`
|
||||
Prefix []string `json:"prefix"`
|
||||
Core []string `json:"core"`
|
||||
Industry []string `json:"industry"`
|
||||
Suffix []string `json:"suffix"`
|
||||
}
|
||||
|
||||
func (s *QuestionExpansionService) GenerateByCombination(ctx context.Context, brandID int64, req QuestionCombinationRequest) (*QuestionCandidateResult, error) {
|
||||
actor := auth.MustActor(ctx)
|
||||
brandCtx, err := s.loadQuestionBrandContext(ctx, actor.TenantID, brandID)
|
||||
@@ -250,7 +288,7 @@ func (s *QuestionExpansionService) GenerateByAIDistill(ctx context.Context, bran
|
||||
}, nil)
|
||||
if err != nil {
|
||||
_ = RefundAIPoints(context.Background(), s.pool, s.cache, actor.TenantID, actor.UserID, *reservation, err.Error())
|
||||
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
|
||||
if errors.Is(err, context.DeadlineExceeded) || errors.Is(ctx.Err(), context.DeadlineExceeded) {
|
||||
return nil, response.ErrServiceUnavailable(50312, "llm_timeout", "AI generation timed out")
|
||||
}
|
||||
return nil, response.ErrServiceUnavailable(50311, "llm_unavailable", err.Error())
|
||||
@@ -296,6 +334,99 @@ func (s *QuestionExpansionService) GenerateByAIDistill(ctx context.Context, bran
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *QuestionExpansionService) FillQuestionCombination(ctx context.Context, brandID int64, clientIP string, req QuestionCombinationFillRequest) (*QuestionCombinationFillResult, error) {
|
||||
actor := auth.MustActor(ctx)
|
||||
brandCtx, err := s.loadQuestionBrandContext(ctx, actor.TenantID, brandID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
regionTerms := questionCombinationRegionTermsFromIPRegion("")
|
||||
if s.ipGeo != nil {
|
||||
regionTerms = questionCombinationRegionTermsFromIPRegion(s.ipGeo.Lookup(clientIP))
|
||||
}
|
||||
fallback := buildQuestionCombinationFillFallback(brandCtx, regionTerms, req)
|
||||
|
||||
cacheKey := questionCombinationFillCacheKey(actor.TenantID, brandID, brandCtx, regionTerms, req)
|
||||
if s.cache != nil {
|
||||
if raw, cacheErr := s.cache.Get(ctx, cacheKey); cacheErr == nil && len(raw) > 0 {
|
||||
var cached QuestionCombinationFillResult
|
||||
if json.Unmarshal(raw, &cached) == nil {
|
||||
cached.CacheHit = true
|
||||
cached.AIPointsCharged = 0
|
||||
return &cached, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if s.llm == nil || s.llm.Validate() != nil {
|
||||
return &fallback, nil
|
||||
}
|
||||
|
||||
resourceType := "question_combination_fill"
|
||||
resourceUID := cacheKey
|
||||
meteredText, _ := json.Marshal(map[string]any{
|
||||
"brand": brandCtx.BrandName,
|
||||
"description": nilToString(brandCtx.Description),
|
||||
"region": fallback.Region,
|
||||
"request": req,
|
||||
})
|
||||
reservation, err := ReserveAIPoints(ctx, s.pool, s.cache, AIPointReserveInput{
|
||||
TenantID: actor.TenantID,
|
||||
OperatorID: actor.UserID,
|
||||
UsageType: AIUsageTypeQuestionCombinationFill,
|
||||
ResourceType: &resourceType,
|
||||
ResourceUID: &resourceUID,
|
||||
MeteredText: string(meteredText),
|
||||
FixedPoints: 1,
|
||||
Metadata: map[string]any{
|
||||
"brand_id": brandID,
|
||||
"prompt_version": "question_combination_fill_v1",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result, err := s.llm.Generate(ctx, llm.GenerateRequest{
|
||||
Prompt: buildQuestionCombinationFillPrompt(brandCtx, fallback.Region, req),
|
||||
Timeout: questionCombinationFillTimeout,
|
||||
MaxOutputTokens: 900,
|
||||
ResponseFormat: &llm.ResponseFormat{
|
||||
Type: llm.ResponseFormatTypeJSONSchema,
|
||||
Name: "question_combination_fill",
|
||||
Description: "Short word columns for question expansion combination tool.",
|
||||
SchemaJSON: questionCombinationFillSchema,
|
||||
Strict: true,
|
||||
},
|
||||
}, nil)
|
||||
if err != nil {
|
||||
_ = RefundAIPoints(context.Background(), s.pool, s.cache, actor.TenantID, actor.UserID, *reservation, err.Error())
|
||||
return &fallback, nil
|
||||
}
|
||||
|
||||
var payload aiCombinationFillPayload
|
||||
if err := json.Unmarshal([]byte(result.Content), &payload); err != nil {
|
||||
_ = RefundAIPoints(context.Background(), s.pool, s.cache, actor.TenantID, actor.UserID, *reservation, err.Error())
|
||||
return &fallback, nil
|
||||
}
|
||||
|
||||
filled := sanitizeQuestionCombinationFillResult(payload, fallback)
|
||||
if err := CompleteAIPoints(context.Background(), s.pool, actor.TenantID, *reservation, result.Model); err != nil {
|
||||
return nil, response.ErrInternal(50127, "ai_points_commit_failed", "failed to confirm ai points")
|
||||
}
|
||||
filled.AIPointsCharged = reservation.Points
|
||||
if s.cache != nil {
|
||||
cached := filled
|
||||
cached.AIPointsCharged = 0
|
||||
cached.CacheHit = false
|
||||
if raw, err := json.Marshal(cached); err == nil {
|
||||
_ = s.cache.Set(context.Background(), cacheKey, raw, questionCombinationFillTTL)
|
||||
}
|
||||
}
|
||||
return &filled, nil
|
||||
}
|
||||
|
||||
func (s *QuestionExpansionService) ClassifyMetadata(ctx context.Context, brandID int64, texts []string) ([]ClassifiedQuestion, error) {
|
||||
actor := auth.MustActor(ctx)
|
||||
brandCtx, err := s.loadQuestionBrandContext(ctx, actor.TenantID, brandID)
|
||||
@@ -485,11 +616,13 @@ func (s *QuestionExpansionService) MaterializeQuestions(ctx context.Context, bra
|
||||
|
||||
func (s *QuestionExpansionService) loadQuestionBrandContext(ctx context.Context, tenantID, brandID int64) (*questionBrandContext, error) {
|
||||
var brandName string
|
||||
var website *string
|
||||
var description *string
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT name
|
||||
SELECT name, website, description
|
||||
FROM brands
|
||||
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
|
||||
`, brandID, tenantID).Scan(&brandName)
|
||||
`, brandID, tenantID).Scan(&brandName, &website, &description)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, response.ErrNotFound(40420, "brand_not_found", "brand not found")
|
||||
@@ -525,6 +658,8 @@ func (s *QuestionExpansionService) loadQuestionBrandContext(ctx context.Context,
|
||||
TenantID: tenantID,
|
||||
BrandID: brandID,
|
||||
BrandName: brandName,
|
||||
Website: website,
|
||||
Description: description,
|
||||
PlanCode: plan.PlanCode,
|
||||
CompetitorNames: competitors,
|
||||
}, nil
|
||||
@@ -642,6 +777,317 @@ func optionalQuestionParts(values []string) []string {
|
||||
return parts
|
||||
}
|
||||
|
||||
func buildQuestionCombinationFillFallback(brandCtx *questionBrandContext, regionTerms []string, req QuestionCombinationFillRequest) QuestionCombinationFillResult {
|
||||
brandName := strings.TrimSpace(brandCtx.BrandName)
|
||||
description := strings.TrimSpace(nilToString(brandCtx.Description))
|
||||
category := inferQuestionCombinationCategory(brandName, description)
|
||||
|
||||
region := compactQuestionCombinationTerms(regionTerms, questionCombinationFillMax)
|
||||
if len(region) == 0 {
|
||||
region = compactQuestionCombinationTerms(req.Region, questionCombinationFillMax)
|
||||
}
|
||||
if len(region) == 0 {
|
||||
region = []string{"全国", "本地", "华东"}
|
||||
}
|
||||
|
||||
prefix := compactQuestionCombinationTerms(req.Prefix, questionCombinationFillMax)
|
||||
if len(prefix) == 0 {
|
||||
prefix = []string{"好用的", "专业的", "靠谱的", "热门的"}
|
||||
}
|
||||
|
||||
core := compactQuestionCombinationTerms(req.Core, questionCombinationFillMax)
|
||||
if len(core) == 0 {
|
||||
core = questionCombinationCoreFallback(brandName, description)
|
||||
}
|
||||
|
||||
industry := compactQuestionCombinationTerms(req.Industry, questionCombinationFillMax)
|
||||
if len(industry) == 0 {
|
||||
industry = []string{category, "平台", "工具", "服务商"}
|
||||
}
|
||||
|
||||
suffix := compactQuestionCombinationTerms(req.Suffix, questionCombinationFillMax)
|
||||
if len(suffix) == 0 {
|
||||
suffix = []string{"哪家好", "怎么选", "推荐", "多少钱"}
|
||||
}
|
||||
|
||||
return QuestionCombinationFillResult{
|
||||
Region: compactQuestionCombinationTerms(region, questionCombinationFillMax),
|
||||
Prefix: compactQuestionCombinationTerms(prefix, questionCombinationFillMax),
|
||||
Core: compactQuestionCombinationTerms(core, questionCombinationFillMax),
|
||||
Industry: compactQuestionCombinationTerms(industry, questionCombinationFillMax),
|
||||
Suffix: compactQuestionCombinationTerms(suffix, questionCombinationFillMax),
|
||||
}
|
||||
}
|
||||
|
||||
func sanitizeQuestionCombinationFillResult(payload aiCombinationFillPayload, fallback QuestionCombinationFillResult) QuestionCombinationFillResult {
|
||||
return QuestionCombinationFillResult{
|
||||
Region: fillQuestionCombinationTerms(payload.Region, fallback.Region),
|
||||
Prefix: fillQuestionCombinationTerms(payload.Prefix, fallback.Prefix),
|
||||
Core: fillQuestionCombinationTerms(payload.Core, fallback.Core),
|
||||
Industry: fillQuestionCombinationTerms(payload.Industry, fallback.Industry),
|
||||
Suffix: fillQuestionCombinationTerms(payload.Suffix, fallback.Suffix),
|
||||
}
|
||||
}
|
||||
|
||||
func fillQuestionCombinationTerms(values, fallback []string) []string {
|
||||
result := compactQuestionCombinationTerms(values, questionCombinationFillMax)
|
||||
if len(result) >= 3 {
|
||||
return result
|
||||
}
|
||||
for _, item := range fallback {
|
||||
result = append(result, item)
|
||||
result = compactQuestionCombinationTerms(result, questionCombinationFillMax)
|
||||
if len(result) >= 3 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func compactQuestionCombinationTerms(values []string, limit int) []string {
|
||||
if limit <= 0 {
|
||||
limit = questionCombinationFillMax
|
||||
}
|
||||
result := make([]string, 0, minInt(len(values), limit))
|
||||
seen := map[string]struct{}{}
|
||||
for _, value := range values {
|
||||
term := normalizeQuestionCombinationTerm(value)
|
||||
if term == "" {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(term)
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
result = append(result, term)
|
||||
if len(result) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func normalizeQuestionCombinationTerm(value string) string {
|
||||
term := strings.TrimSpace(value)
|
||||
term = strings.Trim(term, ",,。!?!?;;、 \t\r\n")
|
||||
term = strings.ReplaceAll(term, " ", "")
|
||||
term = strings.ReplaceAll(term, "\u3000", "")
|
||||
if term == "" || term == "0" || strings.EqualFold(term, "null") {
|
||||
return ""
|
||||
}
|
||||
if utf8.RuneCountInString(term) > 18 {
|
||||
runes := []rune(term)
|
||||
term = string(runes[:18])
|
||||
}
|
||||
return term
|
||||
}
|
||||
|
||||
func questionCombinationRegionTermsFromIPRegion(region string) []string {
|
||||
parts := strings.Split(strings.TrimSpace(region), "|")
|
||||
if len(parts) < 4 {
|
||||
return nil
|
||||
}
|
||||
country := normalizeChineseLocationTerm(parts[0])
|
||||
province := normalizeChineseLocationTerm(parts[2])
|
||||
city := normalizeChineseLocationTerm(parts[3])
|
||||
if country != "" && country != "中国" {
|
||||
return []string{country}
|
||||
}
|
||||
capital := provinceCapitalTerm(province)
|
||||
area := chineseMacroRegion(province)
|
||||
return compactQuestionCombinationTerms([]string{city, capital, area}, 3)
|
||||
}
|
||||
|
||||
func normalizeChineseLocationTerm(value string) string {
|
||||
term := normalizeQuestionCombinationTerm(value)
|
||||
if term == "" || term == "本机" || term == "内网IP" {
|
||||
return ""
|
||||
}
|
||||
replacer := strings.NewReplacer(
|
||||
"特别行政区", "",
|
||||
"维吾尔自治区", "",
|
||||
"壮族自治区", "",
|
||||
"回族自治区", "",
|
||||
"自治区", "",
|
||||
"省", "",
|
||||
"市", "",
|
||||
"地区", "",
|
||||
"盟", "",
|
||||
)
|
||||
return replacer.Replace(term)
|
||||
}
|
||||
|
||||
func provinceCapitalTerm(province string) string {
|
||||
switch province {
|
||||
case "北京":
|
||||
return "北京"
|
||||
case "上海":
|
||||
return "上海"
|
||||
case "天津":
|
||||
return "天津"
|
||||
case "重庆":
|
||||
return "重庆"
|
||||
case "河北":
|
||||
return "石家庄"
|
||||
case "山西":
|
||||
return "太原"
|
||||
case "内蒙古":
|
||||
return "呼和浩特"
|
||||
case "辽宁":
|
||||
return "沈阳"
|
||||
case "吉林":
|
||||
return "长春"
|
||||
case "黑龙江":
|
||||
return "哈尔滨"
|
||||
case "江苏":
|
||||
return "南京"
|
||||
case "浙江":
|
||||
return "杭州"
|
||||
case "安徽":
|
||||
return "合肥"
|
||||
case "福建":
|
||||
return "福州"
|
||||
case "江西":
|
||||
return "南昌"
|
||||
case "山东":
|
||||
return "济南"
|
||||
case "河南":
|
||||
return "郑州"
|
||||
case "湖北":
|
||||
return "武汉"
|
||||
case "湖南":
|
||||
return "长沙"
|
||||
case "广东":
|
||||
return "广州"
|
||||
case "广西":
|
||||
return "南宁"
|
||||
case "海南":
|
||||
return "海口"
|
||||
case "四川":
|
||||
return "成都"
|
||||
case "贵州":
|
||||
return "贵阳"
|
||||
case "云南":
|
||||
return "昆明"
|
||||
case "西藏":
|
||||
return "拉萨"
|
||||
case "陕西":
|
||||
return "西安"
|
||||
case "甘肃":
|
||||
return "兰州"
|
||||
case "青海":
|
||||
return "西宁"
|
||||
case "宁夏":
|
||||
return "银川"
|
||||
case "新疆":
|
||||
return "乌鲁木齐"
|
||||
case "香港":
|
||||
return "香港"
|
||||
case "澳门":
|
||||
return "澳门"
|
||||
case "台湾":
|
||||
return "台北"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func chineseMacroRegion(province string) string {
|
||||
switch province {
|
||||
case "上海", "江苏", "浙江", "安徽", "福建", "江西", "山东", "台湾":
|
||||
return "华东"
|
||||
case "北京", "天津", "河北", "山西", "内蒙古":
|
||||
return "华北"
|
||||
case "河南", "湖北", "湖南":
|
||||
return "华中"
|
||||
case "广东", "广西", "海南", "香港", "澳门":
|
||||
return "华南"
|
||||
case "重庆", "四川", "贵州", "云南", "西藏":
|
||||
return "西南"
|
||||
case "陕西", "甘肃", "青海", "宁夏", "新疆":
|
||||
return "西北"
|
||||
case "辽宁", "吉林", "黑龙江":
|
||||
return "东北"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func questionCombinationCoreFallback(brandName, description string) []string {
|
||||
text := strings.ToLower(strings.TrimSpace(brandName + " " + description))
|
||||
if strings.Contains(text, "geo") || strings.Contains(text, "ai") || strings.Contains(text, "搜索") || strings.Contains(text, "排名") {
|
||||
return []string{"GEO", "AI搜索优化", "AI搜索排名", "品牌可见度"}
|
||||
}
|
||||
if strings.Contains(text, "教育") || strings.Contains(text, "培训") || strings.Contains(text, "课程") {
|
||||
return []string{"课程", "培训", "学习", "机构"}
|
||||
}
|
||||
if strings.Contains(text, "医疗") || strings.Contains(text, "健康") || strings.Contains(text, "诊所") {
|
||||
return []string{"医疗服务", "健康管理", "医生", "诊疗"}
|
||||
}
|
||||
if strings.Contains(text, "家居") || strings.Contains(text, "装修") || strings.Contains(text, "建材") {
|
||||
return []string{"装修", "家居", "建材", "设计"}
|
||||
}
|
||||
return []string{strings.TrimSpace(brandName), "解决方案", "服务", "平台"}
|
||||
}
|
||||
|
||||
func inferQuestionCombinationCategory(brandName, description string) string {
|
||||
text := strings.ToLower(strings.TrimSpace(brandName + " " + description))
|
||||
switch {
|
||||
case strings.Contains(text, "geo") || strings.Contains(text, "seo") || strings.Contains(text, "搜索"):
|
||||
return "营销"
|
||||
case strings.Contains(text, "教育") || strings.Contains(text, "培训") || strings.Contains(text, "课程"):
|
||||
return "教育"
|
||||
case strings.Contains(text, "医疗") || strings.Contains(text, "健康"):
|
||||
return "健康"
|
||||
case strings.Contains(text, "家居") || strings.Contains(text, "装修") || strings.Contains(text, "建材"):
|
||||
return "家居"
|
||||
case strings.Contains(text, "餐饮") || strings.Contains(text, "美食"):
|
||||
return "餐饮"
|
||||
case strings.Contains(text, "旅游") || strings.Contains(text, "酒店"):
|
||||
return "旅游"
|
||||
default:
|
||||
return "服务"
|
||||
}
|
||||
}
|
||||
|
||||
func questionCombinationFillCacheKey(tenantID, brandID int64, brandCtx *questionBrandContext, regionTerms []string, req QuestionCombinationFillRequest) string {
|
||||
raw, _ := json.Marshal(struct {
|
||||
TenantID int64 `json:"tenant_id"`
|
||||
BrandID int64 `json:"brand_id"`
|
||||
SchemaVersion string `json:"schema_version"`
|
||||
BrandName string `json:"brand_name"`
|
||||
Description string `json:"description"`
|
||||
Competitors []string `json:"competitors"`
|
||||
Region []string `json:"region"`
|
||||
Request QuestionCombinationFillRequest `json:"request"`
|
||||
}{
|
||||
TenantID: tenantID,
|
||||
BrandID: brandID,
|
||||
SchemaVersion: "question_combination_fill_v1",
|
||||
BrandName: strings.TrimSpace(brandCtx.BrandName),
|
||||
Description: strings.TrimSpace(nilToString(brandCtx.Description)),
|
||||
Competitors: brandCtx.CompetitorNames,
|
||||
Region: regionTerms,
|
||||
Request: QuestionCombinationFillRequest{
|
||||
Region: compactQuestionCombinationTerms(req.Region, questionCombinationFillMax),
|
||||
Prefix: compactQuestionCombinationTerms(req.Prefix, questionCombinationFillMax),
|
||||
Core: compactQuestionCombinationTerms(req.Core, questionCombinationFillMax),
|
||||
Industry: compactQuestionCombinationTerms(req.Industry, questionCombinationFillMax),
|
||||
Suffix: compactQuestionCombinationTerms(req.Suffix, questionCombinationFillMax),
|
||||
},
|
||||
})
|
||||
sum := sha1.Sum(raw)
|
||||
return "question_combination_fill:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func nilToString(value *string) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func nonEmptyParts(values ...string) []string {
|
||||
result := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
|
||||
@@ -30,13 +30,15 @@ type AssistCompetitor struct {
|
||||
}
|
||||
|
||||
type AnalyzeTaskRequest struct {
|
||||
Locale string `json:"locale"`
|
||||
BrandID *int64 `json:"brand_id,omitempty"`
|
||||
BrandName string `json:"brand_name"`
|
||||
Website string `json:"website,omitempty"`
|
||||
InputParams map[string]interface{} `json:"input_params,omitempty"`
|
||||
ExistingKeywords []string `json:"existing_keywords,omitempty"`
|
||||
ExistingCompetitors []AssistCompetitor `json:"existing_competitors,omitempty"`
|
||||
Locale string `json:"locale"`
|
||||
BrandID *int64 `json:"brand_id,omitempty"`
|
||||
BrandName string `json:"brand_name"`
|
||||
Website string `json:"website,omitempty"`
|
||||
BrandQuestion string `json:"brand_question,omitempty"`
|
||||
SupplementalQuestions []string `json:"supplemental_questions,omitempty"`
|
||||
InputParams map[string]interface{} `json:"input_params,omitempty"`
|
||||
ExistingKeywords []string `json:"existing_keywords,omitempty"`
|
||||
ExistingCompetitors []AssistCompetitor `json:"existing_competitors,omitempty"`
|
||||
}
|
||||
|
||||
type AnalyzeTaskResult struct {
|
||||
@@ -46,28 +48,32 @@ type AnalyzeTaskResult struct {
|
||||
}
|
||||
|
||||
type TitleTaskRequest struct {
|
||||
Locale string `json:"locale"`
|
||||
BrandID *int64 `json:"brand_id,omitempty"`
|
||||
BrandName string `json:"brand_name"`
|
||||
Website string `json:"website,omitempty"`
|
||||
BrandSummary string `json:"brand_summary,omitempty"`
|
||||
InputParams map[string]interface{} `json:"input_params,omitempty"`
|
||||
Keywords []string `json:"keywords,omitempty"`
|
||||
Competitors []AssistCompetitor `json:"competitors,omitempty"`
|
||||
Locale string `json:"locale"`
|
||||
BrandID *int64 `json:"brand_id,omitempty"`
|
||||
BrandName string `json:"brand_name"`
|
||||
Website string `json:"website,omitempty"`
|
||||
BrandSummary string `json:"brand_summary,omitempty"`
|
||||
BrandQuestion string `json:"brand_question,omitempty"`
|
||||
SupplementalQuestions []string `json:"supplemental_questions,omitempty"`
|
||||
InputParams map[string]interface{} `json:"input_params,omitempty"`
|
||||
Keywords []string `json:"keywords,omitempty"`
|
||||
Competitors []AssistCompetitor `json:"competitors,omitempty"`
|
||||
}
|
||||
|
||||
type OutlineTaskRequest struct {
|
||||
Locale string `json:"locale"`
|
||||
BrandID *int64 `json:"brand_id,omitempty"`
|
||||
BrandName string `json:"brand_name"`
|
||||
Website string `json:"website,omitempty"`
|
||||
BrandSummary string `json:"brand_summary,omitempty"`
|
||||
Title string `json:"title"`
|
||||
InputParams map[string]interface{} `json:"input_params,omitempty"`
|
||||
Keywords []string `json:"keywords,omitempty"`
|
||||
Competitors []AssistCompetitor `json:"competitors,omitempty"`
|
||||
OutlineSections []string `json:"outline_sections,omitempty"`
|
||||
KeyPoints string `json:"key_points,omitempty"`
|
||||
Locale string `json:"locale"`
|
||||
BrandID *int64 `json:"brand_id,omitempty"`
|
||||
BrandName string `json:"brand_name"`
|
||||
Website string `json:"website,omitempty"`
|
||||
BrandSummary string `json:"brand_summary,omitempty"`
|
||||
Title string `json:"title"`
|
||||
BrandQuestion string `json:"brand_question,omitempty"`
|
||||
SupplementalQuestions []string `json:"supplemental_questions,omitempty"`
|
||||
InputParams map[string]interface{} `json:"input_params,omitempty"`
|
||||
Keywords []string `json:"keywords,omitempty"`
|
||||
Competitors []AssistCompetitor `json:"competitors,omitempty"`
|
||||
OutlineSections []string `json:"outline_sections,omitempty"`
|
||||
KeyPoints string `json:"key_points,omitempty"`
|
||||
}
|
||||
|
||||
type OutlineNode struct {
|
||||
@@ -632,6 +638,8 @@ func normalizeAnalyzeTaskRequest(req AnalyzeTaskRequest) AnalyzeTaskRequest {
|
||||
}
|
||||
req.BrandName = strings.TrimSpace(req.BrandName)
|
||||
req.Website = strings.TrimSpace(req.Website)
|
||||
req.BrandQuestion = strings.TrimSpace(req.BrandQuestion)
|
||||
req.SupplementalQuestions = []string{}
|
||||
req.ExistingKeywords = normalizeStringList(req.ExistingKeywords, 8)
|
||||
req.ExistingCompetitors = normalizeAssistCompetitors(req.ExistingCompetitors, 8)
|
||||
req.InputParams = normalizeAssistInputParams(req.InputParams)
|
||||
@@ -646,7 +654,15 @@ func normalizeTitleTaskRequest(req TitleTaskRequest) TitleTaskRequest {
|
||||
req.BrandName = strings.TrimSpace(req.BrandName)
|
||||
req.Website = strings.TrimSpace(req.Website)
|
||||
req.BrandSummary = strings.TrimSpace(req.BrandSummary)
|
||||
req.BrandQuestion = strings.TrimSpace(req.BrandQuestion)
|
||||
req.SupplementalQuestions = normalizeStringList(req.SupplementalQuestions, 3)
|
||||
req.Keywords = normalizeStringList(req.Keywords, 12)
|
||||
if req.BrandQuestion != "" {
|
||||
req.SupplementalQuestions = []string{}
|
||||
req.Keywords = []string{req.BrandQuestion}
|
||||
} else if len(req.Keywords) == 0 {
|
||||
req.Keywords = buildQuestionKeywordContext(req.BrandQuestion, req.SupplementalQuestions, 12)
|
||||
}
|
||||
req.Competitors = normalizeAssistCompetitors(req.Competitors, 8)
|
||||
req.InputParams = normalizeAssistInputParams(req.InputParams)
|
||||
return req
|
||||
@@ -662,13 +678,22 @@ func normalizeOutlineTaskRequest(req OutlineTaskRequest) OutlineTaskRequest {
|
||||
req.BrandSummary = strings.TrimSpace(req.BrandSummary)
|
||||
req.Title = strings.TrimSpace(req.Title)
|
||||
req.KeyPoints = strings.TrimSpace(req.KeyPoints)
|
||||
req.BrandQuestion = strings.TrimSpace(req.BrandQuestion)
|
||||
req.SupplementalQuestions = normalizeStringList(req.SupplementalQuestions, 3)
|
||||
req.Keywords = normalizeStringList(req.Keywords, 12)
|
||||
if len(req.Keywords) == 0 {
|
||||
req.Keywords = buildQuestionKeywordContext(req.BrandQuestion, req.SupplementalQuestions, 12)
|
||||
}
|
||||
req.Competitors = normalizeAssistCompetitors(req.Competitors, 8)
|
||||
req.OutlineSections = normalizeStringList(req.OutlineSections, 24)
|
||||
req.InputParams = normalizeAssistInputParams(req.InputParams)
|
||||
return req
|
||||
}
|
||||
|
||||
func buildQuestionKeywordContext(primaryQuestion string, supplementalQuestions []string, max int) []string {
|
||||
return normalizeStringList(append([]string{primaryQuestion}, supplementalQuestions...), max)
|
||||
}
|
||||
|
||||
func normalizeAssistInputParams(params map[string]interface{}) map[string]interface{} {
|
||||
if params == nil {
|
||||
return map[string]interface{}{}
|
||||
@@ -682,7 +707,10 @@ func normalizeAssistInputParams(params map[string]interface{}) map[string]interf
|
||||
}
|
||||
|
||||
func hasAnalyzeContext(req AnalyzeTaskRequest) bool {
|
||||
if req.BrandName != "" || req.Website != "" {
|
||||
if req.BrandName != "" || req.Website != "" || req.BrandQuestion != "" {
|
||||
return true
|
||||
}
|
||||
if len(req.SupplementalQuestions) > 0 {
|
||||
return true
|
||||
}
|
||||
for _, value := range req.InputParams {
|
||||
@@ -707,7 +735,10 @@ func hasAnalyzeContext(req AnalyzeTaskRequest) bool {
|
||||
}
|
||||
|
||||
func hasTitleContext(req TitleTaskRequest) bool {
|
||||
if req.BrandName != "" || req.Website != "" || req.BrandSummary != "" {
|
||||
if req.BrandName != "" || req.Website != "" || req.BrandSummary != "" || req.BrandQuestion != "" {
|
||||
return true
|
||||
}
|
||||
if len(req.SupplementalQuestions) > 0 {
|
||||
return true
|
||||
}
|
||||
if len(req.Keywords) > 0 || len(req.Competitors) > 0 {
|
||||
@@ -738,7 +769,10 @@ func hasOutlineContext(req OutlineTaskRequest) bool {
|
||||
if req.Title == "" || len(req.OutlineSections) == 0 {
|
||||
return false
|
||||
}
|
||||
if req.BrandName != "" || req.Website != "" || req.BrandSummary != "" || req.KeyPoints != "" {
|
||||
if req.BrandName != "" || req.Website != "" || req.BrandSummary != "" || req.KeyPoints != "" || req.BrandQuestion != "" {
|
||||
return true
|
||||
}
|
||||
if len(req.SupplementalQuestions) > 0 {
|
||||
return true
|
||||
}
|
||||
if len(req.Keywords) > 0 || len(req.Competitors) > 0 {
|
||||
@@ -835,13 +869,18 @@ func buildOutlineAIPointMeteredText(req OutlineTaskRequest) string {
|
||||
}
|
||||
|
||||
func analyzePromptParams(templateKey, templateName string, req AnalyzeTaskRequest) map[string]interface{} {
|
||||
keywords := buildQuestionKeywordContext(req.BrandQuestion, nil, 12)
|
||||
return map[string]interface{}{
|
||||
"template_key": templateKey,
|
||||
"template_name": templateName,
|
||||
"locale": req.Locale,
|
||||
"brand_name": req.BrandName,
|
||||
"official_website": req.Website,
|
||||
"brand_question": req.BrandQuestion,
|
||||
"primary_question": req.BrandQuestion,
|
||||
"input_params": req.InputParams,
|
||||
"keywords": keywords,
|
||||
"primary_keyword": req.BrandQuestion,
|
||||
"existing_keywords": req.ExistingKeywords,
|
||||
"existing_competitors": req.ExistingCompetitors,
|
||||
}
|
||||
@@ -855,6 +894,8 @@ func titlePromptParams(templateKey, templateName string, req TitleTaskRequest) m
|
||||
"brand_name": req.BrandName,
|
||||
"official_website": req.Website,
|
||||
"brand_summary": req.BrandSummary,
|
||||
"brand_question": req.BrandQuestion,
|
||||
"primary_question": req.BrandQuestion,
|
||||
"input_params": req.InputParams,
|
||||
"keywords": req.Keywords,
|
||||
"competitors": req.Competitors,
|
||||
@@ -863,7 +904,10 @@ func titlePromptParams(templateKey, templateName string, req TitleTaskRequest) m
|
||||
}
|
||||
mergePromptParams(params, req.InputParams)
|
||||
|
||||
if len(req.Keywords) > 0 {
|
||||
if req.BrandQuestion != "" {
|
||||
params["primary_keyword"] = req.BrandQuestion
|
||||
params["keyword_count"] = 1
|
||||
} else if len(req.Keywords) > 0 {
|
||||
params["primary_keyword"] = req.Keywords[0]
|
||||
params["keyword_count"] = len(req.Keywords)
|
||||
}
|
||||
@@ -892,24 +936,34 @@ func titlePromptParams(templateKey, templateName string, req TitleTaskRequest) m
|
||||
|
||||
func outlinePromptParams(templateKey, templateName string, req OutlineTaskRequest) map[string]interface{} {
|
||||
params := map[string]interface{}{
|
||||
"template_key": templateKey,
|
||||
"template_name": templateName,
|
||||
"locale": req.Locale,
|
||||
"title": req.Title,
|
||||
"brand_name": req.BrandName,
|
||||
"official_website": req.Website,
|
||||
"brand_summary": req.BrandSummary,
|
||||
"input_params": req.InputParams,
|
||||
"keywords": req.Keywords,
|
||||
"competitors": req.Competitors,
|
||||
"competitor_count": len(req.Competitors),
|
||||
"outline_sections": req.OutlineSections,
|
||||
"key_points": req.KeyPoints,
|
||||
"current_year": time.Now().Year(),
|
||||
"template_key": templateKey,
|
||||
"template_name": templateName,
|
||||
"locale": req.Locale,
|
||||
"title": req.Title,
|
||||
"brand_name": req.BrandName,
|
||||
"official_website": req.Website,
|
||||
"brand_summary": req.BrandSummary,
|
||||
"brand_question": req.BrandQuestion,
|
||||
"primary_question": req.BrandQuestion,
|
||||
"supplemental_questions": req.SupplementalQuestions,
|
||||
"input_params": req.InputParams,
|
||||
"keywords": req.Keywords,
|
||||
"competitors": req.Competitors,
|
||||
"competitor_count": len(req.Competitors),
|
||||
"outline_sections": req.OutlineSections,
|
||||
"key_points": req.KeyPoints,
|
||||
"current_year": time.Now().Year(),
|
||||
}
|
||||
mergePromptParams(params, req.InputParams)
|
||||
|
||||
if len(req.Keywords) > 0 {
|
||||
if req.BrandQuestion != "" {
|
||||
params["primary_keyword"] = req.BrandQuestion
|
||||
params["keyword_count"] = len(buildQuestionKeywordContext(
|
||||
req.BrandQuestion,
|
||||
req.SupplementalQuestions,
|
||||
12,
|
||||
))
|
||||
} else if len(req.Keywords) > 0 {
|
||||
params["primary_keyword"] = req.Keywords[0]
|
||||
params["keyword_count"] = len(req.Keywords)
|
||||
}
|
||||
|
||||
@@ -67,7 +67,9 @@ func buildGenerationKnowledgeQuery(params map[string]interface{}) string {
|
||||
appendValue(stringValue(params["product_name"]))
|
||||
appendValue(stringValue(params["subject"]))
|
||||
appendValue(stringValue(params["brand_name"]))
|
||||
appendValue(stringValue(params["brand_question"]))
|
||||
appendValue(stringValue(params["primary_keyword"]))
|
||||
appendValue(formatPromptValue(params["supplemental_questions"]))
|
||||
appendValue(stringValue(params["key_points"]))
|
||||
appendValue(stringValue(params["review_intro_hook"]))
|
||||
|
||||
@@ -187,7 +189,10 @@ func buildPromptContext(params map[string]interface{}) string {
|
||||
"product_name",
|
||||
"subject",
|
||||
"brand_name",
|
||||
"brand_question",
|
||||
"primary_question",
|
||||
"primary_keyword",
|
||||
"supplemental_questions",
|
||||
"brand",
|
||||
"category",
|
||||
"count",
|
||||
|
||||
@@ -20,7 +20,7 @@ func TestBuildPromptContextUsesChineseLabels(t *testing.T) {
|
||||
"- 语言: zh-CN",
|
||||
"- 标题: 测试标题",
|
||||
"- 品牌名: Rankly",
|
||||
"- 核心关键词: GEO 排名",
|
||||
"- 品牌主问题: GEO 排名",
|
||||
"- 已选段落: 引言 > 结论",
|
||||
} {
|
||||
if !strings.Contains(context, expected) {
|
||||
|
||||
@@ -47,7 +47,7 @@ func TestPlatformTemplateSeedsInjectPromptConfig(t *testing.T) {
|
||||
if !strings.Contains(analyzePrompt, "推荐类文章做品牌与竞品分析") {
|
||||
t.Fatalf("analyze_prompt_template = %q, want yaml-backed analyze prompt", analyzePrompt)
|
||||
}
|
||||
if !strings.Contains(analyzePrompt, "全屋定制哪家好") || !strings.Contains(analyzePrompt, "搜索查询词") {
|
||||
if !strings.Contains(analyzePrompt, "全屋定制哪家好") || !strings.Contains(analyzePrompt, "搜索问题候选") {
|
||||
t.Fatalf("analyze_prompt_template = %q, want question-like long-tail query guidance", analyzePrompt)
|
||||
}
|
||||
if !strings.Contains(titlePrompt, "推荐类文章生成 5 个候选标题") {
|
||||
@@ -66,10 +66,10 @@ func TestAnalyzeFallbackPromptPrefersQuestionLikeQueries(t *testing.T) {
|
||||
|
||||
prompt := AnalyzeFallbackPrompt(`{"brand_name":"测试品牌","input_params":{"keyword":"全屋定制"}}`)
|
||||
for _, want := range []string{
|
||||
"真实用户会直接搜索的问题型长尾词",
|
||||
"哪家好",
|
||||
"最多返回 6 个竞品和 10 个关键词",
|
||||
} {
|
||||
"真实用户会直接搜索的问题型长尾词",
|
||||
"哪家好",
|
||||
"最多返回 6 个竞品和 10 个搜索问题候选",
|
||||
} {
|
||||
if !strings.Contains(prompt, want) {
|
||||
t.Fatalf("AnalyzeFallbackPrompt() missing %q in prompt:\n%s", want, prompt)
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ var platformTemplateSeeds = []PlatformTemplateSeed{
|
||||
},
|
||||
"wizard": {
|
||||
"steps": {
|
||||
"basic": { "title": "基本信息", "description": "品牌、关键词与竞品上下文" },
|
||||
"basic": { "title": "基本信息", "description": "品牌、品牌问题与竞品上下文" },
|
||||
"structure": { "title": "文章结构", "description": "标题方案、结构组合与重点补充" },
|
||||
"generate": { "title": "生成文章", "description": "确认参数并进入生成队列" }
|
||||
},
|
||||
@@ -53,11 +53,11 @@ var platformTemplateSeeds = []PlatformTemplateSeed{
|
||||
"cards": {
|
||||
"brand": {
|
||||
"title": "品牌信息",
|
||||
"hint": "品牌可手动输入,也可从品牌库带入。AI 会结合品牌、官网和模板字段补齐关键词与竞品。"
|
||||
"hint": "品牌可手动输入,也可从品牌库带入。AI 会结合品牌、官网和品牌问题补齐摘要与竞品。"
|
||||
},
|
||||
"keywords": {
|
||||
"title": "关键词",
|
||||
"hint": "关键词可以手动改写,AI 分析结果会自动补充到这里,后续标题生成会以当前版本为准。"
|
||||
"title": "品牌问题",
|
||||
"hint": "品牌主问题必选且只能选一个;补充覆盖问题可选,最多 3 个,只进入文章主体和结尾点题。"
|
||||
},
|
||||
"template_fields": {
|
||||
"title": "模板字段",
|
||||
@@ -73,7 +73,7 @@ var platformTemplateSeeds = []PlatformTemplateSeed{
|
||||
"cards": {
|
||||
"choose_title": {
|
||||
"title": "选择文章标题",
|
||||
"hint": "AI 会结合当前关键词和竞品上下文给出 5 个更适合的标题候选,你可以直接选择或改写。"
|
||||
"hint": "AI 会结合品牌主问题和竞品上下文给出 5 个更适合的标题候选,你可以直接选择或改写。"
|
||||
},
|
||||
"outline": {
|
||||
"title": "文章结构",
|
||||
@@ -109,7 +109,7 @@ var platformTemplateSeeds = []PlatformTemplateSeed{
|
||||
{ "key": "intro", "label": "引言", "default": true },
|
||||
{ "key": "site_list", "label": "网站列表", "default": true },
|
||||
{ "key": "key_points", "label": "文章关键要点", "default": true },
|
||||
{ "key": "what_is_keyword", "label_template": "什么是{{primary_keyword}}", "default": false },
|
||||
{ "key": "what_is_question", "label_template": "围绕{{brand_question}}展开", "default": false },
|
||||
{ "key": "features", "label": "特点", "default": false },
|
||||
{ "key": "pros_cons", "label": "优缺点", "default": false },
|
||||
{ "key": "audience", "label": "适合人群", "default": false },
|
||||
@@ -141,11 +141,11 @@ var platformTemplateSeeds = []PlatformTemplateSeed{
|
||||
"cards": {
|
||||
"brand": {
|
||||
"title": "品牌信息",
|
||||
"hint": "可直接输入品牌,也可从品牌库选择。AI 会补充适合评测文章的关键词和品牌背景。"
|
||||
"hint": "可直接输入品牌,也可从品牌库选择。AI 会结合品牌问题补充适合评测文章的品牌背景。"
|
||||
},
|
||||
"keywords": {
|
||||
"title": "关键词",
|
||||
"hint": "关键词会影响标题和正文的评测切入点,可手动修改。"
|
||||
"title": "品牌问题",
|
||||
"hint": "品牌主问题控制标题和主线;补充覆盖问题只进入文章主体与结尾点题。"
|
||||
},
|
||||
"competitors": {
|
||||
"visible": false
|
||||
@@ -222,7 +222,7 @@ var platformTemplateSeeds = []PlatformTemplateSeed{
|
||||
},
|
||||
"wizard": {
|
||||
"steps": {
|
||||
"basic": { "title": "基本信息", "description": "品牌与关键词背景信息" },
|
||||
"basic": { "title": "基本信息", "description": "品牌与品牌问题背景信息" },
|
||||
"structure": { "title": "文章结构", "description": "标题方案、研究结构与重点补充" },
|
||||
"generate": { "title": "生成文章", "description": "确认参数并进入生成队列" }
|
||||
},
|
||||
@@ -234,8 +234,8 @@ var platformTemplateSeeds = []PlatformTemplateSeed{
|
||||
"hint": "如研究对象与品牌有关,可补充品牌与官网信息帮助 AI 建立上下文。"
|
||||
},
|
||||
"keywords": {
|
||||
"title": "关键词",
|
||||
"hint": "关键词会影响报告的核心视角、结论表达和标题风格。"
|
||||
"title": "品牌问题",
|
||||
"hint": "品牌主问题控制报告核心视角;补充覆盖问题只进入主体覆盖和结尾点题。"
|
||||
},
|
||||
"competitors": {
|
||||
"visible": false
|
||||
@@ -305,7 +305,7 @@ var platformTemplateSeeds = []PlatformTemplateSeed{
|
||||
},
|
||||
"wizard": {
|
||||
"steps": {
|
||||
"basic": { "title": "基本信息", "description": "品牌、关键词与搜索意图上下文" },
|
||||
"basic": { "title": "基本信息", "description": "品牌、品牌问题与搜索意图上下文" },
|
||||
"structure": { "title": "文章结构", "description": "标题方案、结构组合与重点补充" },
|
||||
"generate": { "title": "生成文章", "description": "确认参数并进入生成队列" }
|
||||
},
|
||||
@@ -317,8 +317,8 @@ var platformTemplateSeeds = []PlatformTemplateSeed{
|
||||
"hint": "品牌和官网会帮助 AI 理解搜索对象,也便于后续沉淀到品牌库。"
|
||||
},
|
||||
"keywords": {
|
||||
"title": "关键词",
|
||||
"hint": "这里的关键词会直接参与标题与正文的搜索意图组织。"
|
||||
"title": "品牌问题",
|
||||
"hint": "品牌主问题参与标题与主线;补充覆盖问题只进入正文主体和结尾点题。"
|
||||
},
|
||||
"template_fields": {
|
||||
"title": "模板字段",
|
||||
|
||||
@@ -18,6 +18,7 @@ type MonitoringHandler struct {
|
||||
|
||||
type monitoringCollectNowRequest struct {
|
||||
KeywordID *int64 `json:"keyword_id"`
|
||||
QuestionID *int64 `json:"question_id"`
|
||||
PlatformIDs []string `json:"platform_ids"`
|
||||
Preempt *bool `json:"preempt"`
|
||||
WaitForFirstDispatch *bool `json:"wait_for_first_dispatch"`
|
||||
@@ -43,6 +44,12 @@ func (h *MonitoringHandler) DashboardComposite(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
questionID, err := parseOptionalInt64Pointer(c.Query("question_id"))
|
||||
if err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40031, "invalid_question_id", "question_id must be a number"))
|
||||
return
|
||||
}
|
||||
|
||||
days, err := parseOptionalInt(c.Query("days"))
|
||||
if err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40031, "invalid_days", "days must be a number"))
|
||||
@@ -51,7 +58,7 @@ func (h *MonitoringHandler) DashboardComposite(c *gin.Context) {
|
||||
|
||||
aiPlatformID := parseOptionalStringPointer(c.Query("ai_platform_id"))
|
||||
|
||||
data, svcErr := h.svc.DashboardComposite(c.Request.Context(), brandID, keywordID, days, c.Query("business_date"), aiPlatformID)
|
||||
data, svcErr := h.svc.DashboardComposite(c.Request.Context(), brandID, keywordID, questionID, days, c.Query("business_date"), aiPlatformID)
|
||||
if svcErr != nil {
|
||||
response.Error(c, svcErr)
|
||||
return
|
||||
@@ -78,9 +85,15 @@ func (h *MonitoringHandler) CitationSummary(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
questionID, err := parseOptionalInt64Pointer(c.Query("question_id"))
|
||||
if err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40031, "invalid_question_id", "question_id must be a number"))
|
||||
return
|
||||
}
|
||||
|
||||
aiPlatformID := parseOptionalStringPointer(c.Query("ai_platform_id"))
|
||||
|
||||
data, svcErr := h.svc.CitationSummary(c.Request.Context(), days, brandID, keywordID, c.Query("business_date"), aiPlatformID)
|
||||
data, svcErr := h.svc.CitationSummary(c.Request.Context(), days, brandID, keywordID, questionID, c.Query("business_date"), aiPlatformID)
|
||||
if svcErr != nil {
|
||||
response.Error(c, svcErr)
|
||||
return
|
||||
@@ -142,7 +155,7 @@ func (h *MonitoringHandler) CollectNow(c *gin.Context) {
|
||||
targetClientID = &parsed
|
||||
}
|
||||
|
||||
data, svcErr := h.svc.CollectNow(c.Request.Context(), brandID, req.KeywordID, app.MonitoringCollectNowOptions{
|
||||
data, svcErr := h.svc.CollectNow(c.Request.Context(), brandID, req.KeywordID, req.QuestionID, app.MonitoringCollectNowOptions{
|
||||
PlatformIDs: req.PlatformIDs,
|
||||
Preempt: req.Preempt == nil || *req.Preempt,
|
||||
WaitForFirstDispatch: req.WaitForFirstDispatch != nil && *req.WaitForFirstDispatch,
|
||||
|
||||
@@ -36,6 +36,24 @@ func (h *QuestionExpansionHandler) CombinationPreview(c *gin.Context) {
|
||||
response.Success(c, data)
|
||||
}
|
||||
|
||||
func (h *QuestionExpansionHandler) CombinationFill(c *gin.Context) {
|
||||
brandID, ok := parseBrandIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req app.QuestionCombinationFillRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40001, "invalid_params", err.Error()))
|
||||
return
|
||||
}
|
||||
data, err := h.svc.FillQuestionCombination(c.Request.Context(), brandID, c.ClientIP(), req)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, data)
|
||||
}
|
||||
|
||||
func (h *QuestionExpansionHandler) AIDistill(c *gin.Context) {
|
||||
brandID, ok := parseBrandIDParam(c)
|
||||
if !ok {
|
||||
|
||||
@@ -190,6 +190,7 @@ func RegisterRoutes(app *bootstrap.App) {
|
||||
brands.GET("/:id/questions", brandHandler.ListQuestions)
|
||||
brands.POST("/:id/questions", brandHandler.CreateQuestion)
|
||||
brands.POST("/:id/questions/combination-preview", questionExpansionHandler.CombinationPreview)
|
||||
brands.POST("/:id/questions/combination-fill", questionExpansionHandler.CombinationFill)
|
||||
brands.POST("/:id/questions/ai-distill", questionExpansionHandler.AIDistill)
|
||||
brands.POST("/:id/questions/classify-metadata", questionExpansionHandler.ClassifyMetadata)
|
||||
brands.POST("/:id/questions/materialize", questionExpansionHandler.Materialize)
|
||||
|
||||
Reference in New Issue
Block a user