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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user