fix: deliver validated AI insight responses

This commit is contained in:
2026-08-22 15:47:38 +08:00
parent ca55c2fb3d
commit 3fba4261ee
68 changed files with 6870 additions and 493 deletions

173
cmd/admin-reset-ai/main.go Normal file
View File

@@ -0,0 +1,173 @@
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"log"
"os"
"strings"
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/config"
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/database"
"github.com/jackc/pgx/v5"
)
type rowCount struct {
Before int64 `json:"before"`
After int64 `json:"after"`
}
func main() {
apply := flag.Bool("apply", false, "commit the reset; without this flag the transaction is rolled back")
keysFlag := flag.String("terminals", "T83,T88", "comma-separated exact terminal source keys")
flag.Parse()
keys := splitKeys(*keysFlag)
if len(keys) != 2 || keys[0] != "T83" || keys[1] != "T88" {
log.Fatal("this operation is deliberately scoped to exactly T83,T88")
}
cfg, err := config.Load()
if err != nil {
log.Fatal(err)
}
ctx := context.Background()
pool, err := database.Open(ctx, cfg.DatabaseURL)
if err != nil {
log.Fatal(err)
}
defer pool.Close()
tx, err := pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.Serializable})
if err != nil {
log.Fatal(err)
}
defer func() { _ = tx.Rollback(ctx) }()
if _, err := tx.Exec(ctx, `CREATE TEMP TABLE reset_targets(terminal_id bigint PRIMARY KEY,merchant_id bigint NOT NULL,source_key text NOT NULL UNIQUE) ON COMMIT DROP`); err != nil {
log.Fatal(err)
}
for _, key := range keys {
var count int
if err := tx.QueryRow(ctx, `SELECT count(*) FROM terminals WHERE source_key=$1`, key).Scan(&count); err != nil {
log.Fatal(err)
}
if count != 1 {
log.Fatalf("terminal %s resolved to %d records; refusing reset", key, count)
}
if _, err := tx.Exec(ctx, `INSERT INTO reset_targets SELECT id,merchant_id,source_key FROM terminals WHERE source_key=$1`, key); err != nil {
log.Fatal(err)
}
}
if _, err := tx.Exec(ctx, `CREATE TEMP TABLE reset_entity_ids(entity_id text PRIMARY KEY) ON COMMIT DROP`); err != nil {
log.Fatal(err)
}
if _, err := tx.Exec(ctx, `INSERT INTO reset_entity_ids
SELECT id FROM growth_coach_profiles WHERE terminal_id IN(SELECT terminal_id FROM reset_targets)
UNION SELECT id FROM growth_coach_plans WHERE terminal_id IN(SELECT terminal_id FROM reset_targets)
UNION SELECT id FROM growth_coach_recommendations WHERE terminal_id IN(SELECT terminal_id FROM reset_targets)
UNION SELECT id FROM golden_steps WHERE terminal_id IN(SELECT terminal_id FROM reset_targets)
UNION SELECT id FROM monthly_goals WHERE terminal_id IN(SELECT terminal_id FROM reset_targets)
UNION SELECT s.id FROM goal_subtasks s JOIN monthly_goals g ON g.id=s.goal_id WHERE g.terminal_id IN(SELECT terminal_id FROM reset_targets)
UNION SELECT p.id FROM goal_deadline_proposals p JOIN monthly_goals g ON g.id=p.goal_id WHERE g.terminal_id IN(SELECT terminal_id FROM reset_targets)
UNION SELECT id FROM growth_coach_jobs WHERE terminal_id IN(SELECT terminal_id FROM reset_targets)
UNION SELECT id FROM growth_coach_difficulty_proposals WHERE terminal_id IN(SELECT terminal_id FROM reset_targets)`); err != nil {
log.Fatal(err)
}
counts := map[string]rowCount{}
for table, predicate := range resetScopes() {
before, err := countRows(ctx, tx, table, predicate)
if err != nil {
log.Fatal(err)
}
counts[table] = rowCount{Before: before}
}
statements := []string{
`INSERT INTO ai_generation_holds(terminal_id,merchant_id,reason) SELECT terminal_id,merchant_id,'ONBOARDING_REQUIRED' FROM reset_targets ON CONFLICT(terminal_id) DO UPDATE SET reason='ONBOARDING_REQUIRED',created_at=now()`,
`DELETE FROM growth_report_idempotency i USING growth_reports r,reset_targets t WHERE i.report_id=r.id AND r.terminal_id=t.terminal_id`,
`DELETE FROM growth_reports r USING reset_targets t WHERE r.terminal_id=t.terminal_id`,
`DELETE FROM ai_insight_refresh_attempts a USING reset_targets t WHERE a.merchant_id=t.merchant_id AND a.terminal_source_key=t.source_key`,
`DELETE FROM ai_insight_snapshots s USING reset_targets t WHERE s.terminal_id=t.terminal_id`,
`DELETE FROM goal_deadline_proposals p USING monthly_goals g,reset_targets t WHERE p.goal_id=g.id AND g.terminal_id=t.terminal_id`,
`DELETE FROM goal_subtasks s USING monthly_goals g,reset_targets t WHERE s.goal_id=g.id AND g.terminal_id=t.terminal_id`,
`DELETE FROM monthly_goals g USING reset_targets t WHERE g.terminal_id=t.terminal_id`,
`DELETE FROM growth_coach_progress_snapshots p USING golden_steps s,reset_targets t WHERE p.action_id=s.id AND s.terminal_id=t.terminal_id`,
`DELETE FROM golden_steps s USING reset_targets t WHERE s.terminal_id=t.terminal_id`,
`DELETE FROM growth_coach_outcome_reviews o USING growth_coach_plans p,reset_targets t WHERE o.plan_id=p.id AND p.terminal_id=t.terminal_id`,
`DELETE FROM growth_coach_recommendations r USING reset_targets t WHERE r.terminal_id=t.terminal_id`,
`DELETE FROM growth_coach_events e USING reset_targets t WHERE e.terminal_id=t.terminal_id`,
`DELETE FROM growth_coach_difficulty_proposals p USING reset_targets t WHERE p.terminal_id=t.terminal_id`,
`DELETE FROM growth_coach_jobs j USING reset_targets t WHERE j.terminal_id=t.terminal_id`,
`DELETE FROM growth_coach_plans p USING reset_targets t WHERE p.terminal_id=t.terminal_id`,
`DELETE FROM growth_coach_idempotency i USING reset_targets t WHERE i.merchant_id=t.merchant_id AND (i.response::text LIKE '%"terminalId":"'||t.source_key||'"%' OR EXISTS(SELECT 1 FROM reset_entity_ids e WHERE i.response::text LIKE '%'||e.entity_id||'%'))`,
`DELETE FROM growth_coach_profiles p USING reset_targets t WHERE p.terminal_id=t.terminal_id`,
}
for _, statement := range statements {
if _, err := tx.Exec(ctx, statement); err != nil {
log.Fatal(err)
}
}
for table, predicate := range resetScopes() {
after, err := countRows(ctx, tx, table, predicate)
if err != nil {
log.Fatal(err)
}
value := counts[table]
value.After = after
counts[table] = value
}
var holds int64
if err := tx.QueryRow(ctx, `SELECT count(*) FROM ai_generation_holds h JOIN reset_targets t ON t.terminal_id=h.terminal_id WHERE h.reason='ONBOARDING_REQUIRED'`).Scan(&holds); err != nil {
log.Fatal(err)
}
result := map[string]any{"mode": "dry-run", "terminals": keys, "rows": counts, "paymentsDeleted": 0, "generationHolds": holds}
if *apply {
if err := tx.Commit(ctx); err != nil {
log.Fatal(err)
}
result["mode"] = "applied"
} else if err := tx.Rollback(ctx); err != nil {
log.Fatal(err)
}
encoded, _ := json.MarshalIndent(result, "", " ")
_, _ = os.Stdout.Write(append(encoded, '\n'))
}
func splitKeys(value string) []string {
parts := strings.Split(value, ",")
result := make([]string, 0, len(parts))
for _, part := range parts {
if item := strings.TrimSpace(part); item != "" {
result = append(result, item)
}
}
return result
}
func resetScopes() map[string]string {
return map[string]string{
"growth_reports": `terminal_id IN(SELECT terminal_id FROM reset_targets)`,
"growth_report_idempotency": `report_id IN(SELECT id FROM growth_reports WHERE terminal_id IN(SELECT terminal_id FROM reset_targets))`,
"ai_insight_snapshots": `terminal_id IN(SELECT terminal_id FROM reset_targets)`,
"ai_insight_jobs": `snapshot_id IN(SELECT id FROM ai_insight_snapshots WHERE terminal_id IN(SELECT terminal_id FROM reset_targets))`,
"ai_insight_events": `job_id IN(SELECT id FROM ai_insight_jobs WHERE snapshot_id IN(SELECT id FROM ai_insight_snapshots WHERE terminal_id IN(SELECT terminal_id FROM reset_targets)))`,
"ai_insight_refresh_attempts": `(merchant_id,terminal_source_key) IN(SELECT merchant_id,source_key FROM reset_targets)`,
"growth_coach_profiles": `terminal_id IN(SELECT terminal_id FROM reset_targets)`,
"growth_coach_plans": `terminal_id IN(SELECT terminal_id FROM reset_targets)`,
"growth_coach_recommendations": `terminal_id IN(SELECT terminal_id FROM reset_targets)`,
"golden_steps": `terminal_id IN(SELECT terminal_id FROM reset_targets)`,
"monthly_goals": `terminal_id IN(SELECT terminal_id FROM reset_targets)`,
"goal_subtasks": `goal_id IN(SELECT id FROM monthly_goals WHERE terminal_id IN(SELECT terminal_id FROM reset_targets))`,
"goal_deadline_proposals": `goal_id IN(SELECT id FROM monthly_goals WHERE terminal_id IN(SELECT terminal_id FROM reset_targets))`,
"growth_coach_progress_snapshots": `action_id IN(SELECT id FROM golden_steps WHERE terminal_id IN(SELECT terminal_id FROM reset_targets))`,
"growth_coach_jobs": `terminal_id IN(SELECT terminal_id FROM reset_targets)`,
"growth_coach_events": `terminal_id IN(SELECT terminal_id FROM reset_targets)`,
"growth_coach_difficulty_proposals": `terminal_id IN(SELECT terminal_id FROM reset_targets)`,
"growth_coach_outcome_reviews": `plan_id IN(SELECT id FROM growth_coach_plans WHERE terminal_id IN(SELECT terminal_id FROM reset_targets))`,
"growth_coach_idempotency": `EXISTS(SELECT 1 FROM reset_targets t WHERE growth_coach_idempotency.merchant_id=t.merchant_id AND (growth_coach_idempotency.response::text LIKE '%"terminalId":"'||t.source_key||'"%' OR EXISTS(SELECT 1 FROM reset_entity_ids e WHERE growth_coach_idempotency.response::text LIKE '%'||e.entity_id||'%')))`,
}
}
func countRows(ctx context.Context, tx pgx.Tx, table, predicate string) (int64, error) {
var count int64
err := tx.QueryRow(ctx, fmt.Sprintf("SELECT count(*) FROM %s WHERE %s", table, predicate)).Scan(&count)
return count, err
}

View File

@@ -11,6 +11,7 @@ import (
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/analytics"
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/api"
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/coach"
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/config"
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/database"
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/growthreport"
@@ -45,7 +46,14 @@ func main() {
growthService := growthreport.NewService(repository, coordinator, growthreport.ServiceConfig{Enabled: cfg.AIGrowthEnabled, Model: cfg.AvalAIModel, PromptVersion: "1.0.0", SchemaVersion: "1.0.0"})
insightRepository := insight.NewRepository(pool)
insightService := insight.NewService(insightRepository, insight.NewPostgreSQLBuilder(pool))
handler := api.NewServer(identity.New(pool), analytics.New(pool, cfg.CursorSecret), logger, cfg.FrontendOrigin, cfg.CookieSecure, growthService).WithInsights(insightService).Handler()
appServer := api.NewServer(identity.New(pool), analytics.New(pool, cfg.CursorSecret), logger, cfg.FrontendOrigin, cfg.CookieSecure, growthService)
if cfg.AIPageInsightsEnabled {
appServer.WithInsights(insightService)
}
if cfg.AICoachEnabled {
appServer.WithCoach(coach.NewService(coach.NewRepository(pool), nil))
}
handler := appServer.Handler()
server := &http.Server{Addr: cfg.HTTPAddr, Handler: handler, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 15 * time.Second, WriteTimeout: 65 * time.Second, IdleTimeout: 60 * time.Second}
go func() {
<-ctx.Done()

View File

@@ -6,6 +6,7 @@ import (
"os"
"os/signal"
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/coach"
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/config"
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/database"
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/importer"
@@ -42,5 +43,10 @@ func main() {
os.Exit(1)
}
logger.Info("post-import insight scheduling complete", "created_jobs", created)
coachService := coach.NewService(coach.NewRepository(pool), nil)
if err := coachService.OnPaymentUpdate(ctx, result.SourceSHA256); err != nil {
logger.Error("post-import coach evaluation failed", "error", err)
os.Exit(1)
}
}
}

View File

@@ -25,10 +25,14 @@ func main() {
os.Exit(1)
}
defer pool.Close()
if _, err := pool.Exec(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (version bigint PRIMARY KEY,applied_at timestamptz NOT NULL DEFAULT now())`); err != nil {
logger.Error("migration bootstrap failed", "error", err)
os.Exit(1)
}
migrations := []struct {
version int64
path string
}{{1, "migrations/000001_mvp.sql"}, {2, "migrations/000002_growth_analyst.sql"}, {3, "migrations/000003_ai_summary_refresh.sql"}, {4, "migrations/000004_ai_page_insights.sql"}}
}{{1, "migrations/000001_mvp.sql"}, {2, "migrations/000002_growth_analyst.sql"}, {3, "migrations/000003_ai_summary_refresh.sql"}, {4, "migrations/000004_ai_page_insights.sql"}, {5, "migrations/000005_growth_coach.sql"}, {6, "migrations/000006_growth_coach_completion_guard.sql"}, {7, "migrations/000007_growth_coach_manual_completion_provenance.sql"}, {8, "migrations/000008_section_refresh_monthly_goals.sql"}, {9, "migrations/000009_ranked_campaign_calendar.sql"}, {10, "migrations/000010_transaction_insight_period_scope.sql"}}
for _, item := range migrations {
var applied bool
if err := pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE version=$1)`, item.version).Scan(&applied); err != nil {
@@ -49,5 +53,5 @@ func main() {
os.Exit(1)
}
}
logger.Info("migration complete", "version", 4)
logger.Info("migration complete", "version", 10)
}

57
cmd/scheduler/main.go Normal file
View File

@@ -0,0 +1,57 @@
package main
import (
"context"
"log/slog"
"os"
"os/signal"
"time"
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/coach"
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/config"
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/database"
)
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
cfg, err := config.Load()
if err != nil {
logger.Error("configuration failed", "error", err)
os.Exit(1)
}
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
defer cancel()
if !cfg.AICoachEnabled {
logger.Info("growth coach scheduler disabled")
<-ctx.Done()
return
}
pool, err := database.Open(ctx, cfg.DatabaseURL)
if err != nil {
logger.Error("database connection failed", "error", err)
os.Exit(1)
}
defer pool.Close()
service := coach.NewService(coach.NewRepository(pool), nil)
run := func() {
runCtx, runCancel := context.WithTimeout(ctx, time.Minute)
defer runCancel()
created, err := service.ScheduleDue(runCtx)
if err != nil {
logger.Error("coach scheduling failed", "error", err)
return
}
logger.Info("coach scheduling complete", "created_jobs", created)
}
run()
for {
timer := time.NewTimer(time.Until(coach.NextTehranMidnight(time.Now())))
select {
case <-ctx.Done():
timer.Stop()
return
case <-timer.C:
run()
}
}
}

View File

@@ -9,6 +9,8 @@ import (
"os/signal"
"time"
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/aiprovider"
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/coach"
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/config"
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/database"
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/growthreport"
@@ -41,29 +43,55 @@ func main() {
coordinator := growthreport.NewCoordinator(redisClient, growthreport.CoordinatorConfig{CreatePerMinute: cfg.AICreatePerMinute, UncachedPerDay: cfg.AIUncachedScopesPerDay, DailyRequests: cfg.AIDailyRequestBudget, DailyTokens: cfg.AIDailyTokenBudget, MaxConcurrent: cfg.AIMaxConcurrent})
avalai := growthreport.NewAvalAIClient(growthreport.AvalAIConfig{BaseURL: cfg.AvalAIBaseURL, APIKey: cfg.AvalAIAPIKey, Model: cfg.AvalAIModel, Timeout: 30 * time.Second}, &http.Client{})
processor := growthreport.NewProcessor(repository, growthreport.NewPostgreSQLBuilder(pool), avalai, coordinator, cfg.AIGrowthEnabled && cfg.AvalAIAPIKey != "")
insightService := insight.NewService(insight.NewRepository(pool), insight.NewPostgreSQLBuilder(pool))
sharedProvider := aiprovider.New(aiprovider.Config{BaseURL: cfg.AvalAIBaseURL, APIKey: cfg.AvalAIAPIKey, Model: cfg.AvalAIModel, Timeout: 30 * time.Second}, &http.Client{})
var insightService *insight.Service
if cfg.AIPageInsightsEnabled && cfg.AvalAIAPIKey != "" {
insightService = insight.NewService(insight.NewRepository(pool), insight.NewPostgreSQLBuilder(pool), insight.NewAvalAIGenerator(sharedProvider)).WithBudget(cfg.AIPageInsightsDailyRequestBudget, cfg.AIPageInsightsDailyTokenBudget)
} else {
insightService = insight.NewService(insight.NewRepository(pool), insight.NewPostgreSQLBuilder(pool))
}
var coachGenerator coach.Generator
if cfg.AICoachEnabled && cfg.AvalAIAPIKey != "" {
coachGenerator = coach.NewAvalAIGenerator(sharedProvider)
}
coachService := coach.NewService(coach.NewRepository(pool), coachGenerator).WithBudget(cfg.AICoachDailyRequestBudget, cfg.AICoachDailyTokenBudget)
logger.Info("growth worker ready", "ai_enabled", cfg.AIGrowthEnabled && cfg.AvalAIAPIKey != "")
poll := time.NewTicker(time.Second)
defer poll.Stop()
cleanup := time.NewTicker(24 * time.Hour)
defer cleanup.Stop()
go runInsightScheduler(ctx, logger, insightService)
evaluation := time.NewTicker(time.Minute)
defer evaluation.Stop()
if cfg.AIPageInsightsEnabled {
go runInsightScheduler(ctx, logger, insightService)
}
for {
select {
case <-ctx.Done():
return
case <-poll.C:
var err error
if cfg.AIPageInsightsEnabled {
insightCtx, insightCancel := context.WithTimeout(ctx, 2*time.Minute)
err = insightService.ProcessNext(insightCtx)
insightCancel()
if err != nil && !errors.Is(err, insight.ErrNotFound) {
logger.Error("page insight processing failed", "error", err)
}
}
workCtx, workCancel := context.WithTimeout(ctx, 2*time.Minute)
err := processor.ProcessNext(workCtx)
err = processor.ProcessNext(workCtx)
workCancel()
if err != nil && !errors.Is(err, growthreport.ErrNotFound) {
logger.Error("growth report processing failed", "error", err)
}
insightCtx, insightCancel := context.WithTimeout(ctx, 2*time.Minute)
err = insightService.ProcessNext(insightCtx)
insightCancel()
if err != nil && !errors.Is(err, insight.ErrNotFound) {
logger.Error("page insight processing failed", "error", err)
if cfg.AICoachEnabled {
coachCtx, coachCancel := context.WithTimeout(ctx, 2*time.Minute)
err = coachService.ProcessNext(coachCtx)
coachCancel()
if err != nil && !errors.Is(err, coach.ErrNotFound) {
logger.Error("growth coach processing failed", "error", err)
}
}
case <-cleanup.C:
cleanupCtx, cleanupCancel := context.WithTimeout(ctx, 30*time.Second)
@@ -74,6 +102,25 @@ func main() {
} else if deleted > 0 {
logger.Info("growth cleanup complete", "deleted_reports", deleted)
}
case <-evaluation.C:
if cfg.AICoachEnabled {
evaluationCtx, evaluationCancel := context.WithTimeout(ctx, time.Minute)
completed, err := coachService.EvaluateMetrics(evaluationCtx)
evaluationCancel()
if err != nil {
logger.Error("coach metric evaluation failed", "error", err)
} else if completed > 0 {
logger.Info("coach metric goals completed", "count", completed)
}
difficultyCtx, difficultyCancel := context.WithTimeout(ctx, time.Minute)
proposals, err := coachService.EvaluateDifficulty(difficultyCtx)
difficultyCancel()
if err != nil {
logger.Error("coach difficulty evaluation failed", "error", err)
} else if proposals > 0 {
logger.Info("coach difficulty proposals created", "count", proposals)
}
}
}
}
}