feat: Introduce AI Brand Monitoring System V5 technical design document

- Added comprehensive technical design document for AI Brand Monitoring System V5, outlining system architecture, data models, sampling strategies, and monitoring protocols.
- Key changes include a shift to a sampling-based trend monitoring approach, updated data collection and storage strategies, and new metrics for performance evaluation.
- Implemented migration scripts to support the flattening of brand questions and versioning of question texts, ensuring historical data integrity and version control.
This commit is contained in:
2026-04-09 14:43:20 +08:00
parent 41f8e0621e
commit 36451a613d
18 changed files with 2709 additions and 234 deletions
+33 -74
View File
@@ -2,7 +2,6 @@ package app
import (
"context"
"crypto/sha256"
"encoding/json"
"fmt"
"strings"
@@ -276,7 +275,7 @@ func (s *BrandService) DeleteKeyword(ctx context.Context, brandID, keywordID int
return nil
}
// --- Question CRUD with versioning ---
// --- Question CRUD ---
type QuestionRequest struct {
KeywordID int64 `json:"keyword_id" binding:"required"`
@@ -284,23 +283,19 @@ type QuestionRequest struct {
}
type QuestionResponse struct {
ID int64 `json:"id"`
BrandID int64 `json:"brand_id"`
KeywordID int64 `json:"keyword_id"`
CurrentVersionID *int64 `json:"current_version_id"`
QuestionText *string `json:"question_text"`
VersionNo *int `json:"version_no"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
ID int64 `json:"id"`
BrandID int64 `json:"brand_id"`
KeywordID int64 `json:"keyword_id"`
QuestionText string `json:"question_text"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
}
func (s *BrandService) ListQuestions(ctx context.Context, brandID int64, keywordID *int64) ([]QuestionResponse, error) {
actor := auth.MustActor(ctx)
query := `
SELECT q.id, q.brand_id, q.keyword_id, q.current_version_id, q.status, q.created_at,
v.question_text, v.version_no
SELECT q.id, q.brand_id, q.keyword_id, q.question_text, q.status, q.created_at
FROM brand_questions q
LEFT JOIN brand_question_versions v ON v.id = q.current_version_id
WHERE q.brand_id = $1 AND q.tenant_id = $2 AND q.deleted_at IS NULL`
args := []interface{}{brandID, actor.TenantID}
@@ -320,7 +315,7 @@ func (s *BrandService) ListQuestions(ctx context.Context, brandID int64, keyword
for rows.Next() {
var q QuestionResponse
var ca interface{}
if err := rows.Scan(&q.ID, &q.BrandID, &q.KeywordID, &q.CurrentVersionID, &q.Status, &ca, &q.QuestionText, &q.VersionNo); err != nil {
if err := rows.Scan(&q.ID, &q.BrandID, &q.KeywordID, &q.QuestionText, &q.Status, &ca); err != nil {
return nil, response.ErrInternal(50010, "scan_failed", err.Error())
}
q.CreatedAt = fmt.Sprintf("%v", ca)
@@ -334,42 +329,29 @@ func (s *BrandService) ListQuestions(ctx context.Context, brandID int64, keyword
func (s *BrandService) CreateQuestion(ctx context.Context, brandID int64, req QuestionRequest) (*QuestionResponse, error) {
actor := auth.MustActor(ctx)
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, fmt.Errorf("begin tx: %w", err)
req.QuestionText = strings.TrimSpace(req.QuestionText)
if req.QuestionText == "" {
return nil, response.ErrBadRequest(40001, "invalid_params", "question_text is required")
}
defer func() {
_ = tx.Rollback(ctx)
}()
var questionID int64
err = tx.QueryRow(ctx, `
INSERT INTO brand_questions (tenant_id, brand_id, keyword_id, status) VALUES ($1, $2, $3, 'active') RETURNING id
`, actor.TenantID, brandID, req.KeywordID).Scan(&questionID)
var ca interface{}
err := s.pool.QueryRow(ctx, `
INSERT INTO brand_questions (tenant_id, brand_id, keyword_id, question_text, status)
VALUES ($1, $2, $3, $4, 'active')
RETURNING id, created_at
`, actor.TenantID, brandID, req.KeywordID, req.QuestionText).Scan(&questionID, &ca)
if err != nil {
return nil, response.ErrInternal(50010, "create_failed", "failed to create question")
}
hash := fmt.Sprintf("%x", sha256.Sum256([]byte(req.QuestionText)))
var versionID int64
err = tx.QueryRow(ctx, `
INSERT INTO brand_question_versions (question_id, question_text, question_hash, version_no, is_active)
VALUES ($1, $2, $3, 1, true) RETURNING id
`, questionID, req.QuestionText, hash).Scan(&versionID)
if err != nil {
return nil, response.ErrInternal(50010, "create_version_failed", "failed to create question version")
}
_, _ = tx.Exec(ctx, `UPDATE brand_questions SET current_version_id = $1 WHERE id = $2`, versionID, questionID)
if err := tx.Commit(ctx); err != nil {
return nil, fmt.Errorf("commit: %w", err)
}
vno := 1
return &QuestionResponse{
ID: questionID, BrandID: brandID, KeywordID: req.KeywordID,
CurrentVersionID: &versionID, QuestionText: &req.QuestionText, VersionNo: &vno, Status: "active",
ID: questionID,
BrandID: brandID,
KeywordID: req.KeywordID,
QuestionText: req.QuestionText,
Status: "active",
CreatedAt: fmt.Sprintf("%v", ca),
}, nil
}
@@ -379,42 +361,19 @@ type UpdateQuestionRequest struct {
func (s *BrandService) UpdateQuestion(ctx context.Context, brandID, questionID int64, req UpdateQuestionRequest) error {
actor := auth.MustActor(ctx)
tx, err := s.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
req.QuestionText = strings.TrimSpace(req.QuestionText)
if req.QuestionText == "" {
return response.ErrBadRequest(40001, "invalid_params", "question_text is required")
}
defer func() {
_ = tx.Rollback(ctx)
}()
// Verify ownership
var exists bool
_ = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM brand_questions WHERE id = $1 AND brand_id = $2 AND tenant_id = $3 AND deleted_at IS NULL)`,
questionID, brandID, actor.TenantID).Scan(&exists)
if !exists {
tag, err := s.pool.Exec(ctx, `
UPDATE brand_questions SET question_text = $1, updated_at = NOW()
WHERE id = $2 AND brand_id = $3 AND tenant_id = $4 AND deleted_at IS NULL
`, req.QuestionText, questionID, brandID, actor.TenantID)
if err != nil || tag.RowsAffected() == 0 {
return response.ErrNotFound(40422, "question_not_found", "question not found")
}
// Deactivate current version
_, _ = tx.Exec(ctx, `UPDATE brand_question_versions SET is_active = false WHERE question_id = $1 AND is_active = true`, questionID)
// Get next version number
var maxVersion int
_ = tx.QueryRow(ctx, `SELECT COALESCE(MAX(version_no), 0) FROM brand_question_versions WHERE question_id = $1`, questionID).Scan(&maxVersion)
hash := fmt.Sprintf("%x", sha256.Sum256([]byte(req.QuestionText)))
var versionID int64
err = tx.QueryRow(ctx, `
INSERT INTO brand_question_versions (question_id, question_text, question_hash, version_no, is_active)
VALUES ($1, $2, $3, $4, true) RETURNING id
`, questionID, req.QuestionText, hash, maxVersion+1).Scan(&versionID)
if err != nil {
return fmt.Errorf("create version: %w", err)
}
_, _ = tx.Exec(ctx, `UPDATE brand_questions SET current_version_id = $1, updated_at = NOW() WHERE id = $2`, versionID, questionID)
return tx.Commit(ctx)
return nil
}
func (s *BrandService) DeleteQuestion(ctx context.Context, brandID, questionID int64) error {