3ef0807456
- Updated the regex for placeholder matching to support more flexible key formats. - Refactored variable storage to allow both ID and key lookups in rendering. - Improved schema validation to check for duplicate keys and enforce non-empty keys. - Added new utility functions for variable value lookup and display name generation. - Enhanced tests to cover new key-based variable scenarios. - Refactored KolPrompt repository to simplify prompt storage and management, including the removal of obsolete revision handling. - Introduced new API endpoints for saving, activating, and archiving prompts. - Added new utility functions for handling Kol placeholders and platform options in the admin web. - Implemented database migrations to simplify prompt storage structure and ensure data integrity.
1125 lines
38 KiB
Go
1125 lines
38 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
"golang.org/x/crypto/bcrypt"
|
|
|
|
sharedauth "github.com/geo-platform/tenant-api/internal/shared/auth"
|
|
"github.com/geo-platform/tenant-api/internal/shared/config"
|
|
"github.com/geo-platform/tenant-api/internal/tenant/prompts"
|
|
)
|
|
|
|
type seedState struct {
|
|
TenantID int64
|
|
UserID int64
|
|
TestTenantID int64
|
|
TestUserID int64
|
|
PlanID int64
|
|
BrandID int64
|
|
KeywordPrimaryID int64
|
|
KeywordSecondaryID int64
|
|
QuestionPrimaryID int64
|
|
QuestionSecondaryID int64
|
|
QuestionTertiaryID int64
|
|
ArticleID int64
|
|
PluginInstallationID int64
|
|
}
|
|
|
|
type monitoringRunSeed struct {
|
|
BusinessDate time.Time
|
|
CompletedAt time.Time
|
|
QuestionID int64
|
|
QuestionText string
|
|
QuestionHash []byte
|
|
AIPlatformID string
|
|
ProviderModel string
|
|
AnswerText string
|
|
BrandMentioned bool
|
|
MentionPosition string
|
|
FirstRecommended bool
|
|
SentimentLabel string
|
|
MatchedBrandTerms []string
|
|
Citations []citationSeed
|
|
}
|
|
|
|
type citationSeed struct {
|
|
URL string
|
|
Title string
|
|
NormalizedURL string
|
|
Host string
|
|
RegistrableDomain string
|
|
SiteKey string
|
|
ArticleID *int64
|
|
PublishRecordID *int64
|
|
ResolutionStatus string
|
|
ResolutionConfidence string
|
|
}
|
|
|
|
type dailySeed struct {
|
|
Date time.Time
|
|
SampleStatus string
|
|
DesiredSampleCount int
|
|
PlannedSampleCount int
|
|
ActualSampleCount int
|
|
CoverageRate float64
|
|
ConfidenceLevel string
|
|
TriggerSource string
|
|
MentionedCount int
|
|
Top1MentionedCount int
|
|
FirstRecommendedCount int
|
|
PositiveMentionedCount int
|
|
CitedAnswerCount int
|
|
MentionRate float64
|
|
Top1MentionRate float64
|
|
FirstRecommendRate float64
|
|
PositiveMentionRate float64
|
|
CitationRate float64
|
|
}
|
|
|
|
func main() {
|
|
configPath := "configs/config.yaml"
|
|
if p := os.Getenv("CONFIG_PATH"); p != "" {
|
|
configPath = p
|
|
}
|
|
|
|
cfg, err := config.Load(configPath)
|
|
if err != nil {
|
|
log.Fatalf("load config: %v", err)
|
|
}
|
|
prompts.SetConfigFile(filepath.Join(filepath.Dir(configPath), "prompts.yml"))
|
|
|
|
ctx := context.Background()
|
|
businessPool, err := pgxpool.New(ctx, cfg.Database.DSN())
|
|
if err != nil {
|
|
log.Fatalf("connect business db: %v", err)
|
|
}
|
|
defer businessPool.Close()
|
|
|
|
monitoringPool, err := pgxpool.New(ctx, cfg.MonitoringDatabase.DSN())
|
|
if err != nil {
|
|
log.Fatalf("connect monitoring db: %v", err)
|
|
}
|
|
defer monitoringPool.Close()
|
|
|
|
if err := businessPool.Ping(ctx); err != nil {
|
|
log.Fatalf("ping business db: %v", err)
|
|
}
|
|
if err := monitoringPool.Ping(ctx); err != nil {
|
|
log.Fatalf("ping monitoring db: %v", err)
|
|
}
|
|
|
|
state, err := seedBusinessData(ctx, businessPool)
|
|
if err != nil {
|
|
log.Fatalf("seed business data: %v", err)
|
|
}
|
|
|
|
if err := seedMonitoringData(ctx, monitoringPool, state); err != nil {
|
|
log.Fatalf("seed monitoring data: %v", err)
|
|
}
|
|
|
|
fmt.Println("Seed data inserted successfully.")
|
|
fmt.Printf(" Tenant ID: %d\n", state.TenantID)
|
|
fmt.Printf(" User ID: %d (admin@geo.local / Admin@123)\n", state.UserID)
|
|
fmt.Printf(" Test Tenant ID: %d\n", state.TestTenantID)
|
|
fmt.Printf(" Test User ID: %d (test@geo.local / Test@123)\n", state.TestUserID)
|
|
fmt.Printf(" Plan ID: %d (free)\n", state.PlanID)
|
|
fmt.Printf(" Brand ID: %d (轻氧口腔)\n", state.BrandID)
|
|
fmt.Printf(" Plugin Installation ID: %d\n", state.PluginInstallationID)
|
|
fmt.Println(" Templates: 4 platform presets")
|
|
fmt.Println(" Monitoring: brand daily snapshots + question runs + citation facts")
|
|
}
|
|
|
|
func seedBusinessData(ctx context.Context, pool *pgxpool.Pool) (*seedState, error) {
|
|
hash, err := bcrypt.GenerateFromPassword([]byte("Admin@123"), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("hash password: %w", err)
|
|
}
|
|
|
|
tx, err := pool.Begin(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("begin tx: %w", err)
|
|
}
|
|
defer func() {
|
|
_ = tx.Rollback(ctx)
|
|
}()
|
|
|
|
state := &seedState{}
|
|
|
|
state.TenantID, err = ensureTenant(ctx, tx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
state.UserID, err = ensureUser(ctx, tx, string(hash))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := ensureMembership(ctx, tx, state.TenantID, state.UserID); err != nil {
|
|
return nil, err
|
|
}
|
|
state.PlanID, err = ensurePlan(ctx, tx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := ensureSubscription(ctx, tx, state.TenantID, state.PlanID); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := ensureQuotaLedger(ctx, tx, state.TenantID); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := ensureTemplates(ctx, tx); err != nil {
|
|
return nil, err
|
|
}
|
|
state.TestTenantID, state.TestUserID, err = ensureSecondaryTenantUser(ctx, tx, state.PlanID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
state.BrandID, err = ensureBrand(ctx, tx, state.TenantID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
state.KeywordPrimaryID, err = ensureKeyword(ctx, tx, state.TenantID, state.BrandID, "北京口腔医院")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
state.KeywordSecondaryID, err = ensureKeyword(ctx, tx, state.TenantID, state.BrandID, "牙齿种植")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
state.QuestionPrimaryID, err = ensureQuestion(ctx, tx, state.TenantID, state.BrandID, state.KeywordPrimaryID, "北京口腔医院推荐?")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
state.QuestionSecondaryID, err = ensureQuestion(ctx, tx, state.TenantID, state.BrandID, state.KeywordSecondaryID, "北京种植牙哪家靠谱?")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
state.QuestionTertiaryID, err = ensureQuestion(ctx, tx, state.TenantID, state.BrandID, state.KeywordPrimaryID, "北京牙齿矫正医院怎么选?")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := ensureCompetitor(ctx, tx, state.TenantID, state.BrandID); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
state.ArticleID, err = ensureArticle(ctx, tx, state.TenantID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
state.PluginInstallationID, err = ensurePluginInstallation(ctx, tx, state.TenantID, state.UserID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := ensureMonitoringQuota(ctx, tx, state); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return nil, fmt.Errorf("commit business seed: %w", err)
|
|
}
|
|
|
|
return state, nil
|
|
}
|
|
|
|
func seedMonitoringData(ctx context.Context, pool *pgxpool.Pool, state *seedState) error {
|
|
tx, err := pool.Begin(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("begin monitoring tx: %w", err)
|
|
}
|
|
defer func() {
|
|
_ = tx.Rollback(ctx)
|
|
}()
|
|
|
|
if err := clearMonitoringData(ctx, tx, state); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := insertSiteMappings(ctx, tx, state.TenantID); err != nil {
|
|
return err
|
|
}
|
|
|
|
questionHashes := map[int64][]byte{
|
|
state.QuestionPrimaryID: makeQuestionHash("北京口腔医院推荐?"),
|
|
state.QuestionSecondaryID: makeQuestionHash("北京种植牙哪家靠谱?"),
|
|
state.QuestionTertiaryID: makeQuestionHash("北京牙齿矫正医院怎么选?"),
|
|
}
|
|
|
|
if err := insertQuestionSnapshots(ctx, tx, state, questionHashes); err != nil {
|
|
return err
|
|
}
|
|
if err := insertPlatformAccessSnapshots(ctx, tx, state); err != nil {
|
|
return err
|
|
}
|
|
if err := insertArticleAlias(ctx, tx, state); err != nil {
|
|
return err
|
|
}
|
|
if err := insertDailySnapshots(ctx, tx, state); err != nil {
|
|
return err
|
|
}
|
|
if err := insertPlatformDailySnapshots(ctx, tx, state); err != nil {
|
|
return err
|
|
}
|
|
if err := insertTaskSeeds(ctx, tx, state, questionHashes); err != nil {
|
|
return err
|
|
}
|
|
if err := insertRunSeeds(ctx, tx, state, questionHashes); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return fmt.Errorf("commit monitoring seed: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func ensureTenant(ctx context.Context, tx pgx.Tx) (int64, error) {
|
|
return ensureNamedTenant(ctx, tx, "GEO Demo")
|
|
}
|
|
|
|
func ensureNamedTenant(ctx context.Context, tx pgx.Tx, name string) (int64, error) {
|
|
var tenantID int64
|
|
err := tx.QueryRow(ctx, `
|
|
WITH ensured AS (
|
|
INSERT INTO tenants (name, status)
|
|
VALUES ($1, 'active')
|
|
ON CONFLICT DO NOTHING
|
|
RETURNING id
|
|
)
|
|
SELECT id FROM ensured
|
|
UNION ALL
|
|
SELECT id FROM tenants WHERE name = $1
|
|
LIMIT 1
|
|
`, name).Scan(&tenantID)
|
|
return tenantID, wrapSeedErr("insert tenant", err)
|
|
}
|
|
|
|
func ensureUser(ctx context.Context, tx pgx.Tx, passwordHash string) (int64, error) {
|
|
return ensureNamedUser(ctx, tx, "admin@geo.local", "Admin", passwordHash)
|
|
}
|
|
|
|
func ensureNamedUser(ctx context.Context, tx pgx.Tx, email, name, passwordHash string) (int64, error) {
|
|
var userID int64
|
|
err := tx.QueryRow(ctx, `
|
|
WITH ensured AS (
|
|
INSERT INTO users (email, password_hash, name, status)
|
|
VALUES ($1, $2, $3, 'active')
|
|
ON CONFLICT DO NOTHING
|
|
RETURNING id
|
|
)
|
|
SELECT id FROM ensured
|
|
UNION ALL
|
|
SELECT id FROM users WHERE email = $1
|
|
LIMIT 1
|
|
`, email, passwordHash, name).Scan(&userID)
|
|
return userID, wrapSeedErr("insert user", err)
|
|
}
|
|
|
|
func ensureMembership(ctx context.Context, tx pgx.Tx, tenantID, userID int64) error {
|
|
return ensureMembershipWithRole(ctx, tx, tenantID, userID, "tenant_admin")
|
|
}
|
|
|
|
func ensureMembershipWithRole(ctx context.Context, tx pgx.Tx, tenantID, userID int64, role string) error {
|
|
_, err := tx.Exec(ctx, `
|
|
INSERT INTO tenant_memberships (tenant_id, user_id, tenant_role)
|
|
VALUES ($1, $2, $3)
|
|
ON CONFLICT DO NOTHING
|
|
`, tenantID, userID, role)
|
|
return wrapSeedErr("insert membership", err)
|
|
}
|
|
|
|
func ensureSecondaryTenantUser(ctx context.Context, tx pgx.Tx, planID int64) (int64, int64, error) {
|
|
hash, err := bcrypt.GenerateFromPassword([]byte("Test@123"), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return 0, 0, fmt.Errorf("hash secondary user password: %w", err)
|
|
}
|
|
|
|
tenantID, err := ensureNamedTenant(ctx, tx, "test")
|
|
if err != nil {
|
|
return 0, 0, err
|
|
}
|
|
|
|
userID, err := ensureNamedUser(ctx, tx, "test@geo.local", "test", string(hash))
|
|
if err != nil {
|
|
return 0, 0, err
|
|
}
|
|
|
|
if err := ensureMembershipWithRole(ctx, tx, tenantID, userID, "tenant_admin"); err != nil {
|
|
return 0, 0, err
|
|
}
|
|
if err := ensureSubscription(ctx, tx, tenantID, planID); err != nil {
|
|
return 0, 0, err
|
|
}
|
|
if err := ensureQuotaLedger(ctx, tx, tenantID); err != nil {
|
|
return 0, 0, err
|
|
}
|
|
|
|
return tenantID, userID, nil
|
|
}
|
|
|
|
func ensurePlan(ctx context.Context, tx pgx.Tx) (int64, error) {
|
|
var planID int64
|
|
err := tx.QueryRow(ctx, `
|
|
WITH ensured AS (
|
|
INSERT INTO plans (plan_code, name, quota_policy_json, status)
|
|
VALUES ('free', 'Free Plan', '{"article_generation": 100}', 'active')
|
|
ON CONFLICT DO NOTHING
|
|
RETURNING id
|
|
)
|
|
SELECT id FROM ensured
|
|
UNION ALL
|
|
SELECT id FROM plans WHERE plan_code = 'free'
|
|
LIMIT 1
|
|
`).Scan(&planID)
|
|
return planID, wrapSeedErr("insert plan", err)
|
|
}
|
|
|
|
func ensureSubscription(ctx context.Context, tx pgx.Tx, tenantID, planID int64) error {
|
|
_, err := tx.Exec(ctx, `
|
|
INSERT INTO tenant_plan_subscriptions (tenant_id, plan_id, start_at, end_at, status)
|
|
SELECT $1, $2, NOW(), NOW() + INTERVAL '1 year', 'active'
|
|
WHERE NOT EXISTS (
|
|
SELECT 1
|
|
FROM tenant_plan_subscriptions
|
|
WHERE tenant_id = $1
|
|
AND plan_id = $2
|
|
AND status = 'active'
|
|
)
|
|
`, tenantID, planID)
|
|
return wrapSeedErr("insert subscription", err)
|
|
}
|
|
|
|
func ensureQuotaLedger(ctx context.Context, tx pgx.Tx, tenantID int64) error {
|
|
_, err := tx.Exec(ctx, `
|
|
INSERT INTO tenant_quota_ledgers (tenant_id, quota_type, delta, balance_after, reason)
|
|
SELECT $1, 'article_generation', 100, 100, 'initial_allocation'
|
|
WHERE NOT EXISTS (
|
|
SELECT 1
|
|
FROM tenant_quota_ledgers
|
|
WHERE tenant_id = $1
|
|
AND quota_type = 'article_generation'
|
|
AND reason = 'initial_allocation'
|
|
)
|
|
`, tenantID)
|
|
return wrapSeedErr("insert quota", err)
|
|
}
|
|
|
|
func ensureTemplates(ctx context.Context, tx pgx.Tx) error {
|
|
for _, tpl := range prompts.PlatformTemplateSeeds() {
|
|
_, err := tx.Exec(ctx, `
|
|
INSERT INTO article_templates (scope, origin_type, template_key, template_name, prompt_template, prompt_visibility, card_config_json, status)
|
|
VALUES ('platform', 'platform', $1, $2, $3, 'visible', $4::jsonb, 'active')
|
|
ON CONFLICT DO NOTHING
|
|
`, tpl.Key, tpl.Name, tpl.PromptTemplate, tpl.CardConfigJSON)
|
|
if err != nil {
|
|
return wrapSeedErr("insert template", err)
|
|
}
|
|
|
|
_, err = tx.Exec(ctx, `
|
|
UPDATE article_templates
|
|
SET template_name = $2,
|
|
prompt_template = $3,
|
|
card_config_json = $4::jsonb,
|
|
prompt_visibility = 'visible',
|
|
status = 'active',
|
|
updated_at = NOW()
|
|
WHERE scope = 'platform'
|
|
AND template_key = $1
|
|
AND deleted_at IS NULL
|
|
`, tpl.Key, tpl.Name, tpl.PromptTemplate, tpl.CardConfigJSON)
|
|
if err != nil {
|
|
return wrapSeedErr("update template", err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func ensureBrand(ctx context.Context, tx pgx.Tx, tenantID int64) (int64, error) {
|
|
var brandID int64
|
|
err := tx.QueryRow(ctx, `
|
|
WITH ensured AS (
|
|
INSERT INTO brands (tenant_id, name, website, description, status)
|
|
VALUES ($1, '轻氧口腔', 'https://www.qingyang-dental.example', '北京口腔诊疗品牌示例', 'active')
|
|
ON CONFLICT DO NOTHING
|
|
RETURNING id
|
|
)
|
|
SELECT id FROM ensured
|
|
UNION ALL
|
|
SELECT id FROM brands WHERE tenant_id = $1 AND name = '轻氧口腔' AND deleted_at IS NULL
|
|
LIMIT 1
|
|
`, tenantID).Scan(&brandID)
|
|
return brandID, wrapSeedErr("insert brand", err)
|
|
}
|
|
|
|
func ensureKeyword(ctx context.Context, tx pgx.Tx, tenantID, brandID int64, name string) (int64, error) {
|
|
var keywordID int64
|
|
err := tx.QueryRow(ctx, `
|
|
WITH ensured AS (
|
|
INSERT INTO brand_keywords (tenant_id, brand_id, name, status)
|
|
VALUES ($1, $2, $3, 'active')
|
|
ON CONFLICT DO NOTHING
|
|
RETURNING id
|
|
)
|
|
SELECT id FROM ensured
|
|
UNION ALL
|
|
SELECT id FROM brand_keywords
|
|
WHERE tenant_id = $1 AND brand_id = $2 AND name = $3 AND deleted_at IS NULL
|
|
LIMIT 1
|
|
`, tenantID, brandID, name).Scan(&keywordID)
|
|
return keywordID, wrapSeedErr("insert keyword", err)
|
|
}
|
|
|
|
func ensureQuestion(ctx context.Context, tx pgx.Tx, tenantID, brandID, keywordID int64, questionText string) (int64, error) {
|
|
var questionID int64
|
|
err := tx.QueryRow(ctx, `
|
|
SELECT id
|
|
FROM brand_questions
|
|
WHERE tenant_id = $1 AND brand_id = $2 AND keyword_id = $3 AND question_text = $4 AND deleted_at IS NULL
|
|
LIMIT 1
|
|
`, tenantID, brandID, keywordID, questionText).Scan(&questionID)
|
|
if err == nil {
|
|
return questionID, nil
|
|
}
|
|
if err != pgx.ErrNoRows {
|
|
return 0, wrapSeedErr("query question", err)
|
|
}
|
|
|
|
err = tx.QueryRow(ctx, `
|
|
INSERT INTO brand_questions (tenant_id, brand_id, keyword_id, question_text, status)
|
|
VALUES ($1, $2, $3, $4, 'active')
|
|
RETURNING id
|
|
`, tenantID, brandID, keywordID, questionText).Scan(&questionID)
|
|
return questionID, wrapSeedErr("insert question", err)
|
|
}
|
|
|
|
func ensureCompetitor(ctx context.Context, tx pgx.Tx, tenantID, brandID int64) error {
|
|
_, err := tx.Exec(ctx, `
|
|
INSERT INTO competitors (tenant_id, brand_id, name, website, description, product_lines_json)
|
|
SELECT $1, $2, '瑞尔齿科', 'https://www.arrailgroup.com', '北京连锁齿科竞品示例', '["牙齿种植","正畸"]'::jsonb
|
|
WHERE NOT EXISTS (
|
|
SELECT 1
|
|
FROM competitors
|
|
WHERE tenant_id = $1
|
|
AND brand_id = $2
|
|
AND name = '瑞尔齿科'
|
|
AND deleted_at IS NULL
|
|
)
|
|
`, tenantID, brandID)
|
|
return wrapSeedErr("insert competitor", err)
|
|
}
|
|
|
|
func ensureArticle(ctx context.Context, tx pgx.Tx, tenantID int64) (int64, error) {
|
|
var articleID int64
|
|
err := tx.QueryRow(ctx, `
|
|
SELECT a.id
|
|
FROM articles a
|
|
JOIN article_versions av ON av.id = a.current_version_id
|
|
WHERE a.tenant_id = $1
|
|
AND av.title = '北京口腔医院全面测评'
|
|
AND a.deleted_at IS NULL
|
|
LIMIT 1
|
|
`, tenantID).Scan(&articleID)
|
|
if err == nil {
|
|
return articleID, nil
|
|
}
|
|
if err != pgx.ErrNoRows {
|
|
return 0, wrapSeedErr("query article", err)
|
|
}
|
|
|
|
err = tx.QueryRow(ctx, `
|
|
INSERT INTO articles (tenant_id, source_type, generate_status, publish_status)
|
|
VALUES ($1, 'free_create', 'completed', 'published')
|
|
RETURNING id
|
|
`, tenantID).Scan(&articleID)
|
|
if err != nil {
|
|
return 0, wrapSeedErr("insert article", err)
|
|
}
|
|
|
|
var versionID int64
|
|
err = tx.QueryRow(ctx, `
|
|
INSERT INTO article_versions (article_id, version_no, title, markdown_content, word_count, source_label)
|
|
VALUES ($1, 1, '北京口腔医院全面测评', '# 北京口腔医院全面测评', 1280, 'GEO Demo')
|
|
RETURNING id
|
|
`, articleID).Scan(&versionID)
|
|
if err != nil {
|
|
return 0, wrapSeedErr("insert article version", err)
|
|
}
|
|
|
|
_, err = tx.Exec(ctx, `
|
|
UPDATE articles
|
|
SET current_version_id = $2, updated_at = NOW()
|
|
WHERE id = $1
|
|
`, articleID, versionID)
|
|
if err != nil {
|
|
return 0, wrapSeedErr("update article current version", err)
|
|
}
|
|
|
|
return articleID, nil
|
|
}
|
|
|
|
func ensurePluginInstallation(ctx context.Context, tx pgx.Tx, tenantID, userID int64) (int64, error) {
|
|
installationKey := "demo-monitoring-installation"
|
|
tokenHash := sharedauth.HashToken("demo-monitoring-installation-token")
|
|
|
|
var installationID int64
|
|
err := tx.QueryRow(ctx, `
|
|
WITH ensured AS (
|
|
INSERT INTO plugin_installations (
|
|
tenant_id, user_id, installation_key, installation_name, browser_name,
|
|
client_version, installation_token_hash, status, last_seen_at
|
|
)
|
|
VALUES (
|
|
$1, $2, $3, 'GEO Monitoring Chrome', 'Chrome',
|
|
'0.1.0', $4, 'active', NOW()
|
|
)
|
|
ON CONFLICT DO NOTHING
|
|
RETURNING id
|
|
)
|
|
SELECT id FROM ensured
|
|
UNION ALL
|
|
SELECT id
|
|
FROM plugin_installations
|
|
WHERE tenant_id = $1 AND installation_key = $3 AND deleted_at IS NULL
|
|
LIMIT 1
|
|
`, tenantID, userID, installationKey, tokenHash).Scan(&installationID)
|
|
if err != nil {
|
|
return 0, wrapSeedErr("insert plugin installation", err)
|
|
}
|
|
|
|
_, err = tx.Exec(ctx, `
|
|
UPDATE plugin_installations
|
|
SET status = 'active',
|
|
installation_token_hash = $2,
|
|
last_seen_at = NOW(),
|
|
updated_at = NOW()
|
|
WHERE id = $1
|
|
`, installationID, tokenHash)
|
|
if err != nil {
|
|
return 0, wrapSeedErr("touch plugin installation", err)
|
|
}
|
|
return installationID, nil
|
|
}
|
|
|
|
func ensureMonitoringQuota(ctx context.Context, tx pgx.Tx, state *seedState) error {
|
|
enabledPlatforms, _ := json.Marshal([]string{"deepseek", "qwen", "doubao"})
|
|
_, err := tx.Exec(ctx, `
|
|
INSERT INTO tenant_monitoring_quotas (
|
|
tenant_id, max_brands, collection_mode, question_selection_mode, collect_frequency,
|
|
enabled_platforms, primary_installation_id, task_daily_hard_cap, plan_tier
|
|
)
|
|
VALUES ($1, 1, 'plugin', 'best_effort_all', 'daily', $2::jsonb, $3, 36, 'free')
|
|
ON CONFLICT (tenant_id) DO UPDATE
|
|
SET collection_mode = EXCLUDED.collection_mode,
|
|
question_selection_mode = EXCLUDED.question_selection_mode,
|
|
collect_frequency = EXCLUDED.collect_frequency,
|
|
enabled_platforms = EXCLUDED.enabled_platforms,
|
|
primary_installation_id = EXCLUDED.primary_installation_id,
|
|
task_daily_hard_cap = EXCLUDED.task_daily_hard_cap,
|
|
plan_tier = EXCLUDED.plan_tier,
|
|
updated_at = NOW()
|
|
`, state.TenantID, string(enabledPlatforms), state.PluginInstallationID)
|
|
return wrapSeedErr("upsert monitoring quota", err)
|
|
}
|
|
|
|
func clearMonitoringData(ctx context.Context, tx pgx.Tx, state *seedState) error {
|
|
pairs := []string{
|
|
`DELETE FROM monitoring_citation_facts WHERE tenant_id = $1 AND brand_id = $2`,
|
|
`DELETE FROM question_monitor_parse_results WHERE tenant_id = $1 AND brand_id = $2`,
|
|
`DELETE FROM question_monitor_runs WHERE tenant_id = $1 AND brand_id = $2`,
|
|
`DELETE FROM monitoring_collect_tasks WHERE tenant_id = $1 AND brand_id = $2`,
|
|
`DELETE FROM monitoring_brand_platform_daily WHERE tenant_id = $1 AND brand_id = $2`,
|
|
`DELETE FROM monitoring_brand_daily WHERE tenant_id = $1 AND brand_id = $2`,
|
|
`DELETE FROM monitoring_question_config_snapshots WHERE tenant_id = $1 AND brand_id = $2`,
|
|
}
|
|
for _, query := range pairs {
|
|
if _, err := tx.Exec(ctx, query, state.TenantID, state.BrandID); err != nil {
|
|
return wrapSeedErr("clear monitoring data", err)
|
|
}
|
|
}
|
|
|
|
singles := []string{
|
|
`DELETE FROM monitoring_platform_access_snapshots WHERE tenant_id = $1`,
|
|
`DELETE FROM monitoring_article_url_aliases WHERE tenant_id = $1`,
|
|
`DELETE FROM site_domain_mappings WHERE tenant_id = $1`,
|
|
}
|
|
for _, query := range singles {
|
|
if _, err := tx.Exec(ctx, query, state.TenantID); err != nil {
|
|
return wrapSeedErr("clear monitoring data", err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func insertSiteMappings(ctx context.Context, tx pgx.Tx, tenantID int64) error {
|
|
mappings := []struct {
|
|
domain string
|
|
siteKey string
|
|
siteName string
|
|
}{
|
|
{"zhihu.com", "zhihu.com", "知乎"},
|
|
{"weixin.qq.com", "weixin.qq.com", "微信公众号"},
|
|
{"haodf.com", "haodf.com", "好大夫在线"},
|
|
}
|
|
for _, item := range mappings {
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO site_domain_mappings (tenant_id, registrable_domain, site_key, site_name, is_active)
|
|
VALUES ($1, $2, $3, $4, TRUE)
|
|
`, tenantID, item.domain, item.siteKey, item.siteName); err != nil {
|
|
return wrapSeedErr("insert site mapping", err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func insertQuestionSnapshots(ctx context.Context, tx pgx.Tx, state *seedState, hashes map[int64][]byte) error {
|
|
questions := []struct {
|
|
ID int64
|
|
KeywordID int64
|
|
Text string
|
|
}{
|
|
{state.QuestionPrimaryID, state.KeywordPrimaryID, "北京口腔医院推荐?"},
|
|
{state.QuestionSecondaryID, state.KeywordSecondaryID, "北京种植牙哪家靠谱?"},
|
|
{state.QuestionTertiaryID, state.KeywordPrimaryID, "北京牙齿矫正医院怎么选?"},
|
|
}
|
|
|
|
for _, item := range questions {
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO monitoring_question_config_snapshots (
|
|
tenant_id, brand_id, question_id, keyword_id, question_hash, question_text_snapshot,
|
|
monitor_enabled, projected_at
|
|
)
|
|
VALUES ($1, $2, $3, $4, $5, $6, TRUE, NOW())
|
|
`, state.TenantID, state.BrandID, item.ID, item.KeywordID, hashes[item.ID], item.Text); err != nil {
|
|
return wrapSeedErr("insert question snapshot", err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func insertPlatformAccessSnapshots(ctx context.Context, tx pgx.Tx, state *seedState) error {
|
|
businessDate := time.Now().UTC().Format("2006-01-02")
|
|
entries := []struct {
|
|
PlatformID string
|
|
AccessStatus string
|
|
Connected bool
|
|
Accessible bool
|
|
ReasonCode *string
|
|
ReasonMessage *string
|
|
}{
|
|
{"deepseek", "accessible", true, true, nil, nil},
|
|
{"qwen", "not_logged_in", false, false, stringPtr("session_missing"), stringPtr("插件检测到通义千问登录态已失效")},
|
|
{"doubao", "accessible", true, true, nil, nil},
|
|
}
|
|
|
|
for _, item := range entries {
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO monitoring_platform_access_snapshots (
|
|
tenant_id, installation_id, ai_platform_id, business_date, access_status,
|
|
connected, accessible, detected_at, reason_code, reason_message
|
|
)
|
|
VALUES ($1, $2, $3, $4::date, $5, $6, $7, NOW(), $8, $9)
|
|
`, state.TenantID, state.PluginInstallationID, item.PlatformID, businessDate, item.AccessStatus, item.Connected, item.Accessible, item.ReasonCode, item.ReasonMessage); err != nil {
|
|
return wrapSeedErr("insert platform access snapshot", err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func insertArticleAlias(ctx context.Context, tx pgx.Tx, state *seedState) error {
|
|
_, err := tx.Exec(ctx, `
|
|
INSERT INTO monitoring_article_url_aliases (
|
|
tenant_id, article_id, platform_id, publish_platform_name_snapshot,
|
|
article_title_snapshot, original_url, normalized_url, last_path_segment, confidence
|
|
)
|
|
VALUES (
|
|
$1, $2, 'weixin_gzh', '微信公众号',
|
|
'北京口腔医院全面测评',
|
|
'https://mp.weixin.qq.com/s/geo-demo-monitoring',
|
|
'https://mp.weixin.qq.com/s/geo-demo-monitoring',
|
|
'geo-demo-monitoring',
|
|
'high'
|
|
)
|
|
`, state.TenantID, state.ArticleID)
|
|
return wrapSeedErr("insert article alias", err)
|
|
}
|
|
|
|
func insertDailySnapshots(ctx context.Context, tx pgx.Tx, state *seedState) error {
|
|
today := time.Now().UTC().Truncate(24 * time.Hour)
|
|
seeds := []dailySeed{
|
|
{today.AddDate(0, 0, -6), "partial", 30, 18, 12, 0.6667, "medium", "automatic", 4, 2, 3, 7, 2, 0.3333, 0.1667, 0.2500, 0.5833, 0.1667},
|
|
{today.AddDate(0, 0, -5), "sampled", 30, 21, 16, 0.7619, "high", "automatic", 6, 3, 4, 10, 3, 0.3750, 0.1875, 0.2500, 0.6250, 0.1875},
|
|
{today.AddDate(0, 0, -4), "unsampled", 30, 0, 0, 0.0, "low", "", 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
|
|
{today.AddDate(0, 0, -3), "sampled", 30, 24, 18, 0.7500, "high", "automatic", 7, 4, 5, 12, 4, 0.3889, 0.2222, 0.2778, 0.6667, 0.2222},
|
|
{today.AddDate(0, 0, -2), "partial", 30, 21, 13, 0.6190, "medium", "automatic", 5, 2, 3, 9, 2, 0.3846, 0.1538, 0.2308, 0.6923, 0.1538},
|
|
{today.AddDate(0, 0, -1), "sampled", 30, 21, 15, 0.7143, "high", "automatic", 6, 3, 4, 10, 3, 0.4000, 0.2000, 0.2667, 0.6667, 0.2000},
|
|
{today, "sampled", 30, 21, 15, 0.7143, "high", "automatic", 7, 3, 4, 11, 4, 0.4667, 0.2000, 0.2667, 0.7333, 0.2667},
|
|
}
|
|
|
|
for _, item := range seeds {
|
|
var triggerSource interface{}
|
|
var coverageRate interface{}
|
|
var mentionRate interface{}
|
|
var top1Rate interface{}
|
|
var firstRate interface{}
|
|
var positiveRate interface{}
|
|
var citationRate interface{}
|
|
var snapshotUpdatedAt interface{}
|
|
|
|
if item.TriggerSource != "" {
|
|
triggerSource = item.TriggerSource
|
|
snapshotUpdatedAt = item.Date.Add(9*time.Hour + 20*time.Minute)
|
|
}
|
|
if item.ActualSampleCount > 0 {
|
|
coverageRate = item.CoverageRate
|
|
mentionRate = item.MentionRate
|
|
top1Rate = item.Top1MentionRate
|
|
firstRate = item.FirstRecommendRate
|
|
positiveRate = item.PositiveMentionRate
|
|
citationRate = item.CitationRate
|
|
}
|
|
|
|
_, err := tx.Exec(ctx, `
|
|
INSERT INTO monitoring_brand_daily (
|
|
tenant_id, brand_id, collector_type, business_date, sample_status,
|
|
desired_sample_count, planned_sample_count, actual_sample_count,
|
|
coverage_rate, confidence_level, trigger_source, snapshot_updated_at,
|
|
mentioned_count, top1_mentioned_count, first_recommended_count, positive_mentioned_count,
|
|
cited_answer_count, mention_rate, top1_mention_rate, first_recommend_rate,
|
|
positive_mention_rate, citation_rate
|
|
)
|
|
VALUES (
|
|
$1, $2, 'plugin', $3::date, $4, $5, $6, $7, $8, $9, $10, $11,
|
|
$12, $13, $14, $15, $16, $17, $18, $19, $20, $21
|
|
)
|
|
`, state.TenantID, state.BrandID, item.Date.Format("2006-01-02"), item.SampleStatus,
|
|
item.DesiredSampleCount, item.PlannedSampleCount, item.ActualSampleCount,
|
|
coverageRate, item.ConfidenceLevel, triggerSource, snapshotUpdatedAt,
|
|
item.MentionedCount, item.Top1MentionedCount, item.FirstRecommendedCount, item.PositiveMentionedCount,
|
|
item.CitedAnswerCount, mentionRate, top1Rate, firstRate, positiveRate, citationRate,
|
|
)
|
|
if err != nil {
|
|
return wrapSeedErr("insert monitoring_brand_daily", err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func insertPlatformDailySnapshots(ctx context.Context, tx pgx.Tx, state *seedState) error {
|
|
businessDate := time.Now().UTC().Format("2006-01-02")
|
|
type platformDaily struct {
|
|
ID string
|
|
Status string
|
|
Planned int
|
|
Actual int
|
|
MentionRate interface{}
|
|
Top1Rate interface{}
|
|
LastSampledAt interface{}
|
|
}
|
|
items := []platformDaily{
|
|
{"deepseek", "sampled", 8, 8, 0.5000, 0.2500, time.Now().UTC().Add(-25 * time.Minute)},
|
|
{"doubao", "sampled", 7, 7, 0.4286, 0.1429, time.Now().UTC().Add(-40 * time.Minute)},
|
|
{"qwen", "not_logged_in", 6, 0, nil, nil, nil},
|
|
}
|
|
for _, item := range items {
|
|
_, err := tx.Exec(ctx, `
|
|
INSERT INTO monitoring_brand_platform_daily (
|
|
tenant_id, brand_id, ai_platform_id, collector_type, business_date,
|
|
platform_sample_status, planned_sample_count, actual_sample_count,
|
|
mention_rate, top1_mention_rate, last_sampled_at
|
|
)
|
|
VALUES ($1, $2, $3, 'plugin', $4::date, $5, $6, $7, $8, $9, $10)
|
|
`, state.TenantID, state.BrandID, item.ID, businessDate, item.Status, item.Planned, item.Actual, item.MentionRate, item.Top1Rate, item.LastSampledAt)
|
|
if err != nil {
|
|
return wrapSeedErr("insert monitoring_brand_platform_daily", err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func insertTaskSeeds(ctx context.Context, tx pgx.Tx, state *seedState, hashes map[int64][]byte) error {
|
|
businessDate := time.Now().UTC().Format("2006-01-02")
|
|
tasks := []struct {
|
|
QuestionID int64
|
|
PlatformID string
|
|
Status string
|
|
TriggerSource string
|
|
SkipReason *string
|
|
CompletedAt interface{}
|
|
}{
|
|
{state.QuestionPrimaryID, "deepseek", "completed", "automatic", nil, time.Now().UTC().Add(-24 * time.Minute)},
|
|
{state.QuestionPrimaryID, "doubao", "completed", "automatic", nil, time.Now().UTC().Add(-36 * time.Minute)},
|
|
{state.QuestionPrimaryID, "qwen", "skipped", "automatic", stringPtr("platform_not_accessible"), nil},
|
|
{state.QuestionSecondaryID, "deepseek", "completed", "automatic", nil, time.Now().UTC().Add(-18 * time.Minute)},
|
|
{state.QuestionSecondaryID, "doubao", "pending", "automatic", nil, nil},
|
|
{state.QuestionSecondaryID, "qwen", "pending", "automatic", nil, nil},
|
|
{state.QuestionTertiaryID, "deepseek", "pending", "automatic", nil, nil},
|
|
{state.QuestionTertiaryID, "doubao", "completed", "automatic", nil, time.Now().UTC().Add(-42 * time.Minute)},
|
|
}
|
|
for _, item := range tasks {
|
|
_, err := tx.Exec(ctx, `
|
|
INSERT INTO monitoring_collect_tasks (
|
|
tenant_id, brand_id, question_id, question_hash, ai_platform_id, collector_type,
|
|
trigger_source, run_mode, business_date, planned_at, status,
|
|
completed_at, skip_reason
|
|
)
|
|
VALUES (
|
|
$1, $2, $3, $4, $5, 'plugin', $6, 'plugin_standard', $7::date, NOW(), $8, $9, $10
|
|
)
|
|
`, state.TenantID, state.BrandID, item.QuestionID, hashes[item.QuestionID], item.PlatformID, item.TriggerSource, businessDate, item.Status, item.CompletedAt, item.SkipReason)
|
|
if err != nil {
|
|
return wrapSeedErr("insert monitoring_collect_task", err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func insertRunSeeds(ctx context.Context, tx pgx.Tx, state *seedState, hashes map[int64][]byte) error {
|
|
now := time.Now().UTC()
|
|
runs := []monitoringRunSeed{
|
|
{
|
|
BusinessDate: now,
|
|
CompletedAt: now.Add(-25 * time.Minute),
|
|
QuestionID: state.QuestionPrimaryID,
|
|
QuestionText: "北京口腔医院推荐?",
|
|
QuestionHash: hashes[state.QuestionPrimaryID],
|
|
AIPlatformID: "deepseek",
|
|
ProviderModel: "deepseek-chat",
|
|
AnswerText: "根据口碑和诊疗体验,`轻氧口腔` 在医生沟通、正畸方案透明度和术后复诊上都比较稳妥。\n\n如果你更关注连锁规模,也可以对比瑞尔齿科。",
|
|
BrandMentioned: true,
|
|
MentionPosition: "top1",
|
|
FirstRecommended: true,
|
|
SentimentLabel: "positive",
|
|
MatchedBrandTerms: []string{"轻氧口腔"},
|
|
Citations: []citationSeed{
|
|
{
|
|
URL: "https://www.zhihu.com/question/123456",
|
|
Title: "北京口腔医院哪家比较好?",
|
|
NormalizedURL: "https://www.zhihu.com/question/123456",
|
|
Host: "www.zhihu.com",
|
|
RegistrableDomain: "zhihu.com",
|
|
SiteKey: "zhihu.com",
|
|
ResolutionStatus: "resolved",
|
|
ResolutionConfidence: "high",
|
|
},
|
|
{
|
|
URL: "https://mp.weixin.qq.com/s/geo-demo-monitoring",
|
|
Title: "北京口腔医院全面测评",
|
|
NormalizedURL: "https://mp.weixin.qq.com/s/geo-demo-monitoring",
|
|
Host: "mp.weixin.qq.com",
|
|
RegistrableDomain: "weixin.qq.com",
|
|
SiteKey: "weixin.qq.com",
|
|
ArticleID: &state.ArticleID,
|
|
ResolutionStatus: "resolved",
|
|
ResolutionConfidence: "high",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
BusinessDate: now,
|
|
CompletedAt: now.Add(-38 * time.Minute),
|
|
QuestionID: state.QuestionPrimaryID,
|
|
QuestionText: "北京口腔医院推荐?",
|
|
QuestionHash: hashes[state.QuestionPrimaryID],
|
|
AIPlatformID: "doubao",
|
|
ProviderModel: "doubao-seed-2-0-mini-260215",
|
|
AnswerText: "如果你希望流程顺畅、医生解释细致,`轻氧口腔` 值得优先了解;如果预算非常敏感,可以再横向看看公立口腔医院。",
|
|
BrandMentioned: true,
|
|
MentionPosition: "mentioned",
|
|
FirstRecommended: false,
|
|
SentimentLabel: "positive",
|
|
MatchedBrandTerms: []string{"轻氧口腔"},
|
|
Citations: []citationSeed{
|
|
{
|
|
URL: "https://www.haodf.com/neirong/wenzhang/99887766.html",
|
|
Title: "北京口腔医院就诊经验",
|
|
NormalizedURL: "https://www.haodf.com/neirong/wenzhang/99887766.html",
|
|
Host: "www.haodf.com",
|
|
RegistrableDomain: "haodf.com",
|
|
SiteKey: "haodf.com",
|
|
ResolutionStatus: "resolved",
|
|
ResolutionConfidence: "medium",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
BusinessDate: now,
|
|
CompletedAt: now.Add(-18 * time.Minute),
|
|
QuestionID: state.QuestionSecondaryID,
|
|
QuestionText: "北京种植牙哪家靠谱?",
|
|
QuestionHash: hashes[state.QuestionSecondaryID],
|
|
AIPlatformID: "deepseek",
|
|
ProviderModel: "deepseek-chat",
|
|
AnswerText: "在种植牙方案说明、价格透明度和术后复查安排上,`轻氧口腔` 的体验更稳定,适合作为首选参考。",
|
|
BrandMentioned: true,
|
|
MentionPosition: "top1",
|
|
FirstRecommended: true,
|
|
SentimentLabel: "positive",
|
|
MatchedBrandTerms: []string{"轻氧口腔"},
|
|
Citations: []citationSeed{
|
|
{
|
|
URL: "https://mp.weixin.qq.com/s/geo-demo-monitoring",
|
|
Title: "北京口腔医院全面测评",
|
|
NormalizedURL: "https://mp.weixin.qq.com/s/geo-demo-monitoring",
|
|
Host: "mp.weixin.qq.com",
|
|
RegistrableDomain: "weixin.qq.com",
|
|
SiteKey: "weixin.qq.com",
|
|
ArticleID: &state.ArticleID,
|
|
ResolutionStatus: "resolved",
|
|
ResolutionConfidence: "high",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
BusinessDate: now,
|
|
CompletedAt: now.Add(-42 * time.Minute),
|
|
QuestionID: state.QuestionTertiaryID,
|
|
QuestionText: "北京牙齿矫正医院怎么选?",
|
|
QuestionHash: hashes[state.QuestionTertiaryID],
|
|
AIPlatformID: "doubao",
|
|
ProviderModel: "doubao-seed-2-0-mini-260215",
|
|
AnswerText: "正畸更看重医生方案沟通和复诊便利性,`轻氧口腔` 的优势在于复诊节奏安排比较清晰。",
|
|
BrandMentioned: true,
|
|
MentionPosition: "mentioned",
|
|
FirstRecommended: false,
|
|
SentimentLabel: "positive",
|
|
MatchedBrandTerms: []string{"轻氧口腔"},
|
|
Citations: []citationSeed{
|
|
{
|
|
URL: "https://www.zhihu.com/question/9988123",
|
|
Title: "牙齿矫正医院怎么挑?",
|
|
NormalizedURL: "https://www.zhihu.com/question/9988123",
|
|
Host: "www.zhihu.com",
|
|
RegistrableDomain: "zhihu.com",
|
|
SiteKey: "zhihu.com",
|
|
ResolutionStatus: "resolved",
|
|
ResolutionConfidence: "high",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
BusinessDate: now.AddDate(0, 0, -1),
|
|
CompletedAt: now.AddDate(0, 0, -1).Add(9*time.Hour + 18*time.Minute),
|
|
QuestionID: state.QuestionPrimaryID,
|
|
QuestionText: "北京口腔医院推荐?",
|
|
QuestionHash: hashes[state.QuestionPrimaryID],
|
|
AIPlatformID: "deepseek",
|
|
ProviderModel: "deepseek-chat",
|
|
AnswerText: "昨天的样本里,`轻氧口腔` 依然是优先推荐项,主要原因是体验稳定和医生解释清楚。",
|
|
BrandMentioned: true,
|
|
MentionPosition: "top1",
|
|
FirstRecommended: true,
|
|
SentimentLabel: "positive",
|
|
MatchedBrandTerms: []string{"轻氧口腔"},
|
|
Citations: []citationSeed{
|
|
{
|
|
URL: "https://www.zhihu.com/question/123456",
|
|
Title: "北京口腔医院哪家比较好?",
|
|
NormalizedURL: "https://www.zhihu.com/question/123456",
|
|
Host: "www.zhihu.com",
|
|
RegistrableDomain: "zhihu.com",
|
|
SiteKey: "zhihu.com",
|
|
ResolutionStatus: "resolved",
|
|
ResolutionConfidence: "high",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, item := range runs {
|
|
var runID int64
|
|
err := tx.QueryRow(ctx, `
|
|
INSERT INTO question_monitor_runs (
|
|
tenant_id, brand_id, question_id, question_text_snapshot, question_hash,
|
|
ai_platform_id, collector_type, trigger_source, run_mode, business_date,
|
|
provider_model, raw_answer_text, status, completed_at, raw_response_json
|
|
)
|
|
VALUES (
|
|
$1, $2, $3, $4, $5, $6, 'plugin', 'automatic', 'plugin_standard', $7::date,
|
|
$8, $9, 'succeeded', $10, $11::jsonb
|
|
)
|
|
RETURNING id
|
|
`, state.TenantID, state.BrandID, item.QuestionID, item.QuestionText, item.QuestionHash, item.AIPlatformID,
|
|
item.BusinessDate.Format("2006-01-02"), item.ProviderModel, item.AnswerText, item.CompletedAt,
|
|
mustJSON(map[string]any{"source": "dev-seed", "platform": item.AIPlatformID}),
|
|
).Scan(&runID)
|
|
if err != nil {
|
|
return wrapSeedErr("insert question_monitor_runs", err)
|
|
}
|
|
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO question_monitor_parse_results (
|
|
tenant_id, run_id, brand_id, question_id, question_hash, ai_platform_id,
|
|
business_date, brand_mentioned, brand_mention_position, first_recommended,
|
|
sentiment_label, matched_brand_terms, parse_version, parse_status
|
|
)
|
|
VALUES (
|
|
$1, $2, $3, $4, $5, $6, $7::date, $8, $9, $10, $11, $12::jsonb, 'v1', 'succeeded'
|
|
)
|
|
`, state.TenantID, runID, state.BrandID, item.QuestionID, item.QuestionHash, item.AIPlatformID,
|
|
item.BusinessDate.Format("2006-01-02"), item.BrandMentioned, item.MentionPosition, item.FirstRecommended,
|
|
item.SentimentLabel, mustJSON(item.MatchedBrandTerms),
|
|
)
|
|
if err != nil {
|
|
return wrapSeedErr("insert question_monitor_parse_results", err)
|
|
}
|
|
|
|
for _, citation := range item.Citations {
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO monitoring_citation_facts (
|
|
tenant_id, run_id, brand_id, ai_platform_id, business_date, cited_url,
|
|
cited_title, normalized_url, host, registrable_domain, site_key,
|
|
article_id, publish_record_id, resolution_status, resolution_confidence
|
|
)
|
|
VALUES (
|
|
$1, $2, $3, $4, $5::date, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15
|
|
)
|
|
`, state.TenantID, runID, state.BrandID, item.AIPlatformID, item.BusinessDate.Format("2006-01-02"),
|
|
citation.URL, citation.Title, citation.NormalizedURL, citation.Host, citation.RegistrableDomain,
|
|
citation.SiteKey, citation.ArticleID, citation.PublishRecordID, citation.ResolutionStatus, citation.ResolutionConfidence,
|
|
)
|
|
if err != nil {
|
|
return wrapSeedErr("insert monitoring_citation_facts", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func makeQuestionHash(text string) []byte {
|
|
normalized := strings.TrimSpace(strings.ToLower(text))
|
|
sum := sha256.Sum256([]byte(normalized))
|
|
return sum[:16]
|
|
}
|
|
|
|
func mustJSON(value any) string {
|
|
data, _ := json.Marshal(value)
|
|
return string(data)
|
|
}
|
|
|
|
func stringPtr(value string) *string {
|
|
return &value
|
|
}
|
|
|
|
func wrapSeedErr(step string, err error) error {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("%s: %w", step, err)
|
|
}
|