feat(questions): make brand questions the primary monitoring & template axis
Deployment Config CI / Deployment Config (push) Successful in 29s
Frontend CI / Frontend (push) Successful in 4m4s
Backend CI / Backend (push) Failing after 7m12s

- 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:
2026-05-13 15:59:39 +08:00
parent 37b0b32327
commit 1eae6fb6d4
36 changed files with 2119 additions and 412 deletions
+13 -1
View File
@@ -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()
+3 -2
View File
@@ -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
}
+12
View File
@@ -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) {
+3
View File
@@ -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)
}
@@ -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()
}
}
@@ -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": {"保存问题候选", "将用户选中的问题候选保存到当前品牌问题集,执行配额、去重和审计。"},
+3 -3
View File
@@ -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 {
+99 -45
View File
@@ -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)