Compare commits
22 Commits
f5e2a21bd9
...
7951664d08
| Author | SHA1 | Date | |
|---|---|---|---|
| 7951664d08 | |||
| d3189ac8f4 | |||
| a15c04cac1 | |||
| 86e9d7c5f9 | |||
| 066adcfd1a | |||
| 70244f7f79 | |||
| 3e2d1f32c8 | |||
| bb4a85cd65 | |||
| d4265b662f | |||
| d00700ffb8 | |||
| dd0ae15a95 | |||
| 49084ea577 | |||
| 6c80f71847 | |||
| 1d8d4edf92 | |||
| 027dffb59e | |||
| 6df21d4fd0 | |||
| 1a00e897ca | |||
| 8a0b30e936 | |||
| df140b4c25 | |||
| 37416a6f20 | |||
| f3f51a97ef | |||
| ae8747c996 |
@@ -11,3 +11,12 @@ SESSION_SECRET=replace-with-at-least-32-random-characters
|
||||
CURSOR_SECRET=replace-with-a-different-32-character-secret
|
||||
FRONTEND_ORIGIN=http://127.0.0.1:5173
|
||||
COOKIE_SECURE=false
|
||||
AI_GROWTH_ENABLED=false
|
||||
AVALAI_API_KEY=
|
||||
AVALAI_BASE_URL=https://api.avalai.ir
|
||||
AVALAI_MODEL=gpt-5-mini-2025-08-07
|
||||
AI_DAILY_REQUEST_BUDGET=0
|
||||
AI_DAILY_TOKEN_BUDGET=0
|
||||
AI_MAX_CONCURRENT=4
|
||||
AI_CREATE_PER_MINUTE=10
|
||||
AI_UNCACHED_SCOPES_PER_DAY=5
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# ZarinPal Merchant Analytics — Backend
|
||||
|
||||
Go backend for merchant identity, dataset ingestion, deterministic analytics, authenticated APIs, and later AvalAI integration.
|
||||
Go backend for merchant identity, dataset ingestion, deterministic analytics, authenticated APIs, and the bounded AvalAI-assisted growth analyst.
|
||||
|
||||
Current state: the local MVP implements database migration, streaming dataset import,
|
||||
secure demo identity, deterministic dashboard analytics, merchant-scoped details/CSV,
|
||||
|
||||
@@ -13,7 +13,9 @@ import (
|
||||
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/api"
|
||||
"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"
|
||||
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/identity"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -35,7 +37,12 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
defer pool.Close()
|
||||
handler := api.NewServer(identity.New(pool), analytics.New(pool, cfg.CursorSecret), logger, cfg.FrontendOrigin, cfg.CookieSecure).Handler()
|
||||
redisClient := redis.NewClient(&redis.Options{Addr: cfg.RedisAddr, Password: cfg.RedisPassword})
|
||||
defer redisClient.Close()
|
||||
repository := growthreport.NewRepository(pool)
|
||||
coordinator := growthreport.NewCoordinator(redisClient, growthreport.CoordinatorConfig{CreatePerMinute: cfg.AICreatePerMinute, UncachedPerDay: cfg.AIUncachedScopesPerDay, DailyRequests: cfg.AIDailyRequestBudget, DailyTokens: cfg.AIDailyTokenBudget, MaxConcurrent: cfg.AIMaxConcurrent})
|
||||
growthService := growthreport.NewService(repository, coordinator, growthreport.ServiceConfig{Enabled: cfg.AIGrowthEnabled, Model: cfg.AvalAIModel, PromptVersion: "1.0.0", SchemaVersion: "1.0.0"})
|
||||
handler := api.NewServer(identity.New(pool), analytics.New(pool, cfg.CursorSecret), logger, cfg.FrontendOrigin, cfg.CookieSecure, growthService).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()
|
||||
|
||||
@@ -25,14 +25,16 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
defer pool.Close()
|
||||
migration, err := os.ReadFile("migrations/000001_mvp.sql")
|
||||
if err != nil {
|
||||
logger.Error("reading migration failed", "error", err)
|
||||
os.Exit(1)
|
||||
for _, path := range []string{"migrations/000001_mvp.sql", "migrations/000002_growth_analyst.sql"} {
|
||||
migration, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
logger.Error("reading migration failed", "path", path, "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := database.Migrate(ctx, pool, string(migration)); err != nil {
|
||||
logger.Error("migration failed", "path", path, "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
if err := database.Migrate(ctx, pool, string(migration)); err != nil {
|
||||
logger.Error("migration failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
logger.Info("migration complete", "version", 1)
|
||||
logger.Info("migration complete", "version", 2)
|
||||
}
|
||||
|
||||
@@ -2,12 +2,16 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
@@ -18,16 +22,49 @@ func main() {
|
||||
logger.Error("configuration failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||
defer cancel()
|
||||
client := redis.NewClient(&redis.Options{Addr: cfg.RedisAddr, Password: cfg.RedisPassword})
|
||||
defer client.Close()
|
||||
pingCtx, pingCancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer pingCancel()
|
||||
if err := client.Ping(pingCtx).Err(); err != nil {
|
||||
logger.Error("redis connection failed", "error", err)
|
||||
if err := cfg.ValidateAPI(); err != nil {
|
||||
logger.Error("worker configuration failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
logger.Info("analytics worker ready", "redis_address", cfg.RedisAddr)
|
||||
<-ctx.Done()
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||
defer cancel()
|
||||
pool, err := database.Open(ctx, cfg.DatabaseURL)
|
||||
if err != nil {
|
||||
logger.Error("database connection failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer pool.Close()
|
||||
redisClient := redis.NewClient(&redis.Options{Addr: cfg.RedisAddr, Password: cfg.RedisPassword})
|
||||
defer redisClient.Close()
|
||||
repository := growthreport.NewRepository(pool)
|
||||
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 != "")
|
||||
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()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-poll.C:
|
||||
workCtx, workCancel := context.WithTimeout(ctx, 2*time.Minute)
|
||||
err := processor.ProcessNext(workCtx)
|
||||
workCancel()
|
||||
if err != nil && !errors.Is(err, growthreport.ErrNotFound) {
|
||||
logger.Error("growth report processing failed", "error", err)
|
||||
}
|
||||
case <-cleanup.C:
|
||||
cleanupCtx, cleanupCancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
deleted, err := repository.CleanupSuperseded(cleanupCtx, time.Now().UTC().AddDate(0, 0, -90))
|
||||
cleanupCancel()
|
||||
if err != nil {
|
||||
logger.Error("growth cleanup failed", "error", err)
|
||||
} else if deleted > 0 {
|
||||
logger.Info("growth cleanup complete", "deleted_reports", deleted)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ Turn normalized ZarinPal payment facts into trustworthy, merchant-scoped analyti
|
||||
- Merchant API: requests versioned analytics sections and exposes typed results/events.
|
||||
- Dashboard: renders sections independently as they become ready.
|
||||
- Analytics worker: calculates idempotent section results from PostgreSQL and caches approved aggregates in Redis.
|
||||
- Future AI adapter: post-MVP consumer of minimized, approved aggregate facts only.
|
||||
- AI growth analyst: separately approved consumer of minimized, approved aggregate facts only; see ADR 0015 and `GROWTH-ANALYST-SPEC.md`.
|
||||
|
||||
## In scope
|
||||
|
||||
@@ -36,9 +36,11 @@ Turn normalized ZarinPal payment facts into trustworthy, merchant-scoped analyti
|
||||
|
||||
## Out of scope for MVP
|
||||
|
||||
- Holiday or named-event reporting
|
||||
- AI-generated recommendations or free-form LLM analysis
|
||||
- Forecasting, causal claims, or automated campaign execution
|
||||
The exclusions in this section describe the original four-section analytics MVP. The later, separately approved AI growth analyst adds bounded event analytics and forecasting without changing the canonical MVP period or metric contracts.
|
||||
|
||||
- Holiday or named-event reporting inside the original four analytics sections
|
||||
- Unbounded or free-form LLM analysis
|
||||
- Causal claims or automated campaign execution
|
||||
- Arbitrary merchant-created formulas or cross-merchant filters
|
||||
- Real-time streaming payment ingestion owned by this service
|
||||
- Persisting calculated analytics as PostgreSQL business facts
|
||||
@@ -64,7 +66,7 @@ Every section uses a versioned typed contract and has independent `queued`, `cal
|
||||
- Timing percentiles exclude null values and expose sample size and non-null coverage; missing time never becomes zero.
|
||||
- `NoAttempt` markers create zero-attempt sessions and never count as attempts or retries. `InBank` is an observed attempt stage, not proof of rejection.
|
||||
- Every result states the exact Tehran-local half-open period, Jalali label, unit, sample/denominator, dataset snapshot, metric-definition version, and calculation timestamp.
|
||||
- Approved period types are one Tehran-local day, one Saturday–Friday week, one Jalali month, or one Persian season. Holiday/event dimensions do not exist in the MVP contract.
|
||||
- Approved interactive reporting-period types remain one Tehran-local day, one Saturday–Friday week, one Jalali month, or one Persian season. The separately versioned growth-event catalog is evidence for growth reports, not a new interactive reporting-period type.
|
||||
|
||||
## Peer comparison contract
|
||||
|
||||
|
||||
@@ -216,7 +216,7 @@ Resolved: use normalized PostgreSQL source-of-truth entities plus a reporting-da
|
||||
|
||||
Resolved: the MVP reads one immutable completed-import snapshot and performs no payment-fact mutations. An idempotent `POST` starts four sections through Asynq `v0.26.0` (explicitly pre-1.0) in a separate Go worker, limited to 4 concurrent jobs/merchant and 16 globally. Redis caches results for 30 days; authenticated SSE uses 15-second heartbeats and one-hour replay. Sections have a 60-second attempt timeout and two transient exponential-backoff retries. Start, SSE, and manual-retry operations are rate-limited. No PostgreSQL cache-invalidation trigger exists in the MVP. See ADR 0011.
|
||||
|
||||
Resolved: holiday and named-event reporting are removed from scope. MVP reporting periods are limited to daily, weekly, Jalali-month, and Persian-season views. See ADR 0010.
|
||||
Resolved: holiday and named-event reporting were removed from the original analytics MVP, whose interactive reporting periods remain daily, weekly, Jalali-month, and Persian-season views. The later growth analyst adds a separate reviewed event-evidence catalog under ADR 0015 without changing those reporting-period contracts. See ADRs 0010 and 0015.
|
||||
|
||||
Resolved: reporting weeks follow the Iranian week: Saturday through Friday in `Asia/Tehran`, represented as half-open API/database ranges. See ADR 0012.
|
||||
|
||||
|
||||
60
docs/GROWTH-ANALYST-SPEC.md
Normal file
60
docs/GROWTH-ANALYST-SPEC.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# Growth Analyst Capability Spec
|
||||
|
||||
- Status: Approved — implementation authorized 2026-08-21
|
||||
- Capability: Read-only AI-assisted growth analytics
|
||||
- Public contract: `docs/openapi.yaml`
|
||||
- UI contract: frontend `docs/GROWTH-ANALYST-UX-SPEC.md`
|
||||
|
||||
## Objective
|
||||
|
||||
Give an authenticated merchant a trustworthy 90-day Persian growth outlook: historical event wins, upcoming similar occasions, likely low-performance windows, privacy-safe peer context, and compact campaign briefs. PostgreSQL and deterministic Go calculations own every number; AvalAI may only prioritize approved evidence and produce qualitative copy.
|
||||
|
||||
## Deterministic analytics contract
|
||||
|
||||
- Aggregate session count and paid Rial amount by Tehran-local day for the authenticated merchant and optional owned terminal. `Paid` and `Verified` are successful; money remains integer Rial at session grain.
|
||||
- Forecast begins the day after the latest complete imported dataset date and covers 90 days. Count and amount are modeled independently from weekday-aware rolling baselines, bounded trend adjustment, catalogued event uplift, and empirical residual P10/P50/P90 values.
|
||||
- Rolling-origin backtests classify usable output as `HIGH` or `MEDIUM`; other results return `INSUFFICIENT_DATA` and do not call AvalAI.
|
||||
- Historical event effect compares matching weekdays from the preceding eight non-event weeks. At least four observations and 100 baseline sessions are required. A material win/decline is at least 20%.
|
||||
- Upcoming similarity prefers an exact event family, then catalog tag similarity of at least 0.60. Return at most five opportunities and three risks. A risk requires a forecast at least 20% below a baseline with at least 100 sessions.
|
||||
- Merchant scope may include peer context only under the existing threshold of 10 other same-category merchants and 1,000 combined peer sessions, with one equally weighted value per merchant. Terminal scope always suppresses peers.
|
||||
- The versioned catalog stores Persian title, Gregorian date range, family, tags, source, supported merchant categories, and official/commercial classification. The initial reviewed catalog covers the imported history and the following 90 days.
|
||||
|
||||
## AI boundary
|
||||
|
||||
- AvalAI is backend-only and receives minimized aggregate evidence, never raw transactions, payer keys, credentials, arbitrary merchant ids, SQL, or peer rows.
|
||||
- The consumer-owned `GrowthAnalyticsTools` boundary exposes only `get_growth_context`, `get_event_evidence`, `get_peer_context`, and `get_campaign_playbook`. Scope is injected from the authenticated job, not accepted from model arguments.
|
||||
- Use `POST /v1/responses`, `store:false`, strict function schemas, strict structured output, `parallel_tool_calls:false`, no more than four tool calls and two model turns, 1,600 output tokens, a 30-second deadline, and one transient retry.
|
||||
- Treat model output as untrusted. Accept only known evidence ids and qualitative Persian fields. Reject numeric claims, unsafe language, unknown references, refusal/incomplete output, or schema drift and use deterministic Persian fallback copy.
|
||||
- Skip AvalAI when no material opportunity/risk exists or confidence is insufficient. AI failure produces `PARTIAL`, never an empty report.
|
||||
|
||||
## Persistence, caching, jobs, and limits
|
||||
|
||||
- PostgreSQL is authoritative for reports, revisions, deterministic evidence, validated AI copy, usage metadata, and idempotency. Redis is disposable hot cache, lock, rate-counter, and budget state.
|
||||
- A fingerprint includes merchant/terminal scope, import watermark, forecast date/horizon, catalog, analytics, prompt/schema, and model versions. A matching successful report is reused without an AI call.
|
||||
- Keep the current matching report indefinitely and superseded/manual-refresh revisions for 90 days. Concurrent identical requests collapse to one job.
|
||||
- States are `QUEUED`, `ANALYZING`, `GENERATING`, `READY`, `PARTIAL`, and `FAILED`. Preserve the previous successful report during refresh/failure.
|
||||
- Defaults: create 10/user/minute; five uncached scopes/merchant/Tehran day; one merchant-wide manual refresh/Tehran day; four concurrent generations globally; explicit production daily request/token budgets; one active generation per fingerprint.
|
||||
- Budget exhaustion returns deterministic `PARTIAL` output. Only an unavailable manual refresh returns `429` with `nextRefreshAt`/`Retry-After`.
|
||||
- Structured logs include safe report/version/status/cache/usage/latency fields without prompts, transaction values, usernames, or sensitive identifiers.
|
||||
|
||||
## Public API
|
||||
|
||||
- `POST /api/v1/analytics/growth-reports` accepts optional `terminalId` and `refresh`, requires CSRF plus `Idempotency-Key`, and returns an existing or queued report resource.
|
||||
- `GET /api/v1/analytics/growth-reports/latest?terminalId=...` returns the latest authorized scope report.
|
||||
- `GET /api/v1/analytics/growth-reports/{reportId}` returns an owned report; absent and cross-tenant ids are indistinguishable.
|
||||
- `GET /api/v1/analytics/growth-reports/{reportId}/events` is authenticated SSE with stable ids, heartbeat, replay/snapshot recovery, and terminal status.
|
||||
- Responses include deterministic series/ranges/confidence/evidence, opportunities, risks, peer result or suppression reason, validated briefs, cache metadata, and next refresh time.
|
||||
|
||||
## Boundaries and acceptance
|
||||
|
||||
- Always derive merchant identity from the server session, validate terminal ownership, use parameterized SQL/context deadlines, keep numbers deterministic, and preserve useful prior output on failure.
|
||||
- Never add chat, memory, notifications, automated campaigns/actions, runtime web search, raw-data tools, or model-authored numerical predictions.
|
||||
- Tests cover event/baseline/forecast/backtest/risk/peer logic, fingerprints, Tehran refresh boundaries, authorization, persistence, locks/deduplication, budgets, AI schemas/rejections/timeouts, fallback, SSE, and sensitive-field absence.
|
||||
- Feature is disabled by default behind `AI_GROWTH_ENABLED`. Production enablement requires explicit AvalAI key/model/request budget/token budget configuration.
|
||||
|
||||
## Sources
|
||||
|
||||
- AvalAI Responses: https://docs.avalai.ir/en/api-reference/responses
|
||||
- AvalAI function calling: https://docs.avalai.ir/en/guides/function-calling?api-mode=responses
|
||||
- AvalAI structured outputs: https://docs.avalai.ir/en/guides/structured-outputs?api-mode=responses
|
||||
- University of Tehran Iranian calendar: https://calendar.ut.ac.ir/
|
||||
@@ -13,7 +13,7 @@ Every merchant-facing number identifies its grain, formula, source fields, filte
|
||||
- Money uses `payment_sessions.amount_rial` and `payment_sessions.fee_amount_rial` exactly once per session.
|
||||
- Terminal filters join the session's `terminal_id`; peer cohorts join the merchant's `category_id` and exclude the active merchant.
|
||||
- The sole completed `import_runs` row identifies the immutable MVP dataset snapshot included in cache/result metadata.
|
||||
- `calendar_dates` defines Saturday–Friday weeks, Jalali months, and Persian seasons; no holiday dimension exists.
|
||||
- `calendar_dates` defines Saturday–Friday weeks, Jalali months, and Persian seasons; it intentionally has no holiday dimension. Growth reports use their own reviewed, versioned event-evidence catalog under ADR 0015.
|
||||
|
||||
## Bank-paid sessions
|
||||
|
||||
|
||||
@@ -98,7 +98,7 @@ Unique `(import_run_id, severity, rule_code)` keeps one aggregate record per rul
|
||||
| `reporting_week_end_exclusive` | `date` required | following Saturday; equals start + 7 days |
|
||||
| `persian_season` | `smallint` required | `1..4` |
|
||||
|
||||
Unique `(jalali_year, jalali_month, jalali_day)` prevents duplicate calendar mappings. Indexes on `(jalali_year, jalali_month, gregorian_date)`, `(jalali_year, persian_season, gregorian_date)`, and `(reporting_week_start, gregorian_date)` support approved periods. Holiday/named-event columns do not exist.
|
||||
Unique `(jalali_year, jalali_month, jalali_day)` prevents duplicate calendar mappings. Indexes on `(jalali_year, jalali_month, gregorian_date)`, `(jalali_year, persian_season, gregorian_date)`, and `(reporting_week_start, gregorian_date)` support approved periods. Holiday/named-event columns do not exist here; the later growth capability owns a separate reviewed event-evidence catalog.
|
||||
|
||||
### Merchant and demo identity
|
||||
|
||||
|
||||
19
docs/decisions/0015-deterministic-ai-growth-reports.md
Normal file
19
docs/decisions/0015-deterministic-ai-growth-reports.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# ADR 0015: Deterministic analytics with bounded AI interpretation
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-08-21
|
||||
|
||||
## Context
|
||||
|
||||
Growth forecasting, event uplift, low-period detection, and peer comparisons must be reproducible, testable, privacy-safe, and available when an AI provider is unavailable. AvalAI adds value for semantic prioritization and natural Persian campaign guidance, but must not become the source of financial analytics.
|
||||
|
||||
## Decision
|
||||
|
||||
Use PostgreSQL plus deterministic Go analytics as the sole authority for numbers and evidence. Run a bounded backend-only AvalAI Responses workflow over sanitized aggregates, validate its structured qualitative output, and persist report snapshots by a versioned data fingerprint. Use Redis only for hot caching, coordination, budgets, and rate limits. Defer MCP; keep a narrow internal tool interface that an MCP adapter may wrap only when multiple independent agent clients exist.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The growth page remains useful with AI disabled, unavailable, rate-limited, or rejected.
|
||||
- Repeated page visits do not create paid calls while the fingerprint is unchanged.
|
||||
- Algorithm, catalog, prompt, schema, model, or import changes intentionally invalidate the report.
|
||||
- The backend owns more deterministic forecasting and validation code, but the result is auditable and tenant-safe.
|
||||
@@ -1,10 +1,10 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: ZarinPal Merchant Analytics API
|
||||
version: 1.0.0
|
||||
summary: Contract-first API for authenticated merchant analytics.
|
||||
version: 1.1.0
|
||||
summary: Contract-first API for authenticated merchant analytics and growth reports.
|
||||
description: |
|
||||
Approved MVP interface. This document is the API source of truth; generated
|
||||
Approved analytics and AI growth-report interface. This document is the API source of truth; generated
|
||||
frontend types must come from this contract.
|
||||
|
||||
Merchant scope is always derived from the authenticated server-side session.
|
||||
@@ -578,6 +578,66 @@ paths:
|
||||
'504':
|
||||
$ref: '#/components/responses/ExportTimeout'
|
||||
|
||||
/analytics/growth-reports:
|
||||
post:
|
||||
tags: [AI growth analyst]
|
||||
operationId: createGrowthReport
|
||||
summary: Create or reuse a growth report
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/IdempotencyKeyHeader'
|
||||
- $ref: '#/components/parameters/CsrfTokenHeader'
|
||||
- $ref: '#/components/parameters/OriginHeader'
|
||||
- $ref: '#/components/parameters/RequestIdHeader'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/CreateGrowthReportRequest'}
|
||||
responses:
|
||||
'200': {description: Matching durable report reused., content: {application/json: {schema: {$ref: '#/components/schemas/GrowthReport'}}}}
|
||||
'202': {description: Report accepted for generation., content: {application/json: {schema: {$ref: '#/components/schemas/GrowthReport'}}}}
|
||||
'401': {$ref: '#/components/responses/Unauthenticated'}
|
||||
'403': {$ref: '#/components/responses/Forbidden'}
|
||||
'409': {$ref: '#/components/responses/Conflict'}
|
||||
'422': {$ref: '#/components/responses/ValidationFailed'}
|
||||
'429': {$ref: '#/components/responses/RateLimited'}
|
||||
'503': {$ref: '#/components/responses/AnalyticsUnavailable'}
|
||||
/analytics/growth-reports/latest:
|
||||
get:
|
||||
tags: [AI growth analyst]
|
||||
operationId: getLatestGrowthReport
|
||||
parameters:
|
||||
- {name: terminalId, in: query, required: false, schema: {type: string}}
|
||||
- $ref: '#/components/parameters/RequestIdHeader'
|
||||
responses:
|
||||
'200': {description: Latest successful report for the authorized scope., content: {application/json: {schema: {$ref: '#/components/schemas/GrowthReport'}}}}
|
||||
'401': {$ref: '#/components/responses/Unauthenticated'}
|
||||
'404': {$ref: '#/components/responses/ResourceNotFound'}
|
||||
'503': {$ref: '#/components/responses/AnalyticsUnavailable'}
|
||||
/analytics/growth-reports/{reportId}:
|
||||
get:
|
||||
tags: [AI growth analyst]
|
||||
operationId: getGrowthReport
|
||||
parameters:
|
||||
- {name: reportId, in: path, required: true, schema: {type: string, pattern: '^growth_[a-f0-9]{24}$'}}
|
||||
- $ref: '#/components/parameters/RequestIdHeader'
|
||||
responses:
|
||||
'200': {description: Authorized report snapshot., content: {application/json: {schema: {$ref: '#/components/schemas/GrowthReport'}}}}
|
||||
'401': {$ref: '#/components/responses/Unauthenticated'}
|
||||
'404': {$ref: '#/components/responses/ResourceNotFound'}
|
||||
'503': {$ref: '#/components/responses/AnalyticsUnavailable'}
|
||||
/analytics/growth-reports/{reportId}/events:
|
||||
get:
|
||||
tags: [AI growth analyst]
|
||||
operationId: streamGrowthReportEvents
|
||||
parameters:
|
||||
- {name: reportId, in: path, required: true, schema: {type: string}}
|
||||
- {name: Last-Event-ID, in: header, required: false, schema: {type: string}}
|
||||
responses:
|
||||
'200': {description: Stable-id report snapshots and heartbeats., content: {text/event-stream: {schema: {type: string}}}}
|
||||
'401': {$ref: '#/components/responses/Unauthenticated'}
|
||||
'404': {$ref: '#/components/responses/ResourceNotFound'}
|
||||
|
||||
components:
|
||||
securitySchemes:
|
||||
merchantSession:
|
||||
@@ -943,6 +1003,174 @@ components:
|
||||
$ref: '#/components/schemas/Problem'
|
||||
|
||||
schemas:
|
||||
CreateGrowthReportRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
properties:
|
||||
terminalId: {type: string, minLength: 1, maxLength: 128}
|
||||
refresh:
|
||||
type: boolean
|
||||
description: When omitted, the request reuses the current matching report.
|
||||
GrowthRange:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [p10, p50, p90]
|
||||
properties:
|
||||
p10: {type: integer, format: int64, minimum: 0}
|
||||
p50: {type: integer, format: int64, minimum: 0}
|
||||
p90: {type: integer, format: int64, minimum: 0}
|
||||
GrowthForecastPoint:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [date, baselineSessions, baselineAmountRial, paidSessions, paidAmountRial]
|
||||
properties:
|
||||
date: {type: string, format: date-time}
|
||||
baselineSessions: {type: integer, format: int64, minimum: 0}
|
||||
baselineAmountRial: {type: integer, format: int64, minimum: 0}
|
||||
paidSessions: {$ref: '#/components/schemas/GrowthRange'}
|
||||
paidAmountRial: {$ref: '#/components/schemas/GrowthRange'}
|
||||
eventIds: {type: array, items: {type: string}}
|
||||
GrowthForecast:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [startDate, horizonDays, confidence, points, backtest]
|
||||
properties:
|
||||
startDate: {type: string, format: date-time}
|
||||
horizonDays: {type: integer, enum: [90]}
|
||||
confidence: {type: string, enum: [HIGH, MEDIUM, INSUFFICIENT_DATA]}
|
||||
suppressionReason: {type: string}
|
||||
points: {type: array, maxItems: 90, items: {$ref: '#/components/schemas/GrowthForecastPoint'}}
|
||||
backtest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [origins, sessionMapeBps, amountMapeBps, intervalCoverageBps]
|
||||
properties:
|
||||
origins: {type: integer, minimum: 0}
|
||||
sessionMapeBps: {type: integer, format: int64, minimum: 0}
|
||||
amountMapeBps: {type: integer, format: int64, minimum: 0}
|
||||
intervalCoverageBps: {type: integer, format: int64, minimum: 0, maximum: 10000}
|
||||
GrowthHistoricalWin:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [evidenceId, eventId, eventTitleFa, startDate, endDate, materialWin]
|
||||
properties:
|
||||
evidenceId: {type: string}
|
||||
eventId: {type: string}
|
||||
eventTitleFa: {type: string}
|
||||
startDate: {type: string, format: date-time}
|
||||
endDate: {type: string, format: date-time}
|
||||
eligible: {type: boolean}
|
||||
suppressionReason: {type: string}
|
||||
observationCount: {type: integer}
|
||||
baselineSessionCount: {type: integer, format: int64}
|
||||
baselinePaidSessions: {type: integer, format: int64}
|
||||
baselineAmountRial: {type: integer, format: int64}
|
||||
actualSessionCount: {type: integer, format: int64}
|
||||
actualPaidSessions: {type: integer, format: int64}
|
||||
actualAmountRial: {type: integer, format: int64}
|
||||
sessionUpliftBps: {type: integer, format: int64}
|
||||
paidSessionUpliftBps: {type: integer, format: int64}
|
||||
amountUpliftBps: {type: integer, format: int64}
|
||||
materialWin: {type: boolean}
|
||||
materialDecline: {type: boolean}
|
||||
GrowthOpportunity:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [evidenceId, eventId, eventTitleFa, date, similarityBps, expectedSessions, expectedAmountRial, historicalEvidenceId]
|
||||
properties:
|
||||
evidenceId: {type: string}
|
||||
eventId: {type: string}
|
||||
eventTitleFa: {type: string}
|
||||
date: {type: string, format: date-time}
|
||||
similarityBps: {type: integer, format: int64, minimum: 6000, maximum: 10000}
|
||||
expectedSessions: {$ref: '#/components/schemas/GrowthRange'}
|
||||
expectedAmountRial: {$ref: '#/components/schemas/GrowthRange'}
|
||||
historicalEvidenceId: {type: string}
|
||||
GrowthRisk:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [evidenceId, date, sessionDeclineBps, amountDeclineBps, expectedSessions, expectedAmountRial]
|
||||
properties:
|
||||
evidenceId: {type: string}
|
||||
date: {type: string, format: date-time}
|
||||
endDate: {type: string, format: date-time}
|
||||
sessionDeclineBps: {type: integer, format: int64, minimum: 0, description: At least one decline dimension is 2000 BPS or greater.}
|
||||
amountDeclineBps: {type: integer, format: int64, minimum: 0, description: At least one decline dimension is 2000 BPS or greater.}
|
||||
expectedSessions: {$ref: '#/components/schemas/GrowthRange'}
|
||||
expectedAmountRial: {$ref: '#/components/schemas/GrowthRange'}
|
||||
GrowthPeer:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [state, eligiblePeerCount, combinedPeerSessionCount, medianPaidSessions, percentileBps]
|
||||
properties:
|
||||
state: {type: string, enum: [READY, SUPPRESSED]}
|
||||
reason: {type: string}
|
||||
eligiblePeerCount: {type: integer, minimum: 0}
|
||||
combinedPeerSessionCount: {type: integer, format: int64, minimum: 0}
|
||||
medianPaidSessions: {type: integer, format: int64, minimum: 0}
|
||||
percentileBps: {type: integer, format: int64, minimum: 0, maximum: 10000}
|
||||
GrowthCampaignBrief:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [headline, interpretation, offerSuggestion, messageSuggestion, preparationAction, limitation, evidenceIds]
|
||||
properties:
|
||||
headline: {type: string}
|
||||
interpretation: {type: string}
|
||||
offerSuggestion: {type: string}
|
||||
messageSuggestion: {type: string}
|
||||
preparationAction: {type: string}
|
||||
limitation: {type: string}
|
||||
evidenceIds: {type: array, minItems: 1, items: {type: string}}
|
||||
GrowthFallbackBrief:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [headlineFa, guidanceFa, offerFa, messageFa, preparationFa, limitationFa, evidenceIds]
|
||||
properties:
|
||||
headlineFa: {type: string}
|
||||
guidanceFa: {type: string}
|
||||
offerFa: {type: string}
|
||||
messageFa: {type: string}
|
||||
preparationFa: {type: string}
|
||||
limitationFa: {type: string}
|
||||
evidenceIds: {type: array, minItems: 1, items: {type: string}}
|
||||
GrowthEvidence:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [forecast, historicalWins, opportunities, risks, fallbackBriefs]
|
||||
properties:
|
||||
forecast: {$ref: '#/components/schemas/GrowthForecast'}
|
||||
historicalWins: {type: array, items: {$ref: '#/components/schemas/GrowthHistoricalWin'}}
|
||||
opportunities: {type: array, maxItems: 5, items: {$ref: '#/components/schemas/GrowthOpportunity'}}
|
||||
risks: {type: array, maxItems: 3, items: {$ref: '#/components/schemas/GrowthRisk'}}
|
||||
peer: {$ref: '#/components/schemas/GrowthPeer'}
|
||||
fallbackBriefs: {type: array, items: {$ref: '#/components/schemas/GrowthFallbackBrief'}}
|
||||
GrowthReport:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [reportId, revision, status, scope, horizonDays, cached, stale, nextRefreshAt, createdAt, updatedAt, eventsUrl]
|
||||
properties:
|
||||
reportId: {type: string}
|
||||
revision: {type: integer, minimum: 1}
|
||||
status: {type: string, enum: [QUEUED, ANALYZING, GENERATING, READY, PARTIAL, FAILED]}
|
||||
scope:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
properties: {terminalId: {type: [string, 'null']}}
|
||||
horizonDays: {type: integer, enum: [90]}
|
||||
forecastStartDate: {type: string, format: date-time}
|
||||
deterministicEvidence: {$ref: '#/components/schemas/GrowthEvidence'}
|
||||
aiCopy:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [briefs]
|
||||
properties: {briefs: {type: array, maxItems: 5, items: {$ref: '#/components/schemas/GrowthCampaignBrief'}}}
|
||||
failureCode: {type: string}
|
||||
cached: {type: boolean}
|
||||
stale: {type: boolean}
|
||||
nextRefreshAt: {type: string, format: date-time}
|
||||
createdAt: {type: string, format: date-time}
|
||||
updatedAt: {type: string, format: date-time}
|
||||
eventsUrl: {type: string, format: uri-reference}
|
||||
LoginRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
@@ -1907,3 +2135,7 @@ components:
|
||||
type: string
|
||||
format: uri-reference
|
||||
description: Optional safe recovery link for run-related problems.
|
||||
nextRefreshAt:
|
||||
type: string
|
||||
format: date-time
|
||||
description: Present for REFRESH_LIMITED responses.
|
||||
|
||||
36
docs/openapi_test.go
Normal file
36
docs/openapi_test.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package docs
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCreateGrowthReportRequestFieldsAreOptional(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
document, err := os.ReadFile("openapi.yaml")
|
||||
if err != nil {
|
||||
t.Fatalf("read OpenAPI document: %v", err)
|
||||
}
|
||||
|
||||
const schemaStart = " CreateGrowthReportRequest:\n"
|
||||
const nextSchema = " GrowthRange:\n"
|
||||
start := strings.Index(string(document), schemaStart)
|
||||
if start < 0 {
|
||||
t.Fatal("CreateGrowthReportRequest schema not found")
|
||||
}
|
||||
schema := string(document)[start+len(schemaStart):]
|
||||
var found bool
|
||||
schema, _, found = strings.Cut(schema, nextSchema)
|
||||
if !found {
|
||||
t.Fatal("schema following CreateGrowthReportRequest not found")
|
||||
}
|
||||
|
||||
if strings.Contains(schema, "required:") {
|
||||
t.Fatalf("optional request fields must not be listed as required:\n%s", schema)
|
||||
}
|
||||
if strings.Contains(schema, "default:") {
|
||||
t.Fatalf("optional request fields must not use defaults that generators treat as required:\n%s", schema)
|
||||
}
|
||||
}
|
||||
225
internal/api/growth.go
Normal file
225
internal/api/growth.go
Normal file
@@ -0,0 +1,225 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/growthreport"
|
||||
)
|
||||
|
||||
var growthReportIDPattern = regexp.MustCompile(`^growth_[a-f0-9]{24}$`)
|
||||
|
||||
type growthReportService interface {
|
||||
Create(context.Context, int64, int64, growthreport.CreateRequest, string) (growthreport.Record, bool, error)
|
||||
Get(context.Context, int64, string) (growthreport.Record, error)
|
||||
Latest(context.Context, int64, string) (growthreport.Record, error)
|
||||
}
|
||||
|
||||
func (s *Server) createGrowthReport(w http.ResponseWriter, r *http.Request) {
|
||||
if s.growth == nil {
|
||||
s.problem(w, r, 404, "RESOURCE_NOT_FOUND", "Resource not found")
|
||||
return
|
||||
}
|
||||
key := r.Header.Get("Idempotency-Key")
|
||||
if !validIdempotencyKey(key) {
|
||||
s.problem(w, r, 422, "VALIDATION_FAILED", "A valid Idempotency-Key is required")
|
||||
return
|
||||
}
|
||||
var input growthreport.CreateRequest
|
||||
if err := decodeStrictJSON(w, r, &input); err != nil || len(input.TerminalID) > 128 {
|
||||
s.problem(w, r, 422, "VALIDATION_FAILED", "Invalid growth report request")
|
||||
return
|
||||
}
|
||||
session := sessionFrom(r.Context())
|
||||
record, cached, err := s.growth.Create(r.Context(), session.UserID, session.MerchantID, input, key)
|
||||
if err != nil {
|
||||
s.handleGrowthCreateError(w, r, err)
|
||||
return
|
||||
}
|
||||
status := http.StatusAccepted
|
||||
if cached {
|
||||
status = http.StatusOK
|
||||
}
|
||||
w.Header().Set("Location", "/api/v1/analytics/growth-reports/"+record.ReportID)
|
||||
s.writeJSON(w, status, record.Report)
|
||||
}
|
||||
|
||||
func (s *Server) handleGrowthCreateError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
var limit *growthreport.RefreshLimitError
|
||||
switch {
|
||||
case errors.Is(err, growthreport.ErrDisabled):
|
||||
s.problem(w, r, 404, "RESOURCE_NOT_FOUND", "Resource not found")
|
||||
case errors.Is(err, growthreport.ErrUnknownTerminal):
|
||||
s.problem(w, r, 422, "VALIDATION_FAILED", "Unknown terminal")
|
||||
case errors.Is(err, growthreport.ErrIdempotencyConflict):
|
||||
s.problem(w, r, 409, "IDEMPOTENCY_CONFLICT", "Idempotency key was already used for another request")
|
||||
case errors.As(err, &limit):
|
||||
seconds := max(int64(1), int64(time.Until(limit.NextRefreshAt).Seconds()))
|
||||
w.Header().Set("Retry-After", strconv.FormatInt(seconds, 10))
|
||||
w.Header().Set("Content-Type", "application/problem+json")
|
||||
w.WriteHeader(429)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"type": "https://zarinpal.amiirkhl.ir/docs/errors/refresh-limited", "title": "Too Many Requests", "status": 429, "code": "REFRESH_LIMITED", "detail": "Daily refresh is not available yet", "instance": r.URL.Path, "requestId": w.Header().Get("X-Request-ID"), "nextRefreshAt": limit.NextRefreshAt})
|
||||
case errors.Is(err, growthreport.ErrRateLimited):
|
||||
s.problem(w, r, 429, "RATE_LIMITED", "Too many growth report requests")
|
||||
default:
|
||||
s.logger.Error("growth report creation failed", "error", err)
|
||||
s.problem(w, r, 503, "ANALYTICS_UNAVAILABLE", "Growth analytics unavailable")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) latestGrowthReport(w http.ResponseWriter, r *http.Request) {
|
||||
if s.growth == nil {
|
||||
s.problem(w, r, 404, "RESOURCE_NOT_FOUND", "Resource not found")
|
||||
return
|
||||
}
|
||||
for key := range r.URL.Query() {
|
||||
if key != "terminalId" {
|
||||
s.problem(w, r, 422, "VALIDATION_FAILED", "Unknown query parameter")
|
||||
return
|
||||
}
|
||||
}
|
||||
terminalID := r.URL.Query().Get("terminalId")
|
||||
if len(terminalID) > 128 {
|
||||
s.problem(w, r, 422, "VALIDATION_FAILED", "Invalid terminal")
|
||||
return
|
||||
}
|
||||
record, err := s.growth.Latest(r.Context(), sessionFrom(r.Context()).MerchantID, terminalID)
|
||||
s.writeGrowthResult(w, r, record, err)
|
||||
}
|
||||
|
||||
func (s *Server) getGrowthReport(w http.ResponseWriter, r *http.Request) {
|
||||
if s.growth == nil {
|
||||
s.problem(w, r, 404, "RESOURCE_NOT_FOUND", "Resource not found")
|
||||
return
|
||||
}
|
||||
id := r.PathValue("reportId")
|
||||
if !growthReportIDPattern.MatchString(id) {
|
||||
s.problem(w, r, 404, "RESOURCE_NOT_FOUND", "Resource not found")
|
||||
return
|
||||
}
|
||||
record, err := s.growth.Get(r.Context(), sessionFrom(r.Context()).MerchantID, id)
|
||||
s.writeGrowthResult(w, r, record, err)
|
||||
}
|
||||
|
||||
func (s *Server) writeGrowthResult(w http.ResponseWriter, r *http.Request, record growthreport.Record, err error) {
|
||||
if errors.Is(err, growthreport.ErrNotFound) {
|
||||
s.problem(w, r, 404, "RESOURCE_NOT_FOUND", "Resource not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
s.problem(w, r, 503, "ANALYTICS_UNAVAILABLE", "Growth analytics unavailable")
|
||||
return
|
||||
}
|
||||
s.writeJSON(w, 200, record.Report)
|
||||
}
|
||||
|
||||
func (s *Server) growthReportEvents(w http.ResponseWriter, r *http.Request) {
|
||||
if s.growth == nil {
|
||||
s.problem(w, r, 404, "RESOURCE_NOT_FOUND", "Resource not found")
|
||||
return
|
||||
}
|
||||
id := r.PathValue("reportId")
|
||||
if !growthReportIDPattern.MatchString(id) {
|
||||
s.problem(w, r, 404, "RESOURCE_NOT_FOUND", "Resource not found")
|
||||
return
|
||||
}
|
||||
merchantID := sessionFrom(r.Context()).MerchantID
|
||||
slots := s.growthSSESlots
|
||||
if slots == nil {
|
||||
slots = defaultGrowthSSESlots
|
||||
}
|
||||
select {
|
||||
case slots <- struct{}{}:
|
||||
defer func() { <-slots }()
|
||||
default:
|
||||
s.problem(w, r, http.StatusTooManyRequests, "RATE_LIMITED", "Too many growth report streams")
|
||||
return
|
||||
}
|
||||
maxDuration := s.growthSSEMaxDuration
|
||||
if maxDuration <= 0 {
|
||||
maxDuration = defaultGrowthSSEMaxDuration
|
||||
}
|
||||
pollInterval := s.growthSSEPollInterval
|
||||
if pollInterval <= 0 {
|
||||
pollInterval = defaultGrowthSSEPollInterval
|
||||
}
|
||||
streamContext, cancel := context.WithTimeout(r.Context(), maxDuration)
|
||||
defer cancel()
|
||||
record, err := s.growth.Get(streamContext, merchantID, id)
|
||||
if errors.Is(err, growthreport.ErrNotFound) {
|
||||
s.problem(w, r, 404, "RESOURCE_NOT_FOUND", "Resource not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
s.problem(w, r, 503, "ANALYTICS_UNAVAILABLE", "Growth analytics unavailable")
|
||||
return
|
||||
}
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
s.problem(w, r, 500, "INTERNAL_ERROR", "Streaming unavailable")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-cache, no-transform")
|
||||
w.Header().Set("X-Accel-Buffering", "no")
|
||||
ticker := time.NewTicker(pollInterval)
|
||||
defer ticker.Stop()
|
||||
heartbeat := time.NewTicker(15 * time.Second)
|
||||
defer heartbeat.Stop()
|
||||
last := ""
|
||||
for {
|
||||
eventID := record.ReportID + ":" + strconv.FormatInt(record.UpdatedAt.UnixNano(), 10)
|
||||
if eventID != last {
|
||||
payload, _ := json.Marshal(map[string]any{"eventId": eventID, "report": record.Report})
|
||||
_, _ = fmt.Fprintf(w, "id: %s\nevent: report.snapshot\ndata: %s\n\n", eventID, payload)
|
||||
flusher.Flush()
|
||||
last = eventID
|
||||
}
|
||||
if record.Status.Terminal() {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-streamContext.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
record, err = s.growth.Get(streamContext, merchantID, id)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
case <-heartbeat.C:
|
||||
_, _ = fmt.Fprint(w, ": heartbeat\n\n")
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func validIdempotencyKey(key string) bool {
|
||||
if len(key) < 8 || len(key) > 128 {
|
||||
return false
|
||||
}
|
||||
for _, value := range []byte(key) {
|
||||
if value < 0x21 || value > 0x7e {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
func decodeStrictJSON(w http.ResponseWriter, r *http.Request, value any) error {
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(value); err != nil {
|
||||
return err
|
||||
}
|
||||
var extra any
|
||||
if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) {
|
||||
return errors.New("trailing JSON")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
194
internal/api/growth_test.go
Normal file
194
internal/api/growth_test.go
Normal file
@@ -0,0 +1,194 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/growthreport"
|
||||
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/identity"
|
||||
)
|
||||
|
||||
func TestValidIdempotencyKeyUsesPrintableAllowlist(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name, key string
|
||||
want bool
|
||||
}{{"valid", "growth-123456", true}, {"short", "short", false}, {"space", "growth key", false}, {"unicode", "رشد-گزارش", false}}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := validIdempotencyKey(tt.key); got != tt.want {
|
||||
t.Fatalf("validIdempotencyKey(%q)=%v", tt.key, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestDecodeStrictJSONRejectsTrailingAndUnknownFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, body := range []string{`{"refresh":true}{}`, `{"unexpected":true}`} {
|
||||
request := httptest.NewRequest("POST", "/", strings.NewReader(body))
|
||||
recorder := httptest.NewRecorder()
|
||||
var value struct {
|
||||
Refresh bool `json:"refresh"`
|
||||
}
|
||||
if err := decodeStrictJSON(recorder, request, &value); err == nil {
|
||||
t.Fatalf("decodeStrictJSON(%q) error=nil", body)
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestGrowthReportIDPatternRejectsTenantEnumerationInputs(t *testing.T) {
|
||||
t.Parallel()
|
||||
if !growthReportIDPattern.MatchString("growth_0123456789abcdef01234567") {
|
||||
t.Fatal("valid report id rejected")
|
||||
}
|
||||
for _, id := range []string{"growth_1", "../growth_0123456789abcdef01234567", "run_0123456789abcdef01234567"} {
|
||||
if growthReportIDPattern.MatchString(id) {
|
||||
t.Fatalf("unsafe id accepted: %q", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type fakeGrowthService struct {
|
||||
get func(int64, string) (growthreport.Record, error)
|
||||
getContext func(context.Context, int64, string) (growthreport.Record, error)
|
||||
create func() (growthreport.Record, bool, error)
|
||||
}
|
||||
|
||||
func (f fakeGrowthService) Create(_ context.Context, _, _ int64, _ growthreport.CreateRequest, _ string) (growthreport.Record, bool, error) {
|
||||
return f.create()
|
||||
}
|
||||
|
||||
func (f fakeGrowthService) Get(ctx context.Context, merchantID int64, id string) (growthreport.Record, error) {
|
||||
if f.getContext != nil {
|
||||
return f.getContext(ctx, merchantID, id)
|
||||
}
|
||||
return f.get(merchantID, id)
|
||||
}
|
||||
func (f fakeGrowthService) Latest(_ context.Context, merchantID int64, terminal string) (growthreport.Record, error) {
|
||||
return f.get(merchantID, terminal)
|
||||
}
|
||||
|
||||
func TestGrowthEndpointsRequireAuthentication(t *testing.T) {
|
||||
t.Parallel()
|
||||
server := &Server{logger: slog.New(slog.NewTextHandler(io.Discard, nil)), growth: fakeGrowthService{}}
|
||||
request := httptest.NewRequest("GET", "/api/v1/analytics/growth-reports/latest", nil)
|
||||
response := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(response, request)
|
||||
if response.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status=%d", response.Code)
|
||||
}
|
||||
}
|
||||
func TestGetGrowthReportHidesOtherMerchantResource(t *testing.T) {
|
||||
t.Parallel()
|
||||
service := fakeGrowthService{get: func(merchantID int64, _ string) (growthreport.Record, error) {
|
||||
if merchantID != 7 {
|
||||
t.Fatalf("merchant=%d", merchantID)
|
||||
}
|
||||
return growthreport.Record{}, growthreport.ErrNotFound
|
||||
}}
|
||||
server := &Server{logger: slog.New(slog.NewTextHandler(io.Discard, nil)), growth: service}
|
||||
request := httptest.NewRequest("GET", "/", nil)
|
||||
request.SetPathValue("reportId", "growth_0123456789abcdef01234567")
|
||||
request = request.WithContext(context.WithValue(request.Context(), sessionKey{}, identity.Session{MerchantID: 7}))
|
||||
response := httptest.NewRecorder()
|
||||
server.getGrowthReport(response, request)
|
||||
if response.Code != 404 {
|
||||
t.Fatalf("status=%d", response.Code)
|
||||
}
|
||||
}
|
||||
func TestRefreshLimitProblemIncludesNextRefreshAt(t *testing.T) {
|
||||
t.Parallel()
|
||||
next := time.Now().UTC().Add(time.Hour)
|
||||
service := fakeGrowthService{create: func() (growthreport.Record, bool, error) {
|
||||
return growthreport.Record{}, false, &growthreport.RefreshLimitError{NextRefreshAt: next}
|
||||
}}
|
||||
server := &Server{logger: slog.New(slog.NewTextHandler(io.Discard, nil)), growth: service}
|
||||
request := httptest.NewRequest("POST", "/api/v1/analytics/growth-reports", strings.NewReader(`{"refresh":true}`))
|
||||
request.Header.Set("Idempotency-Key", "growth-refresh-1")
|
||||
request = request.WithContext(context.WithValue(request.Context(), sessionKey{}, identity.Session{MerchantID: 7, UserID: 3}))
|
||||
response := httptest.NewRecorder()
|
||||
server.createGrowthReport(response, request)
|
||||
if response.Code != 429 || !strings.Contains(response.Body.String(), "nextRefreshAt") {
|
||||
t.Fatalf("status=%d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
func TestGrowthSSEAuthorizesBeforeSettingStreamHeaders(t *testing.T) {
|
||||
t.Parallel()
|
||||
service := fakeGrowthService{get: func(int64, string) (growthreport.Record, error) {
|
||||
return growthreport.Record{}, growthreport.ErrNotFound
|
||||
}}
|
||||
server := &Server{logger: slog.New(slog.NewTextHandler(io.Discard, nil)), growth: service}
|
||||
request := httptest.NewRequest("GET", "/", nil)
|
||||
request.SetPathValue("reportId", "growth_0123456789abcdef01234567")
|
||||
request = request.WithContext(context.WithValue(request.Context(), sessionKey{}, identity.Session{MerchantID: 7}))
|
||||
response := httptest.NewRecorder()
|
||||
server.growthReportEvents(response, request)
|
||||
if response.Code != 404 || !strings.HasPrefix(response.Header().Get("Content-Type"), "application/problem+json") {
|
||||
t.Fatalf("status=%d content-type=%q", response.Code, response.Header().Get("Content-Type"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrowthSSERejectsWhenGlobalCapacityIsFull(t *testing.T) {
|
||||
t.Parallel()
|
||||
slots := make(chan struct{}, 1)
|
||||
slots <- struct{}{}
|
||||
reads := 0
|
||||
service := fakeGrowthService{get: func(int64, string) (growthreport.Record, error) {
|
||||
reads++
|
||||
return growthreport.Record{Report: growthreport.Report{ReportID: "growth_0123456789abcdef01234567", Status: growthreport.StatusQueued}}, nil
|
||||
}}
|
||||
server := &Server{logger: slog.New(slog.NewTextHandler(io.Discard, nil)), growth: service, growthSSESlots: slots}
|
||||
request := httptest.NewRequest("GET", "/", nil)
|
||||
request.SetPathValue("reportId", "growth_0123456789abcdef01234567")
|
||||
request = request.WithContext(context.WithValue(request.Context(), sessionKey{}, identity.Session{MerchantID: 7}))
|
||||
response := httptest.NewRecorder()
|
||||
server.growthReportEvents(response, request)
|
||||
if response.Code != http.StatusTooManyRequests || reads != 0 {
|
||||
t.Fatalf("status=%d database reads=%d", response.Code, reads)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrowthSSEEndsAtConfiguredDeadline(t *testing.T) {
|
||||
t.Parallel()
|
||||
reads := 0
|
||||
service := fakeGrowthService{get: func(int64, string) (growthreport.Record, error) {
|
||||
reads++
|
||||
return growthreport.Record{Report: growthreport.Report{ReportID: "growth_0123456789abcdef01234567", Status: growthreport.StatusQueued, UpdatedAt: time.Unix(int64(reads), 0)}}, nil
|
||||
}}
|
||||
server := &Server{logger: slog.New(slog.NewTextHandler(io.Discard, nil)), growth: service, growthSSESlots: make(chan struct{}, 1), growthSSEMaxDuration: 5 * time.Millisecond, growthSSEPollInterval: time.Millisecond}
|
||||
request := httptest.NewRequest("GET", "/", nil)
|
||||
request.SetPathValue("reportId", "growth_0123456789abcdef01234567")
|
||||
request = request.WithContext(context.WithValue(request.Context(), sessionKey{}, identity.Session{MerchantID: 7}))
|
||||
response := httptest.NewRecorder()
|
||||
started := time.Now()
|
||||
server.growthReportEvents(response, request)
|
||||
if elapsed := time.Since(started); elapsed > 250*time.Millisecond {
|
||||
t.Fatalf("stream remained open for %s", elapsed)
|
||||
}
|
||||
if reads < 1 || reads > 10 {
|
||||
t.Fatalf("database reads=%d", reads)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrowthSSEBoundsInitialDatabaseRead(t *testing.T) {
|
||||
t.Parallel()
|
||||
service := fakeGrowthService{getContext: func(ctx context.Context, _ int64, _ string) (growthreport.Record, error) {
|
||||
<-ctx.Done()
|
||||
return growthreport.Record{}, ctx.Err()
|
||||
}}
|
||||
server := &Server{logger: slog.New(slog.NewTextHandler(io.Discard, nil)), growth: service, growthSSESlots: make(chan struct{}, 1), growthSSEMaxDuration: 5 * time.Millisecond}
|
||||
request := httptest.NewRequest("GET", "/", nil)
|
||||
request.SetPathValue("reportId", "growth_0123456789abcdef01234567")
|
||||
request = request.WithContext(context.WithValue(request.Context(), sessionKey{}, identity.Session{MerchantID: 7}))
|
||||
response := httptest.NewRecorder()
|
||||
started := time.Now()
|
||||
server.growthReportEvents(response, request)
|
||||
if elapsed := time.Since(started); elapsed > 250*time.Millisecond || response.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("elapsed=%s status=%d", elapsed, response.Code)
|
||||
}
|
||||
}
|
||||
@@ -23,21 +23,37 @@ import (
|
||||
|
||||
const maxExportBytes = 50 << 20
|
||||
|
||||
const (
|
||||
defaultGrowthSSECapacity = 64
|
||||
defaultGrowthSSEMaxDuration = 5 * time.Minute
|
||||
defaultGrowthSSEPollInterval = 2 * time.Second
|
||||
)
|
||||
|
||||
var defaultGrowthSSESlots = make(chan struct{}, defaultGrowthSSECapacity)
|
||||
|
||||
var errExportBytesExceeded = errors.New("export exceeds byte limit")
|
||||
|
||||
type Server struct {
|
||||
identity *identity.Service
|
||||
analytics *analytics.Service
|
||||
logger *slog.Logger
|
||||
frontendOrigin string
|
||||
cookieSecure bool
|
||||
identity *identity.Service
|
||||
analytics *analytics.Service
|
||||
growth growthReportService
|
||||
logger *slog.Logger
|
||||
frontendOrigin string
|
||||
cookieSecure bool
|
||||
growthSSESlots chan struct{}
|
||||
growthSSEMaxDuration time.Duration
|
||||
growthSSEPollInterval time.Duration
|
||||
}
|
||||
|
||||
type sessionKey struct{}
|
||||
type tokenKey struct{}
|
||||
|
||||
func NewServer(identityService *identity.Service, analyticsService *analytics.Service, logger *slog.Logger, frontendOrigin string, cookieSecure bool) *Server {
|
||||
return &Server{identity: identityService, analytics: analyticsService, logger: logger, frontendOrigin: frontendOrigin, cookieSecure: cookieSecure}
|
||||
func NewServer(identityService *identity.Service, analyticsService *analytics.Service, logger *slog.Logger, frontendOrigin string, cookieSecure bool, growthService ...growthReportService) *Server {
|
||||
server := &Server{identity: identityService, analytics: analyticsService, logger: logger, frontendOrigin: frontendOrigin, cookieSecure: cookieSecure, growthSSESlots: defaultGrowthSSESlots, growthSSEMaxDuration: defaultGrowthSSEMaxDuration, growthSSEPollInterval: defaultGrowthSSEPollInterval}
|
||||
if len(growthService) > 0 {
|
||||
server.growth = growthService[0]
|
||||
}
|
||||
return server
|
||||
}
|
||||
|
||||
func (s *Server) Handler() http.Handler {
|
||||
@@ -54,6 +70,10 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.Handle("GET /api/v1/analytics/session-details", s.authenticate(http.HandlerFunc(s.details)))
|
||||
mux.Handle("GET /api/v1/analytics/session-details.csv", s.authenticate(http.HandlerFunc(s.exportCSV)))
|
||||
mux.Handle("GET /api/v1/analytics/sessions/{sessionId}", s.authenticate(http.HandlerFunc(s.sessionDetail)))
|
||||
mux.Handle("POST /api/v1/analytics/growth-reports", s.authenticate(s.csrf(http.HandlerFunc(s.createGrowthReport))))
|
||||
mux.Handle("GET /api/v1/analytics/growth-reports/latest", s.authenticate(http.HandlerFunc(s.latestGrowthReport)))
|
||||
mux.Handle("GET /api/v1/analytics/growth-reports/{reportId}", s.authenticate(http.HandlerFunc(s.getGrowthReport)))
|
||||
mux.Handle("GET /api/v1/analytics/growth-reports/{reportId}/events", s.authenticate(http.HandlerFunc(s.growthReportEvents)))
|
||||
return s.boundary(mux)
|
||||
}
|
||||
|
||||
|
||||
@@ -8,21 +8,32 @@ import (
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
HTTPAddr string
|
||||
DatabaseURL string
|
||||
RedisAddr string
|
||||
RedisPassword string
|
||||
ImportPath string
|
||||
DemoUsername string
|
||||
DemoPassword string
|
||||
SessionSecret string
|
||||
CursorSecret string
|
||||
FrontendOrigin string
|
||||
CookieSecure bool
|
||||
AppEnv string
|
||||
HTTPAddr string
|
||||
DatabaseURL string
|
||||
RedisAddr string
|
||||
RedisPassword string
|
||||
ImportPath string
|
||||
DemoUsername string
|
||||
DemoPassword string
|
||||
SessionSecret string
|
||||
CursorSecret string
|
||||
FrontendOrigin string
|
||||
CookieSecure bool
|
||||
AIGrowthEnabled bool
|
||||
AvalAIAPIKey string
|
||||
AvalAIBaseURL string
|
||||
AvalAIModel string
|
||||
AIDailyRequestBudget int64
|
||||
AIDailyTokenBudget int64
|
||||
AIMaxConcurrent int
|
||||
AICreatePerMinute int64
|
||||
AIUncachedScopesPerDay int64
|
||||
}
|
||||
|
||||
func Load() (Config, error) {
|
||||
cfg := Config{
|
||||
AppEnv: value("APP_ENV", "development"),
|
||||
HTTPAddr: value("HTTP_ADDR", "127.0.0.1:8080"),
|
||||
DatabaseURL: os.Getenv("DATABASE_URL"),
|
||||
RedisAddr: value("REDIS_ADDR", "localhost:6379"),
|
||||
@@ -33,6 +44,9 @@ func Load() (Config, error) {
|
||||
SessionSecret: os.Getenv("SESSION_SECRET"),
|
||||
CursorSecret: os.Getenv("CURSOR_SECRET"),
|
||||
FrontendOrigin: value("FRONTEND_ORIGIN", "http://localhost:5173"),
|
||||
AvalAIAPIKey: os.Getenv("AVALAI_API_KEY"),
|
||||
AvalAIBaseURL: value("AVALAI_BASE_URL", "https://api.avalai.ir"),
|
||||
AvalAIModel: value("AVALAI_MODEL", "gpt-5-mini-2025-08-07"),
|
||||
}
|
||||
|
||||
var err error
|
||||
@@ -40,6 +54,31 @@ func Load() (Config, error) {
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("parsing cookie secure: %w", err)
|
||||
}
|
||||
cfg.AIGrowthEnabled, err = strconv.ParseBool(value("AI_GROWTH_ENABLED", "false"))
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("parsing AI growth enabled: %w", err)
|
||||
}
|
||||
cfg.AIDailyRequestBudget, err = parseInt64("AI_DAILY_REQUEST_BUDGET", 0)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.AIDailyTokenBudget, err = parseInt64("AI_DAILY_TOKEN_BUDGET", 0)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
maxConcurrent, err := parseInt64("AI_MAX_CONCURRENT", 4)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.AIMaxConcurrent = int(maxConcurrent)
|
||||
cfg.AICreatePerMinute, err = parseInt64("AI_CREATE_PER_MINUTE", 10)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.AIUncachedScopesPerDay, err = parseInt64("AI_UNCACHED_SCOPES_PER_DAY", 5)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if cfg.DatabaseURL == "" {
|
||||
return Config{}, errors.New("config: DATABASE_URL is required")
|
||||
}
|
||||
@@ -56,6 +95,17 @@ func (c Config) ValidateAPI() error {
|
||||
if c.CursorSecret == c.SessionSecret {
|
||||
return errors.New("config: CURSOR_SECRET must differ from SESSION_SECRET")
|
||||
}
|
||||
if c.AIMaxConcurrent < 1 || c.AICreatePerMinute < 1 || c.AIUncachedScopesPerDay < 1 {
|
||||
return errors.New("config: AI limits must be positive")
|
||||
}
|
||||
if c.AppEnv == "production" && c.AIGrowthEnabled {
|
||||
if c.AvalAIAPIKey == "" || c.AvalAIModel == "" {
|
||||
return errors.New("config: enabled production AI requires AVALAI_API_KEY and AVALAI_MODEL")
|
||||
}
|
||||
if c.AIDailyRequestBudget < 1 || c.AIDailyTokenBudget < 1 {
|
||||
return errors.New("config: enabled production AI requires positive daily budgets")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -72,3 +122,12 @@ func value(name, fallback string) string {
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func parseInt64(name string, fallback int64) (int64, error) {
|
||||
raw := value(name, strconv.FormatInt(fallback, 10))
|
||||
parsed, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil || parsed < 0 {
|
||||
return 0, fmt.Errorf("config: %s must be a non-negative integer", name)
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
@@ -33,3 +33,26 @@ func TestValidateAPIRequiresIndependentCursorSecret(t *testing.T) {
|
||||
t.Fatal("ValidateAPI() error = nil, want independent secrets error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAPIRequiresGrowthBudgetsInProduction(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := Config{SessionSecret: "session-secret-that-is-at-least-32-bytes", CursorSecret: "cursor-secret-that-is-at-least-32-bytes", AppEnv: "production", AIGrowthEnabled: true, AvalAIAPIKey: "key", AvalAIModel: "model", AIMaxConcurrent: 4, AICreatePerMinute: 10, AIUncachedScopesPerDay: 5}
|
||||
if err := cfg.ValidateAPI(); err == nil {
|
||||
t.Fatal("ValidateAPI() error = nil, want AI budget error")
|
||||
}
|
||||
cfg.AIDailyRequestBudget, cfg.AIDailyTokenBudget = 10, 10_000
|
||||
if err := cfg.ValidateAPI(); err != nil {
|
||||
t.Fatalf("ValidateAPI() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadGrowthDefaultsAreCostSafe(t *testing.T) {
|
||||
t.Setenv("DATABASE_URL", "postgresql://example")
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.AIGrowthEnabled || cfg.AIDailyRequestBudget != 0 || cfg.AIDailyTokenBudget != 0 || cfg.AIMaxConcurrent != 4 {
|
||||
t.Fatalf("unsafe AI defaults: %+v", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
43
internal/growth/aggregate.go
Normal file
43
internal/growth/aggregate.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package growth
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Session struct {
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
Status string `json:"status"`
|
||||
AmountRial int64 `json:"amountRial"`
|
||||
}
|
||||
|
||||
// AggregateDaily groups already-authorized, scope-filtered sessions by Tehran
|
||||
// local day. Paid and Verified are the only successful bank-payment statuses.
|
||||
func AggregateDaily(sessions []Session, catalog []Event) []DailyPoint {
|
||||
byDate := make(map[string]DailyPoint)
|
||||
for _, session := range sessions {
|
||||
local := session.CreatedAt.In(tehranLocation())
|
||||
date := time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, tehranLocation())
|
||||
key := dayKey(date)
|
||||
point := byDate[key]
|
||||
point.Date = date
|
||||
point.SessionCount++
|
||||
if session.Status == "Paid" || session.Status == "Verified" {
|
||||
point.PaidSessionCount++
|
||||
point.PaidAmountRial += session.AmountRial
|
||||
}
|
||||
byDate[key] = point
|
||||
}
|
||||
points := make([]DailyPoint, 0, len(byDate))
|
||||
for _, point := range byDate {
|
||||
for _, event := range catalog {
|
||||
if !dateBefore(point.Date, event.StartDate) && !dateAfter(point.Date, event.EndDate) {
|
||||
point.EventIDs = append(point.EventIDs, event.ID)
|
||||
}
|
||||
}
|
||||
slices.Sort(point.EventIDs)
|
||||
points = append(points, point)
|
||||
}
|
||||
slices.SortFunc(points, func(a, b DailyPoint) int { return a.Date.Compare(b.Date) })
|
||||
return points
|
||||
}
|
||||
29
internal/growth/aggregate_test.go
Normal file
29
internal/growth/aggregate_test.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package growth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAggregateDailyUsesTehranDayAndSessionGrain(t *testing.T) {
|
||||
sessions := []Session{
|
||||
{CreatedAt: time.Date(2026, 1, 1, 20, 29, 0, 0, time.UTC), Status: "Paid", AmountRial: 100},
|
||||
{CreatedAt: time.Date(2026, 1, 1, 20, 31, 0, 0, time.UTC), Status: "Verified", AmountRial: 200},
|
||||
{CreatedAt: time.Date(2026, 1, 1, 21, 0, 0, 0, time.UTC), Status: "Failed", AmountRial: 900},
|
||||
}
|
||||
points := AggregateDaily(sessions, nil)
|
||||
if len(points) != 2 || dayKey(points[0].Date) != "2026-01-01" || points[0].PaidAmountRial != 100 {
|
||||
t.Fatalf("unexpected first day: %+v", points)
|
||||
}
|
||||
if dayKey(points[1].Date) != "2026-01-02" || points[1].SessionCount != 2 || points[1].PaidSessionCount != 1 || points[1].PaidAmountRial != 200 {
|
||||
t.Fatalf("unexpected second day: %+v", points[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAggregateDailyAnnotatesCatalogEvents(t *testing.T) {
|
||||
events := []Event{{ID: "b", StartDate: date("2026-05-02"), EndDate: date("2026-05-02")}, {ID: "a", StartDate: date("2026-05-02"), EndDate: date("2026-05-02")}}
|
||||
points := AggregateDaily([]Session{{CreatedAt: time.Date(2026, 5, 2, 8, 0, 0, 0, tehranLocation()), Status: "Paid", AmountRial: 10}}, events)
|
||||
if len(points) != 1 || len(points[0].EventIDs) != 2 || points[0].EventIDs[0] != "a" || points[0].EventIDs[1] != "b" {
|
||||
t.Fatalf("unexpected event annotation: %+v", points)
|
||||
}
|
||||
}
|
||||
198
internal/growth/analytics_test.go
Normal file
198
internal/growth/analytics_test.go
Normal file
@@ -0,0 +1,198 @@
|
||||
package growth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAnalyzeEventEffectUsesMatchingWeekdaysAndThresholds(t *testing.T) {
|
||||
event := Event{ID: "teachers-day", TitleFA: "روز معلم", StartDate: date("2026-05-02"), EndDate: date("2026-05-02")}
|
||||
history := make([]DailyPoint, 0, 9)
|
||||
for weeks := 8; weeks >= 1; weeks-- {
|
||||
history = append(history, DailyPoint{Date: event.StartDate.AddDate(0, 0, -7*weeks), SessionCount: 25, PaidSessionCount: 20, PaidAmountRial: 2_000_000})
|
||||
}
|
||||
history = append(history, DailyPoint{Date: event.StartDate, SessionCount: 40, PaidSessionCount: 30, PaidAmountRial: 3_000_000})
|
||||
|
||||
effect := AnalyzeEventEffect(history, event, nil)
|
||||
if !effect.Eligible || !effect.MaterialWin {
|
||||
t.Fatalf("expected eligible material win, got %+v", effect)
|
||||
}
|
||||
if effect.BaselineSessionCount != 25 || effect.SessionUpliftBPS != 6_000 || effect.AmountUpliftBPS != 5_000 {
|
||||
t.Fatalf("unexpected effect: %+v", effect)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzeEventEffectSuppressesSmallBaseline(t *testing.T) {
|
||||
event := Event{ID: "small", StartDate: date("2026-05-02"), EndDate: date("2026-05-02")}
|
||||
history := []DailyPoint{{Date: event.StartDate.AddDate(0, 0, -7), SessionCount: 20}, {Date: event.StartDate.AddDate(0, 0, -14), SessionCount: 20}, {Date: event.StartDate.AddDate(0, 0, -21), SessionCount: 20}, {Date: event.StartDate.AddDate(0, 0, -28), SessionCount: 20}, {Date: event.StartDate, SessionCount: 50}}
|
||||
effect := AnalyzeEventEffect(history, event, nil)
|
||||
if effect.Eligible || effect.SuppressionReason != SuppressionInsufficientBaselineSessions {
|
||||
t.Fatalf("expected baseline suppression, got %+v", effect)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchUpcomingEventsPrefersFamilyThenTags(t *testing.T) {
|
||||
historical := Event{ID: "teacher", Family: "appreciation", Tags: []string{"gift", "education"}}
|
||||
upcoming := []Event{
|
||||
{ID: "exact", Family: "appreciation", Tags: []string{"unrelated"}},
|
||||
{ID: "similar", Family: "other", Tags: []string{"gift", "education", "campaign"}},
|
||||
{ID: "weak", Family: "other", Tags: []string{"internet"}},
|
||||
}
|
||||
matches := MatchUpcomingEvents(historical, upcoming)
|
||||
if len(matches) != 2 || matches[0].Event.ID != "exact" || matches[0].ScoreBPS != 10_000 || matches[1].Event.ID != "similar" {
|
||||
t.Fatalf("unexpected matches: %+v", matches)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildForecastReturnsOrderedNinetyDayIntegerRanges(t *testing.T) {
|
||||
start := date("2026-07-01")
|
||||
history := make([]DailyPoint, 0, 112)
|
||||
for i := 112; i > 0; i-- {
|
||||
day := start.AddDate(0, 0, -i)
|
||||
base := int64(100 + int(day.Weekday())*5)
|
||||
history = append(history, DailyPoint{Date: day, SessionCount: base + 20, PaidSessionCount: base, PaidAmountRial: base * 100_000})
|
||||
}
|
||||
forecast := BuildForecast(history, nil, start, 90)
|
||||
if len(forecast.Points) != 90 || forecast.Confidence == ConfidenceInsufficientData {
|
||||
t.Fatalf("unexpected forecast: confidence=%s points=%d", forecast.Confidence, len(forecast.Points))
|
||||
}
|
||||
for _, point := range forecast.Points {
|
||||
if point.PaidSessions.P10 > point.PaidSessions.P50 || point.PaidSessions.P50 > point.PaidSessions.P90 || point.PaidAmountRial.P10 > point.PaidAmountRial.P50 || point.PaidAmountRial.P50 > point.PaidAmountRial.P90 {
|
||||
t.Fatalf("unordered range: %+v", point)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectRisksRequiresTwentyPercentDropAndBaselineSize(t *testing.T) {
|
||||
forecast := Forecast{Confidence: ConfidenceMedium, Points: []ForecastPoint{
|
||||
{Date: date("2026-07-01"), BaselineSessions: 200, PaidSessions: Range{P50: 150}, BaselineAmountRial: 10_000_000, PaidAmountRial: Range{P50: 7_000_000}},
|
||||
{Date: date("2026-07-02"), BaselineSessions: 99, PaidSessions: Range{P50: 10}, BaselineAmountRial: 10_000_000, PaidAmountRial: Range{P50: 1}},
|
||||
}}
|
||||
risks := DetectRisks(forecast, 3)
|
||||
if len(risks) != 1 || risks[0].Date != date("2026-07-01") {
|
||||
t.Fatalf("unexpected risks: %+v", risks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectRisksCoalescesAdjacentDaysAndRanksDeepestFirst(t *testing.T) {
|
||||
forecast := Forecast{Confidence: ConfidenceHigh, Points: []ForecastPoint{
|
||||
{Date: date("2026-07-01"), BaselineSessions: 200, BaselineAmountRial: 1_000, PaidSessions: Range{P50: 150}, PaidAmountRial: Range{P50: 750}},
|
||||
{Date: date("2026-07-02"), BaselineSessions: 200, BaselineAmountRial: 1_000, PaidSessions: Range{P50: 100}, PaidAmountRial: Range{P50: 500}},
|
||||
{Date: date("2026-07-04"), BaselineSessions: 200, BaselineAmountRial: 1_000, PaidSessions: Range{P50: 120}, PaidAmountRial: Range{P50: 600}},
|
||||
}}
|
||||
risks := DetectRisks(forecast, 3)
|
||||
if len(risks) != 2 || risks[0].Date != date("2026-07-01") || risks[0].EndDate != date("2026-07-02") || risks[0].SessionDeclineBPS != 5_000 {
|
||||
t.Fatalf("unexpected ranked/coalesced risks: %+v", risks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluatePeerAppliesPrivacyThresholdAndEqualMerchantWeight(t *testing.T) {
|
||||
peers := make([]PeerMerchantValue, 10)
|
||||
for i := range peers {
|
||||
peers[i] = PeerMerchantValue{PaidSessions: int64(i + 1), SessionCount: 100}
|
||||
}
|
||||
result := EvaluatePeer(PeerInput{MerchantPaidSessions: 8, Peers: peers})
|
||||
if result.State != PeerReady || result.MedianPaidSessions != 5 || result.PercentileBPS != 7_500 {
|
||||
t.Fatalf("unexpected peer result: %+v", result)
|
||||
}
|
||||
suppressed := EvaluatePeer(PeerInput{TerminalScoped: true, Peers: peers})
|
||||
if suppressed.State != PeerSuppressed || suppressed.Reason != SuppressionTerminalScope {
|
||||
t.Fatalf("unexpected terminal result: %+v", suppressed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFingerprintIsStableAndVersionSensitive(t *testing.T) {
|
||||
input := FingerprintInput{MerchantID: 7, DatasetWatermark: "run-1", ForecastStart: date("2026-07-01"), HorizonDays: 90, CatalogVersion: "1", AnalyticsVersion: "1", PromptVersion: "1", SchemaVersion: "1", Model: "mini", ModelConfigVersion: "cfg-1"}
|
||||
a := Fingerprint(input)
|
||||
b := Fingerprint(input)
|
||||
input.Model = "other"
|
||||
if a == "" || a != b || a == Fingerprint(input) {
|
||||
t.Fatalf("fingerprint stability/versioning failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeerPercentileUsesMidRankForTies(t *testing.T) {
|
||||
peers := make([]PeerMerchantValue, 10)
|
||||
for index := range peers {
|
||||
peers[index] = PeerMerchantValue{PaidSessions: 50, SessionCount: 100}
|
||||
}
|
||||
result := EvaluatePeer(PeerInput{MerchantPaidSessions: 50, Peers: peers})
|
||||
if result.PercentileBPS != 5_000 {
|
||||
t.Fatalf("all-tie percentile = %d, want 5000", result.PercentileBPS)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFingerprintIncludesModelConfigurationPolicy(t *testing.T) {
|
||||
input := FingerprintInput{MerchantID: 7, ForecastStart: date("2026-07-01"), HorizonDays: 90, Model: "mini", ModelConfigVersion: "cfg-1"}
|
||||
first := Fingerprint(input)
|
||||
input.ModelConfigVersion = "cfg-2"
|
||||
if first == Fingerprint(input) {
|
||||
t.Fatal("model configuration policy must invalidate fingerprint")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTehranRefreshBoundary(t *testing.T) {
|
||||
last := time.Date(2026, 8, 20, 20, 31, 0, 0, time.UTC) // 00:01 Tehran on Aug 21.
|
||||
next := NextRefreshAt(last)
|
||||
want := time.Date(2026, 8, 21, 20, 30, 0, 0, time.UTC)
|
||||
if !next.Equal(want) || CanManualRefresh(last, want.Add(-time.Second)) || !CanManualRefresh(last, want) {
|
||||
t.Fatalf("unexpected boundary next=%s", next)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFallbackUsesOnlyEvidenceAndPersianCopy(t *testing.T) {
|
||||
bundle := EvidenceBundle{Opportunities: []Opportunity{{EvidenceID: "opp-1", EventTitleFA: "روز معلم"}}, Risks: []Risk{{EvidenceID: "risk-1"}}}
|
||||
briefs := BuildFallback(bundle)
|
||||
if len(briefs) != 2 || briefs[0].EvidenceIDs[0] != "opp-1" || briefs[0].HeadlineFA == "" {
|
||||
t.Fatalf("unexpected briefs: %+v", briefs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDeterministicReportLimitsAndOrdersOpportunities(t *testing.T) {
|
||||
start := date("2026-07-01")
|
||||
history := make([]DailyPoint, 0, 120)
|
||||
for i := 120; i > 0; i-- {
|
||||
day := start.AddDate(0, 0, -i)
|
||||
sessions := int64(120)
|
||||
amount := int64(12_000_000)
|
||||
if day.Equal(date("2026-05-02")) {
|
||||
sessions = 180
|
||||
amount = 18_000_000
|
||||
}
|
||||
history = append(history, DailyPoint{Date: day, SessionCount: sessions, PaidSessionCount: sessions, PaidAmountRial: amount})
|
||||
}
|
||||
catalog := []Event{
|
||||
{ID: "past", TitleFA: "روز معلم", StartDate: date("2026-05-02"), EndDate: date("2026-05-02"), Family: "appreciation", Tags: []string{"gift"}},
|
||||
{ID: "future", TitleFA: "روز پدر", StartDate: date("2026-07-10"), EndDate: date("2026-07-10"), Family: "appreciation", Tags: []string{"gift"}},
|
||||
}
|
||||
report := BuildDeterministicReport(ReportInput{History: history, Catalog: catalog, ForecastStart: start, HorizonDays: 90, Peer: &PeerInput{TerminalScoped: true}})
|
||||
if len(report.HistoricalWins) != 1 || len(report.Opportunities) != 1 || report.Opportunities[0].EventID != "future" {
|
||||
t.Fatalf("unexpected report evidence: %+v", report)
|
||||
}
|
||||
if report.Peer == nil || report.Peer.Reason != SuppressionTerminalScope {
|
||||
t.Fatalf("expected terminal peer suppression: %+v", report.Peer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFingerprintTreatsEquivalentForecastDatesEqually(t *testing.T) {
|
||||
tehran, err := time.LoadLocation("Asia/Tehran")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
local := time.Date(2026, 7, 1, 0, 0, 0, 0, tehran)
|
||||
utcInstant := local.UTC()
|
||||
base := FingerprintInput{MerchantID: 1, ForecastStart: local, HorizonDays: 90}
|
||||
a := Fingerprint(base)
|
||||
base.ForecastStart = utcInstant
|
||||
if a != Fingerprint(base) {
|
||||
t.Fatal("equivalent forecast instants must fingerprint equally")
|
||||
}
|
||||
}
|
||||
|
||||
func date(value string) time.Time {
|
||||
parsed, err := time.Parse(time.DateOnly, value)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
103
internal/growth/catalog.go
Normal file
103
internal/growth/catalog.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package growth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const officialCalendarSource = "https://calendar.ut.ac.ir/"
|
||||
const curatedCommercialSource = "CURATED_COMMERCIAL_1405_V1"
|
||||
const officialCalendarVersion = "IR_OFFICIAL_1404_1405_REVIEWED_2026-08-21"
|
||||
|
||||
var importedMerchantCategories = []string{
|
||||
"ارائه دهنده خدمات اینترنت",
|
||||
"مراکز آموزشی مجازی",
|
||||
"کیف و کفش فروشی",
|
||||
"خدمات شبکههای کامپیوتری و اینترنت",
|
||||
"فروشگاه لوازم آرایشی و بهداشتی",
|
||||
}
|
||||
|
||||
// Catalog1405 returns a defensive copy of the reviewed initial catalog. Dates are
|
||||
// Gregorian Tehran-local calendar dates and cover the imported 1405 history plus
|
||||
// the first 90 forecast days after 2026-06-30.
|
||||
func Catalog1405() []Event {
|
||||
events := []Event{
|
||||
officialEvent("fathers-day-1404", "روز پدر", "2026-01-03", "2026-01-03", "appreciation", true, []string{"هدیه", "قدردانی"}),
|
||||
officialEvent("mabath-1404", "مبعث پیامبر اکرم", "2026-01-17", "2026-01-17", "religious-holiday", true, []string{"تعطیل", "مناسبت-مذهبی"}),
|
||||
officialEvent("imam-mahdi-birthday-1404", "نیمه شعبان", "2026-02-04", "2026-02-04", "religious-holiday", true, []string{"تعطیل", "مناسبت-مذهبی"}),
|
||||
officialEvent("revolution-day-1404", "پیروزی انقلاب اسلامی", "2026-02-11", "2026-02-11", "national-holiday", true, []string{"تعطیل", "مناسبت-ملی"}),
|
||||
officialEvent("imam-ali-martyrdom-1404", "شهادت امام علی", "2026-03-11", "2026-03-11", "religious-holiday", true, []string{"تعطیل", "مناسبت-مذهبی"}),
|
||||
officialEvent("oil-nationalization-1404", "ملی شدن صنعت نفت", "2026-03-19", "2026-03-19", "national-holiday", true, []string{"تعطیل", "مناسبت-ملی"}),
|
||||
officialEvent("nowruz-1405", "نوروز", "2026-03-21", "2026-03-24", "new-year", true, []string{"هدیه", "خرید", "سال-نو", "تعطیل"}),
|
||||
officialEvent("islamic-republic-day-1405", "روز جمهوری اسلامی", "2026-04-01", "2026-04-01", "national-holiday", true, []string{"تعطیل", "مناسبت-ملی"}),
|
||||
officialEvent("nature-day-1405", "روز طبیعت", "2026-04-02", "2026-04-02", "leisure", true, []string{"تفریح", "خرید", "تعطیل"}),
|
||||
officialEvent("imam-sadegh-martyrdom-1405", "شهادت امام جعفر صادق", "2026-04-14", "2026-04-14", "religious-holiday", true, []string{"تعطیل", "مناسبت-مذهبی"}),
|
||||
officialEvent("teachers-day-1405", "روز معلم", "2026-05-02", "2026-05-02", "appreciation", false, []string{"هدیه", "آموزش", "قدردانی"}),
|
||||
officialEvent("eid-al-adha-1405", "عید قربان", "2026-05-27", "2026-05-27", "religious-holiday", true, []string{"تعطیل", "مناسبت-مذهبی"}),
|
||||
officialEvent("khomeini-ghadir-1405", "رحلت امام خمینی و عید غدیر", "2026-06-04", "2026-06-04", "official-holiday", true, []string{"تعطیل", "مناسبت-ملی", "مناسبت-مذهبی"}),
|
||||
officialEvent("khordad-15-1405", "قیام پانزده خرداد", "2026-06-05", "2026-06-05", "national-holiday", true, []string{"تعطیل", "مناسبت-ملی"}),
|
||||
officialEvent("tasua-1405", "تاسوعای حسینی", "2026-06-24", "2026-06-24", "religious-holiday", true, []string{"تعطیل", "مناسبت-مذهبی"}),
|
||||
officialEvent("ashura-1405", "عاشورای حسینی", "2026-06-25", "2026-06-25", "religious-holiday", true, []string{"تعطیل", "مناسبت-مذهبی"}),
|
||||
commercialEvent("summer-midseason-1405", "میانه فصل تابستان", "2026-07-15", "2026-07-17", "seasonal-shopping", []string{"خرید", "تابستان", "پیشنهاد-فصلی"}, importedMerchantCategories),
|
||||
officialEvent("arbaeen-1405", "اربعین حسینی", "2026-08-04", "2026-08-04", "religious-holiday", true, []string{"تعطیل", "مناسبت-مذهبی"}),
|
||||
officialEvent("prophet-demise-1405", "رحلت پیامبر اکرم و شهادت امام حسن", "2026-08-12", "2026-08-12", "religious-holiday", true, []string{"تعطیل", "مناسبت-مذهبی"}),
|
||||
officialEvent("doctors-day-1405", "روز پزشک", "2026-08-23", "2026-08-23", "appreciation", false, []string{"هدیه", "قدردانی", "حرفهای"}),
|
||||
officialEvent("prophet-birthday-1405", "میلاد پیامبر اکرم و امام جعفر صادق", "2026-08-30", "2026-08-30", "religious-holiday", true, []string{"تعطیل", "مناسبت-مذهبی"}),
|
||||
commercialEvent("back-to-school-1405", "آمادگی بازگشایی مدارس", "2026-09-10", "2026-09-22", "education-season", []string{"آموزش", "خرید", "مدرسه"}, []string{"مراکز آموزشی مجازی", "کیف و کفش فروشی"}),
|
||||
commercialEvent("autumn-opening-1405", "آغاز فصل پاییز", "2026-09-23", "2026-09-28", "seasonal-shopping", []string{"خرید", "پاییز", "پیشنهاد-فصلی"}, importedMerchantCategories),
|
||||
}
|
||||
result := make([]Event, len(events))
|
||||
for index, event := range events {
|
||||
result[index] = event
|
||||
result[index].Tags = slices.Clone(event.Tags)
|
||||
result[index].Categories = slices.Clone(event.Categories)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func officialEvent(id, title, start, end, family string, holiday bool, tags []string) Event {
|
||||
return Event{ID: id, TitleFA: title, StartDate: mustCatalogDate(start), EndDate: mustCatalogDate(end), Family: family, Tags: slices.Clone(tags), Source: officialCalendarSource, SourceVersion: officialCalendarVersion, Kind: EventOfficial, OfficialHoliday: holiday, Universal: true}
|
||||
}
|
||||
|
||||
func commercialEvent(id, title, start, end, family string, tags, categories []string) Event {
|
||||
return Event{ID: id, TitleFA: title, StartDate: mustCatalogDate(start), EndDate: mustCatalogDate(end), Family: family, Tags: slices.Clone(tags), Source: curatedCommercialSource, SourceVersion: CatalogVersion, Categories: slices.Clone(categories), Kind: EventCommercial, Universal: false}
|
||||
}
|
||||
|
||||
func ValidateCatalog(events []Event) error {
|
||||
seen := make(map[string]struct{}, len(events))
|
||||
for index, event := range events {
|
||||
if strings.TrimSpace(event.ID) == "" || strings.TrimSpace(event.TitleFA) == "" || strings.TrimSpace(event.Family) == "" || strings.TrimSpace(event.Source) == "" || strings.TrimSpace(event.SourceVersion) == "" {
|
||||
return fmt.Errorf("growth catalog event %d is incomplete", index)
|
||||
}
|
||||
if _, exists := seen[event.ID]; exists {
|
||||
return fmt.Errorf("growth catalog contains duplicate id %q", event.ID)
|
||||
}
|
||||
seen[event.ID] = struct{}{}
|
||||
if event.StartDate.IsZero() || event.EndDate.Before(event.StartDate) {
|
||||
return fmt.Errorf("growth catalog event %q has invalid date range", event.ID)
|
||||
}
|
||||
if event.Kind != EventOfficial && event.Kind != EventCommercial {
|
||||
return fmt.Errorf("growth catalog event %q has invalid kind", event.ID)
|
||||
}
|
||||
if len(normalizedSet(event.Tags)) == 0 {
|
||||
return fmt.Errorf("growth catalog event %q lacks tags", event.ID)
|
||||
}
|
||||
if event.Universal && len(event.Categories) != 0 {
|
||||
return fmt.Errorf("growth catalog universal event %q must not contain categories", event.ID)
|
||||
}
|
||||
if !event.Universal && len(event.Categories) == 0 {
|
||||
return fmt.Errorf("growth catalog scoped event %q lacks categories", event.ID)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mustCatalogDate(value string) time.Time {
|
||||
parsed, err := time.Parse(time.DateOnly, value)
|
||||
if err != nil {
|
||||
panic("invalid reviewed event catalog date: " + value)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
111
internal/growth/catalog_test.go
Normal file
111
internal/growth/catalog_test.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package growth
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCatalog1405IsValidAndDefensivelyCopied(t *testing.T) {
|
||||
first := Catalog1405()
|
||||
if err := ValidateCatalog(first); err != nil {
|
||||
t.Fatalf("catalog should be valid: %v", err)
|
||||
}
|
||||
first[0].Tags[0] = "mutated"
|
||||
second := Catalog1405()
|
||||
if second[0].Tags[0] == "mutated" {
|
||||
t.Fatal("catalog tags escaped through a shared backing array")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCatalogRejectsDuplicateAndIncompleteEvents(t *testing.T) {
|
||||
event := officialEvent("duplicate", "رویداد", "2026-01-01", "2026-01-01", "family", false, []string{"tag"})
|
||||
if err := ValidateCatalog([]Event{event, event}); err == nil {
|
||||
t.Fatal("expected duplicate id error")
|
||||
}
|
||||
event.Source = ""
|
||||
if err := ValidateCatalog([]Event{event}); err == nil {
|
||||
t.Fatal("expected missing source error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogCoversImportedHistoryAndForecastHorizon(t *testing.T) {
|
||||
catalog := Catalog1405()
|
||||
if catalog[0].StartDate.After(date("2026-01-03")) || catalog[len(catalog)-1].EndDate.Before(date("2026-09-28")) {
|
||||
t.Fatalf("catalog coverage is incomplete: %s..%s", catalog[0].StartDate, catalog[len(catalog)-1].EndDate)
|
||||
}
|
||||
var scoped bool
|
||||
for _, event := range catalog {
|
||||
if event.ID == "back-to-school-1405" {
|
||||
scoped = !event.Universal && len(event.Categories) == 2
|
||||
}
|
||||
}
|
||||
if !scoped {
|
||||
t.Fatal("back-to-school event must preserve explicit category scope")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzeEventEffectExcludesCatalogEventsFromBaseline(t *testing.T) {
|
||||
target := Event{ID: "target", StartDate: date("2026-05-02"), EndDate: date("2026-05-02")}
|
||||
excluded := Event{ID: "other", StartDate: target.StartDate.AddDate(0, 0, -7), EndDate: target.StartDate.AddDate(0, 0, -7)}
|
||||
history := make([]DailyPoint, 0, 9)
|
||||
for week := 1; week <= 8; week++ {
|
||||
count := int64(30)
|
||||
if week == 1 {
|
||||
count = 1_000
|
||||
}
|
||||
history = append(history, DailyPoint{Date: target.StartDate.AddDate(0, 0, -7*week), SessionCount: count, PaidSessionCount: count, PaidAmountRial: count * 100})
|
||||
}
|
||||
history = append(history, DailyPoint{Date: target.StartDate, SessionCount: 50, PaidSessionCount: 50, PaidAmountRial: 5_000})
|
||||
effect := AnalyzeEventEffect(history, target, []Event{excluded})
|
||||
if effect.ObservationCount != 7 || effect.BaselineSessionCount != 30 || !effect.MaterialWin {
|
||||
t.Fatalf("catalog event polluted baseline: %+v", effect)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzeEventEffectUsesCompleteComparableWindows(t *testing.T) {
|
||||
target := Event{ID: "two-day", StartDate: date("2026-05-02"), EndDate: date("2026-05-03")}
|
||||
history := []DailyPoint{
|
||||
{Date: date("2026-04-25"), SessionCount: 60, PaidSessionCount: 50, PaidAmountRial: 5_000},
|
||||
{Date: date("2026-04-26"), SessionCount: 60, PaidSessionCount: 50, PaidAmountRial: 5_000},
|
||||
{Date: date("2026-04-18"), SessionCount: 1_000, PaidSessionCount: 1_000, PaidAmountRial: 100_000}, // fragmented week, next day absent
|
||||
{Date: date("2026-04-11"), SessionCount: 60, PaidSessionCount: 50, PaidAmountRial: 5_000},
|
||||
{Date: date("2026-04-12"), SessionCount: 60, PaidSessionCount: 50, PaidAmountRial: 5_000},
|
||||
{Date: date("2026-04-04"), SessionCount: 60, PaidSessionCount: 50, PaidAmountRial: 5_000},
|
||||
{Date: date("2026-04-05"), SessionCount: 60, PaidSessionCount: 50, PaidAmountRial: 5_000},
|
||||
{Date: date("2026-03-28"), SessionCount: 60, PaidSessionCount: 50, PaidAmountRial: 5_000},
|
||||
{Date: date("2026-03-29"), SessionCount: 60, PaidSessionCount: 50, PaidAmountRial: 5_000},
|
||||
{Date: target.StartDate, SessionCount: 80, PaidSessionCount: 75, PaidAmountRial: 7_500},
|
||||
{Date: target.EndDate, SessionCount: 80, PaidSessionCount: 75, PaidAmountRial: 7_500},
|
||||
}
|
||||
effect := AnalyzeEventEffect(history, target, nil)
|
||||
if effect.ObservationCount != 4 || effect.BaselineSessionCount != 120 || effect.ActualSessionCount != 160 || effect.PaidSessionUpliftBPS != 5_000 {
|
||||
t.Fatalf("unexpected complete-window effect: %+v", effect)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForecastSuppressesPoorBacktest(t *testing.T) {
|
||||
start := date("2026-07-01")
|
||||
history := make([]DailyPoint, 0, 140)
|
||||
for i := 140; i > 0; i-- {
|
||||
value := int64(100)
|
||||
if i%2 == 0 {
|
||||
value = 1_000
|
||||
}
|
||||
history = append(history, DailyPoint{Date: start.AddDate(0, 0, -i), SessionCount: value, PaidSessionCount: value, PaidAmountRial: value * 1_000})
|
||||
}
|
||||
forecast := BuildForecast(history, nil, start, 90)
|
||||
if forecast.Backtest.Origins < 4 || forecast.Confidence != ConfidenceInsufficientData || len(forecast.Points) != 0 {
|
||||
t.Fatalf("poor model should be suppressed: %+v", forecast)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBacktestUsesProductionTrendPredictorAcrossFourteenDayHorizons(t *testing.T) {
|
||||
start := date("2026-07-01")
|
||||
history := make([]DailyPoint, 0, 140)
|
||||
for index := 0; index < 140; index++ {
|
||||
block := index / 28
|
||||
value := int64(100 + block*20)
|
||||
history = append(history, DailyPoint{Date: start.AddDate(0, 0, index-140), SessionCount: value, PaidSessionCount: value, PaidAmountRial: value * 1_000})
|
||||
}
|
||||
forecast := BuildForecast(history, nil, start, 90)
|
||||
if forecast.Backtest.Origins < 4 || forecast.Confidence == ConfidenceInsufficientData {
|
||||
t.Fatalf("trend-aware rolling backtest should be usable: %+v", forecast.Backtest)
|
||||
}
|
||||
}
|
||||
171
internal/growth/events.go
Normal file
171
internal/growth/events.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package growth
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func AnalyzeEventEffect(history []DailyPoint, event Event, catalog []Event) EventEffect {
|
||||
effect := EventEffect{EvidenceID: "event:" + event.ID, EventID: event.ID}
|
||||
eventDates := catalogDates(append(slices.Clone(catalog), event))
|
||||
byDate := pointsByDate(history)
|
||||
eventDays := daysInclusive(event.StartDate, event.EndDate)
|
||||
var baselineSessions, baselinePaid, baselineAmount int64
|
||||
actualComplete := true
|
||||
for _, eventDay := range eventDays {
|
||||
point, ok := byDate[dayKey(eventDay)]
|
||||
if !ok {
|
||||
actualComplete = false
|
||||
break
|
||||
}
|
||||
effect.ActualSessionCount += point.SessionCount
|
||||
effect.ActualPaidSessions += point.PaidSessionCount
|
||||
effect.ActualAmountRial += point.PaidAmountRial
|
||||
}
|
||||
if !actualComplete {
|
||||
effect.SuppressionReason = SuppressionIncompleteEventWindow
|
||||
return effect
|
||||
}
|
||||
for week := 1; week <= 8; week++ {
|
||||
var windowSessions, windowPaid, windowAmount int64
|
||||
complete := true
|
||||
for _, eventDay := range eventDays {
|
||||
candidate := eventDay.AddDate(0, 0, -7*week)
|
||||
if eventDates[dayKey(candidate)] {
|
||||
complete = false
|
||||
break
|
||||
}
|
||||
point, ok := byDate[dayKey(candidate)]
|
||||
if !ok {
|
||||
complete = false
|
||||
break
|
||||
}
|
||||
windowSessions += point.SessionCount
|
||||
windowPaid += point.PaidSessionCount
|
||||
windowAmount += point.PaidAmountRial
|
||||
}
|
||||
if !complete {
|
||||
continue
|
||||
}
|
||||
effect.ObservationCount++
|
||||
baselineSessions += windowSessions
|
||||
baselinePaid += windowPaid
|
||||
baselineAmount += windowAmount
|
||||
}
|
||||
if effect.ObservationCount < MinBaselineObservations {
|
||||
effect.SuppressionReason = SuppressionInsufficientObservations
|
||||
return effect
|
||||
}
|
||||
effect.BaselineSessionCount = baselineSessions / int64(effect.ObservationCount)
|
||||
effect.BaselinePaidSessions = baselinePaid / int64(effect.ObservationCount)
|
||||
effect.BaselineAmountRial = baselineAmount / int64(effect.ObservationCount)
|
||||
if baselineSessions < MinBaselineSessions {
|
||||
effect.SuppressionReason = SuppressionInsufficientBaselineSessions
|
||||
return effect
|
||||
}
|
||||
effect.Eligible = true
|
||||
effect.SessionUpliftBPS = changeBPS(effect.ActualSessionCount, effect.BaselineSessionCount)
|
||||
effect.PaidSessionUpliftBPS = changeBPS(effect.ActualPaidSessions, effect.BaselinePaidSessions)
|
||||
effect.AmountUpliftBPS = changeBPS(effect.ActualAmountRial, effect.BaselineAmountRial)
|
||||
effect.MaterialWin = effect.PaidSessionUpliftBPS >= MaterialChangeBPS || effect.AmountUpliftBPS >= MaterialChangeBPS
|
||||
effect.MaterialDecline = effect.PaidSessionUpliftBPS <= -MaterialChangeBPS || effect.AmountUpliftBPS <= -MaterialChangeBPS
|
||||
return effect
|
||||
}
|
||||
|
||||
func MatchScoreBPS(historical, upcoming Event) (int64, string) {
|
||||
if historical.Family != "" && strings.EqualFold(strings.TrimSpace(historical.Family), strings.TrimSpace(upcoming.Family)) {
|
||||
return 10_000, "FAMILY"
|
||||
}
|
||||
a := normalizedSet(historical.Tags)
|
||||
b := normalizedSet(upcoming.Tags)
|
||||
if len(a) == 0 || len(b) == 0 {
|
||||
return 0, "TAGS"
|
||||
}
|
||||
intersection := 0
|
||||
union := make(map[string]struct{}, len(a)+len(b))
|
||||
for tag := range a {
|
||||
union[tag] = struct{}{}
|
||||
if _, ok := b[tag]; ok {
|
||||
intersection++
|
||||
}
|
||||
}
|
||||
for tag := range b {
|
||||
union[tag] = struct{}{}
|
||||
}
|
||||
return int64(intersection * 10_000 / len(union)), "TAGS"
|
||||
}
|
||||
|
||||
func MatchUpcomingEvents(historical Event, upcoming []Event) []EventMatch {
|
||||
matches := make([]EventMatch, 0, len(upcoming))
|
||||
for _, candidate := range upcoming {
|
||||
score, basis := MatchScoreBPS(historical, candidate)
|
||||
if score >= MinSimilarityBPS {
|
||||
matches = append(matches, EventMatch{Event: candidate, ScoreBPS: score, MatchBasis: basis})
|
||||
}
|
||||
}
|
||||
slices.SortFunc(matches, func(a, b EventMatch) int {
|
||||
if a.ScoreBPS != b.ScoreBPS {
|
||||
return int(b.ScoreBPS - a.ScoreBPS)
|
||||
}
|
||||
return strings.Compare(a.Event.ID, b.Event.ID)
|
||||
})
|
||||
return matches
|
||||
}
|
||||
|
||||
func pointsByDate(points []DailyPoint) map[string]DailyPoint {
|
||||
result := make(map[string]DailyPoint, len(points))
|
||||
for _, point := range points {
|
||||
result[dayKey(point.Date)] = point
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func catalogDates(events []Event) map[string]bool {
|
||||
result := make(map[string]bool)
|
||||
for _, event := range events {
|
||||
for _, day := range daysInclusive(event.StartDate, event.EndDate) {
|
||||
result[dayKey(day)] = true
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func daysInclusive(start, end time.Time) []time.Time {
|
||||
if end.Before(start) {
|
||||
return nil
|
||||
}
|
||||
days := make([]time.Time, 0, int(end.Sub(start).Hours()/24)+1)
|
||||
for day := normalizeDate(start); !day.After(normalizeDate(end)); day = day.AddDate(0, 0, 1) {
|
||||
days = append(days, day)
|
||||
}
|
||||
return days
|
||||
}
|
||||
|
||||
func normalizedSet(values []string) map[string]struct{} {
|
||||
result := make(map[string]struct{}, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
if value != "" {
|
||||
result[value] = struct{}{}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func normalizeDate(value time.Time) time.Time {
|
||||
year, month, day := value.Date()
|
||||
return time.Date(year, month, day, 0, 0, 0, 0, value.Location())
|
||||
}
|
||||
|
||||
func dayKey(value time.Time) string { return normalizeDate(value).Format(time.DateOnly) }
|
||||
|
||||
func dateBefore(a, b time.Time) bool { return dayKey(a) < dayKey(b) }
|
||||
func dateAfter(a, b time.Time) bool { return dayKey(a) > dayKey(b) }
|
||||
|
||||
func changeBPS(actual, baseline int64) int64 {
|
||||
if baseline <= 0 {
|
||||
return 0
|
||||
}
|
||||
return (actual - baseline) * 10_000 / baseline
|
||||
}
|
||||
323
internal/growth/forecast.go
Normal file
323
internal/growth/forecast.go
Normal file
@@ -0,0 +1,323 @@
|
||||
package growth
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultHorizonDays = 90
|
||||
minimumHistoryDays = 56
|
||||
maxTrendBPS = 2_000
|
||||
)
|
||||
|
||||
func BuildForecast(history []DailyPoint, catalog []Event, start time.Time, horizonDays int) Forecast {
|
||||
if horizonDays <= 0 {
|
||||
horizonDays = defaultHorizonDays
|
||||
}
|
||||
result := Forecast{StartDate: normalizeDate(start), HorizonDays: horizonDays, Confidence: ConfidenceInsufficientData, SuppressionReason: SuppressionInsufficientHistory, Points: []ForecastPoint{}}
|
||||
ordered := normalizedHistory(history, start)
|
||||
if len(ordered) < minimumHistoryDays {
|
||||
return result
|
||||
}
|
||||
result.Backtest = backtest(ordered, catalog)
|
||||
result.Confidence = classifyConfidence(result.Backtest)
|
||||
if result.Confidence == ConfidenceInsufficientData {
|
||||
return result
|
||||
}
|
||||
result.SuppressionReason = ""
|
||||
result.Points = forecastPoints(ordered, catalog, result.StartDate, horizonDays)
|
||||
return result
|
||||
}
|
||||
|
||||
func forecastPoints(history []DailyPoint, catalog []Event, start time.Time, horizon int) []ForecastPoint {
|
||||
sessionResiduals, amountResiduals := modelResiduals(history, catalog)
|
||||
points := make([]ForecastPoint, 0, horizon)
|
||||
for index := 0; index < horizon; index++ {
|
||||
day := start.AddDate(0, 0, index)
|
||||
baselineSessions, baselineAmount, predictedSessions, predictedAmount, eventIDs := predictCenter(history, catalog, day)
|
||||
points = append(points, ForecastPoint{
|
||||
Date: day, BaselineSessions: baselineSessions, BaselineAmountRial: baselineAmount,
|
||||
PaidSessions: rangeFromResiduals(predictedSessions, sessionResiduals),
|
||||
PaidAmountRial: rangeFromResiduals(predictedAmount, amountResiduals), EventIDs: eventIDs,
|
||||
})
|
||||
}
|
||||
return points
|
||||
}
|
||||
|
||||
func normalizedHistory(history []DailyPoint, before time.Time) []DailyPoint {
|
||||
ordered := make([]DailyPoint, 0, len(history))
|
||||
for _, point := range history {
|
||||
if dateBefore(point.Date, before) {
|
||||
point.Date = normalizeDate(point.Date)
|
||||
ordered = append(ordered, point)
|
||||
}
|
||||
}
|
||||
slices.SortFunc(ordered, func(a, b DailyPoint) int { return a.Date.Compare(b.Date) })
|
||||
return ordered
|
||||
}
|
||||
|
||||
func weekdayMedian(history []DailyPoint, weekday time.Weekday, value func(DailyPoint) int64) int64 {
|
||||
values := make([]int64, 0, 8)
|
||||
for index := len(history) - 1; index >= 0 && len(values) < 8; index-- {
|
||||
if history[index].Date.Weekday() == weekday {
|
||||
values = append(values, value(history[index]))
|
||||
}
|
||||
}
|
||||
return median(values)
|
||||
}
|
||||
|
||||
func median(values []int64) int64 {
|
||||
if len(values) == 0 {
|
||||
return 0
|
||||
}
|
||||
copyOfValues := slices.Clone(values)
|
||||
slices.Sort(copyOfValues)
|
||||
middle := len(copyOfValues) / 2
|
||||
if len(copyOfValues)%2 == 1 {
|
||||
return copyOfValues[middle]
|
||||
}
|
||||
return (copyOfValues[middle-1] + copyOfValues[middle]) / 2
|
||||
}
|
||||
|
||||
func modelResiduals(history []DailyPoint, catalog []Event) ([]int64, []int64) {
|
||||
sessions := make([]int64, 0, maxInt(0, len(history)-minimumHistoryDays))
|
||||
amounts := make([]int64, 0, maxInt(0, len(history)-minimumHistoryDays))
|
||||
for index := minimumHistoryDays; index < len(history); index++ {
|
||||
prior := history[:index]
|
||||
_, _, predictedSessions, predictedAmount, _ := predictCenter(prior, catalog, history[index].Date)
|
||||
sessions = append(sessions, history[index].PaidSessionCount-predictedSessions)
|
||||
amounts = append(amounts, history[index].PaidAmountRial-predictedAmount)
|
||||
}
|
||||
return sessions, amounts
|
||||
}
|
||||
|
||||
func rangeFromResiduals(center int64, residualValues []int64) Range {
|
||||
return Range{
|
||||
P10: maxInt64(0, center+quantile(residualValues, 10)),
|
||||
P50: maxInt64(0, center+quantile(residualValues, 50)),
|
||||
P90: maxInt64(0, center+quantile(residualValues, 90)),
|
||||
}
|
||||
}
|
||||
|
||||
func quantile(values []int64, percentile int) int64 {
|
||||
if len(values) == 0 {
|
||||
return 0
|
||||
}
|
||||
ordered := slices.Clone(values)
|
||||
slices.Sort(ordered)
|
||||
index := (len(ordered) - 1) * percentile / 100
|
||||
return ordered[index]
|
||||
}
|
||||
|
||||
func trendBPS(history []DailyPoint, value func(DailyPoint) int64) int64 {
|
||||
if len(history) < 56 {
|
||||
return 0
|
||||
}
|
||||
var recent, previous int64
|
||||
for _, point := range history[len(history)-28:] {
|
||||
recent += value(point)
|
||||
}
|
||||
for _, point := range history[len(history)-56 : len(history)-28] {
|
||||
previous += value(point)
|
||||
}
|
||||
trend := changeBPS(recent, previous)
|
||||
if trend > maxTrendBPS {
|
||||
return maxTrendBPS
|
||||
}
|
||||
if trend < -maxTrendBPS {
|
||||
return -maxTrendBPS
|
||||
}
|
||||
return trend
|
||||
}
|
||||
|
||||
func backtest(history []DailyPoint, catalog []Event) BacktestResult {
|
||||
result := BacktestResult{}
|
||||
var sessionError, amountError, covered int64
|
||||
var evaluated int64
|
||||
first := len(history) - 56
|
||||
for origin := first; origin < len(history); origin += 14 {
|
||||
if origin < minimumHistoryDays {
|
||||
continue
|
||||
}
|
||||
result.Origins++
|
||||
training := history[:origin]
|
||||
sessionResiduals, amountResiduals := modelResiduals(training, catalog)
|
||||
for index := origin; index < minInt(origin+14, len(history)); index++ {
|
||||
actual := history[index]
|
||||
_, _, sessionCenter, amountCenter, _ := predictCenter(training, catalog, actual.Date)
|
||||
sessionRange := rangeFromResiduals(sessionCenter, sessionResiduals)
|
||||
amountRange := rangeFromResiduals(amountCenter, amountResiduals)
|
||||
sessionError += absolutePercentageErrorBPS(actual.PaidSessionCount, sessionRange.P50)
|
||||
amountError += absolutePercentageErrorBPS(actual.PaidAmountRial, amountRange.P50)
|
||||
if actual.PaidSessionCount >= sessionRange.P10 && actual.PaidSessionCount <= sessionRange.P90 && actual.PaidAmountRial >= amountRange.P10 && actual.PaidAmountRial <= amountRange.P90 {
|
||||
covered++
|
||||
}
|
||||
evaluated++
|
||||
}
|
||||
}
|
||||
if evaluated > 0 {
|
||||
result.SessionMAPEBPS = sessionError / evaluated
|
||||
result.AmountMAPEBPS = amountError / evaluated
|
||||
result.IntervalCoverageBPS = covered * 10_000 / evaluated
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func predictCenter(history []DailyPoint, catalog []Event, day time.Time) (int64, int64, int64, int64, []string) {
|
||||
baselineSessions := weekdayMedian(history, day.Weekday(), func(p DailyPoint) int64 { return p.PaidSessionCount })
|
||||
baselineAmount := weekdayMedian(history, day.Weekday(), func(p DailyPoint) int64 { return p.PaidAmountRial })
|
||||
predictedSessions := applyBPS(baselineSessions, trendBPS(history, func(p DailyPoint) int64 { return p.PaidSessionCount }))
|
||||
predictedAmount := applyBPS(baselineAmount, trendBPS(history, func(p DailyPoint) int64 { return p.PaidAmountRial }))
|
||||
eventIDs := make([]string, 0, 1)
|
||||
familyEffects := eventFamilyEffects(history, catalog)
|
||||
for _, event := range catalog {
|
||||
if dateBefore(day, event.StartDate) || dateAfter(day, event.EndDate) {
|
||||
continue
|
||||
}
|
||||
eventIDs = append(eventIDs, event.ID)
|
||||
if uplift, ok := familyEffects[event.Family]; ok {
|
||||
predictedSessions = applyBPS(predictedSessions, uplift.sessions)
|
||||
predictedAmount = applyBPS(predictedAmount, uplift.amount)
|
||||
}
|
||||
}
|
||||
return baselineSessions, baselineAmount, predictedSessions, predictedAmount, eventIDs
|
||||
}
|
||||
|
||||
func classifyConfidence(backtest BacktestResult) Confidence {
|
||||
if backtest.Origins < 4 {
|
||||
return ConfidenceInsufficientData
|
||||
}
|
||||
worstMAPE := maxInt64(backtest.SessionMAPEBPS, backtest.AmountMAPEBPS)
|
||||
if worstMAPE <= 2_000 && backtest.IntervalCoverageBPS >= 7_000 {
|
||||
return ConfidenceHigh
|
||||
}
|
||||
if worstMAPE <= 4_000 && backtest.IntervalCoverageBPS >= 5_000 {
|
||||
return ConfidenceMedium
|
||||
}
|
||||
return ConfidenceInsufficientData
|
||||
}
|
||||
|
||||
type familyUplift struct{ sessions, amount int64 }
|
||||
|
||||
func eventFamilyEffects(history []DailyPoint, catalog []Event) map[string]familyUplift {
|
||||
result := make(map[string]familyUplift)
|
||||
counts := make(map[string]int64)
|
||||
for _, event := range catalog {
|
||||
if event.Family == "" || dateAfter(event.StartDate, history[len(history)-1].Date) {
|
||||
continue
|
||||
}
|
||||
effect := AnalyzeEventEffect(history, event, catalog)
|
||||
if !effect.Eligible {
|
||||
continue
|
||||
}
|
||||
value := result[event.Family]
|
||||
value.sessions += effect.PaidSessionUpliftBPS
|
||||
value.amount += effect.AmountUpliftBPS
|
||||
result[event.Family] = value
|
||||
counts[event.Family]++
|
||||
}
|
||||
for family, value := range result {
|
||||
value.sessions /= counts[family]
|
||||
value.amount /= counts[family]
|
||||
result[family] = value
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func applyBPS(value, adjustment int64) int64 { return maxInt64(0, value*(10_000+adjustment)/10_000) }
|
||||
|
||||
func absolutePercentageErrorBPS(actual, predicted int64) int64 {
|
||||
if actual <= 0 {
|
||||
if predicted == 0 {
|
||||
return 0
|
||||
}
|
||||
return 10_000
|
||||
}
|
||||
difference := actual - predicted
|
||||
if difference < 0 {
|
||||
difference = -difference
|
||||
}
|
||||
return difference * 10_000 / actual
|
||||
}
|
||||
|
||||
func maxInt64(a, b int64) int64 {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
func minInt(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func maxInt(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func DetectRisks(forecast Forecast, limit int) []Risk {
|
||||
if forecast.Confidence == ConfidenceInsufficientData || limit <= 0 {
|
||||
return nil
|
||||
}
|
||||
candidates := make([]Risk, 0, len(forecast.Points))
|
||||
for _, point := range forecast.Points {
|
||||
if point.BaselineSessions < MinBaselineSessions {
|
||||
continue
|
||||
}
|
||||
sessionDecline := -changeBPS(point.PaidSessions.P50, point.BaselineSessions)
|
||||
amountDecline := -changeBPS(point.PaidAmountRial.P50, point.BaselineAmountRial)
|
||||
if sessionDecline < MaterialChangeBPS && amountDecline < MaterialChangeBPS {
|
||||
continue
|
||||
}
|
||||
candidates = append(candidates, Risk{Date: point.Date, EndDate: point.Date, SessionDeclineBPS: maxInt64(0, sessionDecline), AmountDeclineBPS: maxInt64(0, amountDecline), ExpectedSessions: point.PaidSessions, ExpectedAmountRial: point.PaidAmountRial})
|
||||
}
|
||||
coalesced := coalesceRisks(candidates)
|
||||
slices.SortStableFunc(coalesced, func(a, b Risk) int {
|
||||
aSeverity := maxInt64(a.SessionDeclineBPS, a.AmountDeclineBPS)
|
||||
bSeverity := maxInt64(b.SessionDeclineBPS, b.AmountDeclineBPS)
|
||||
if aSeverity > bSeverity {
|
||||
return -1
|
||||
}
|
||||
if aSeverity < bSeverity {
|
||||
return 1
|
||||
}
|
||||
return a.Date.Compare(b.Date)
|
||||
})
|
||||
if len(coalesced) > limit {
|
||||
coalesced = coalesced[:limit]
|
||||
}
|
||||
return coalesced
|
||||
}
|
||||
|
||||
func coalesceRisks(candidates []Risk) []Risk {
|
||||
if len(candidates) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]Risk, 0, len(candidates))
|
||||
current := candidates[0]
|
||||
for _, candidate := range candidates[1:] {
|
||||
if dayKey(candidate.Date) != dayKey(current.EndDate.AddDate(0, 0, 1)) {
|
||||
current.EvidenceID = EvidenceID("risk", dayKey(current.Date)+":"+dayKey(current.EndDate))
|
||||
result = append(result, current)
|
||||
current = candidate
|
||||
continue
|
||||
}
|
||||
current.EndDate = candidate.EndDate
|
||||
if candidate.SessionDeclineBPS > current.SessionDeclineBPS {
|
||||
current.SessionDeclineBPS = candidate.SessionDeclineBPS
|
||||
current.ExpectedSessions = candidate.ExpectedSessions
|
||||
}
|
||||
if candidate.AmountDeclineBPS > current.AmountDeclineBPS {
|
||||
current.AmountDeclineBPS = candidate.AmountDeclineBPS
|
||||
current.ExpectedAmountRial = candidate.ExpectedAmountRial
|
||||
}
|
||||
}
|
||||
current.EvidenceID = EvidenceID("risk", dayKey(current.Date)+":"+dayKey(current.EndDate))
|
||||
return append(result, current)
|
||||
}
|
||||
101
internal/growth/opportunities.go
Normal file
101
internal/growth/opportunities.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package growth
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func historicalWins(history []DailyPoint, catalog []Event, before time.Time) []EventEffect {
|
||||
wins := make([]EventEffect, 0)
|
||||
for _, event := range catalog {
|
||||
if !dateBefore(event.EndDate, before) {
|
||||
continue
|
||||
}
|
||||
effect := AnalyzeEventEffect(history, event, catalog)
|
||||
if effect.Eligible && effect.MaterialWin {
|
||||
wins = append(wins, effect)
|
||||
}
|
||||
}
|
||||
slices.SortFunc(wins, func(a, b EventEffect) int {
|
||||
aScore := maxInt64(a.PaidSessionUpliftBPS, a.AmountUpliftBPS)
|
||||
bScore := maxInt64(b.PaidSessionUpliftBPS, b.AmountUpliftBPS)
|
||||
if aScore != bScore {
|
||||
if aScore > bScore {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
return strings.Compare(a.EventID, b.EventID)
|
||||
})
|
||||
return wins
|
||||
}
|
||||
|
||||
func buildOpportunities(wins []EventEffect, catalog []Event, forecast Forecast, start time.Time, limit int) []Opportunity {
|
||||
if forecast.Confidence == ConfidenceInsufficientData || limit <= 0 {
|
||||
return nil
|
||||
}
|
||||
eventByID := make(map[string]Event, len(catalog))
|
||||
for _, event := range catalog {
|
||||
eventByID[event.ID] = event
|
||||
}
|
||||
forecastByDate := make(map[string]ForecastPoint, len(forecast.Points))
|
||||
for _, point := range forecast.Points {
|
||||
forecastByDate[dayKey(point.Date)] = point
|
||||
}
|
||||
best := make(map[string]Opportunity)
|
||||
for _, win := range wins {
|
||||
historical := eventByID[win.EventID]
|
||||
upcoming := make([]Event, 0)
|
||||
for _, event := range catalog {
|
||||
if !dateBefore(event.EndDate, start) {
|
||||
upcoming = append(upcoming, event)
|
||||
}
|
||||
}
|
||||
for _, match := range MatchUpcomingEvents(historical, upcoming) {
|
||||
point, ok := forecastByDate[dayKey(match.Event.StartDate)]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
candidate := Opportunity{
|
||||
EvidenceID: EvidenceID("opportunity", match.Event.ID+":"+win.EventID), EventID: match.Event.ID,
|
||||
EventTitleFA: match.Event.TitleFA, Date: normalizeDate(match.Event.StartDate), SimilarityBPS: match.ScoreBPS,
|
||||
ExpectedSessions: point.PaidSessions, ExpectedAmountRial: point.PaidAmountRial,
|
||||
HistoricalEvidenceID: win.EvidenceID,
|
||||
}
|
||||
if current, exists := best[match.Event.ID]; !exists || candidate.SimilarityBPS > current.SimilarityBPS {
|
||||
best[match.Event.ID] = candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
result := make([]Opportunity, 0, len(best))
|
||||
for _, opportunity := range best {
|
||||
result = append(result, opportunity)
|
||||
}
|
||||
slices.SortFunc(result, func(a, b Opportunity) int {
|
||||
if a.SimilarityBPS != b.SimilarityBPS {
|
||||
if a.SimilarityBPS > b.SimilarityBPS {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
if comparison := a.Date.Compare(b.Date); comparison != 0 {
|
||||
return comparison
|
||||
}
|
||||
return strings.Compare(a.EventID, b.EventID)
|
||||
})
|
||||
if len(result) > limit {
|
||||
result = result[:limit]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func filterCatalog(catalog []Event, category string) []Event {
|
||||
filtered := make([]Event, 0, len(catalog))
|
||||
for _, event := range catalog {
|
||||
if category == "" || event.Universal || slices.Contains(event.Categories, category) {
|
||||
filtered = append(filtered, event)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
60
internal/growth/peer.go
Normal file
60
internal/growth/peer.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package growth
|
||||
|
||||
import "slices"
|
||||
|
||||
type PeerState string
|
||||
|
||||
const (
|
||||
PeerReady PeerState = "READY"
|
||||
PeerSuppressed PeerState = "SUPPRESSED"
|
||||
)
|
||||
|
||||
type PeerMerchantValue struct {
|
||||
PaidSessions int64 `json:"paidSessions"`
|
||||
SessionCount int64 `json:"sessionCount"`
|
||||
}
|
||||
|
||||
type PeerInput struct {
|
||||
TerminalScoped bool `json:"terminalScoped"`
|
||||
MerchantPaidSessions int64 `json:"merchantPaidSessions"`
|
||||
Peers []PeerMerchantValue `json:"peers"`
|
||||
}
|
||||
|
||||
type PeerResult struct {
|
||||
State PeerState `json:"state"`
|
||||
Reason SuppressionReason `json:"reason,omitempty"`
|
||||
EligiblePeerCount int `json:"eligiblePeerCount"`
|
||||
CombinedPeerSessions int64 `json:"combinedPeerSessions"`
|
||||
MedianPaidSessions int64 `json:"medianPaidSessions"`
|
||||
PercentileBPS int64 `json:"percentileBps"`
|
||||
}
|
||||
|
||||
func EvaluatePeer(input PeerInput) PeerResult {
|
||||
result := PeerResult{State: PeerSuppressed, EligiblePeerCount: len(input.Peers)}
|
||||
if input.TerminalScoped {
|
||||
result.Reason = SuppressionTerminalScope
|
||||
return result
|
||||
}
|
||||
values := make([]int64, 0, len(input.Peers))
|
||||
for _, peer := range input.Peers {
|
||||
result.CombinedPeerSessions += peer.SessionCount
|
||||
values = append(values, peer.PaidSessions)
|
||||
}
|
||||
if len(values) < 10 || result.CombinedPeerSessions < 1_000 {
|
||||
result.Reason = SuppressionInsufficientCohort
|
||||
return result
|
||||
}
|
||||
slices.Sort(values)
|
||||
result.State = PeerReady
|
||||
result.MedianPaidSessions = median(values)
|
||||
var below, equal int64
|
||||
for _, value := range values {
|
||||
if input.MerchantPaidSessions > value {
|
||||
below++
|
||||
} else if input.MerchantPaidSessions == value {
|
||||
equal++
|
||||
}
|
||||
}
|
||||
result.PercentileBPS = (2*below + equal) * 5_000 / int64(len(values))
|
||||
return result
|
||||
}
|
||||
131
internal/growth/report.go
Normal file
131
internal/growth/report.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package growth
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
type FingerprintInput struct {
|
||||
MerchantID int64 `json:"merchantId"`
|
||||
TerminalID string `json:"terminalId,omitempty"`
|
||||
DatasetWatermark string `json:"datasetWatermark"`
|
||||
ForecastStart time.Time `json:"forecastStart"`
|
||||
HorizonDays int `json:"horizonDays"`
|
||||
CatalogVersion string `json:"catalogVersion"`
|
||||
AnalyticsVersion string `json:"analyticsVersion"`
|
||||
PromptVersion string `json:"promptVersion"`
|
||||
SchemaVersion string `json:"schemaVersion"`
|
||||
Model string `json:"model"`
|
||||
ModelConfigVersion string `json:"modelConfigVersion"`
|
||||
}
|
||||
|
||||
func Fingerprint(input FingerprintInput) string {
|
||||
payload, err := json.Marshal(struct {
|
||||
FingerprintInput
|
||||
ForecastDate string `json:"forecastDate"`
|
||||
}{FingerprintInput: FingerprintInput{
|
||||
MerchantID: input.MerchantID, TerminalID: input.TerminalID,
|
||||
DatasetWatermark: input.DatasetWatermark, HorizonDays: input.HorizonDays,
|
||||
CatalogVersion: input.CatalogVersion, AnalyticsVersion: input.AnalyticsVersion,
|
||||
PromptVersion: input.PromptVersion, SchemaVersion: input.SchemaVersion, Model: input.Model,
|
||||
ModelConfigVersion: input.ModelConfigVersion,
|
||||
}, ForecastDate: input.ForecastStart.In(tehranLocation()).Format(time.DateOnly)})
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
digest := sha256.Sum256(payload)
|
||||
return hex.EncodeToString(digest[:])
|
||||
}
|
||||
|
||||
type ReportInput struct {
|
||||
History []DailyPoint `json:"history"`
|
||||
Catalog []Event `json:"catalog"`
|
||||
ForecastStart time.Time `json:"forecastStart"`
|
||||
HorizonDays int `json:"horizonDays"`
|
||||
Category string `json:"category,omitempty"`
|
||||
Peer *PeerInput `json:"peer,omitempty"`
|
||||
}
|
||||
|
||||
type DeterministicReport struct {
|
||||
Forecast Forecast `json:"forecast"`
|
||||
HistoricalWins []EventEffect `json:"historicalWins"`
|
||||
Opportunities []Opportunity `json:"opportunities"`
|
||||
Risks []Risk `json:"risks"`
|
||||
Peer *PeerResult `json:"peer,omitempty"`
|
||||
FallbackBriefs []CampaignBrief `json:"fallbackBriefs"`
|
||||
}
|
||||
|
||||
func BuildDeterministicReport(input ReportInput) DeterministicReport {
|
||||
catalog := filterCatalog(input.Catalog, input.Category)
|
||||
forecast := BuildForecast(input.History, catalog, input.ForecastStart, input.HorizonDays)
|
||||
wins := historicalWins(input.History, catalog, input.ForecastStart)
|
||||
opportunities := buildOpportunities(wins, catalog, forecast, input.ForecastStart, 5)
|
||||
risks := DetectRisks(forecast, 3)
|
||||
bundle := EvidenceBundle{Opportunities: opportunities, Risks: risks}
|
||||
report := DeterministicReport{Forecast: forecast, HistoricalWins: wins, Opportunities: opportunities, Risks: risks, FallbackBriefs: BuildFallback(bundle)}
|
||||
if input.Peer != nil {
|
||||
peer := EvaluatePeer(*input.Peer)
|
||||
report.Peer = &peer
|
||||
}
|
||||
return report
|
||||
}
|
||||
|
||||
func TehranDay(value time.Time) string {
|
||||
return value.In(tehranLocation()).Format(time.DateOnly)
|
||||
}
|
||||
|
||||
func NextRefreshAt(lastRefresh time.Time) time.Time {
|
||||
local := lastRefresh.In(tehranLocation())
|
||||
nextLocal := time.Date(local.Year(), local.Month(), local.Day()+1, 0, 0, 0, 0, tehranLocation())
|
||||
return nextLocal.UTC()
|
||||
}
|
||||
|
||||
func CanManualRefresh(lastRefresh, now time.Time) bool {
|
||||
if lastRefresh.IsZero() {
|
||||
return true
|
||||
}
|
||||
return !now.Before(NextRefreshAt(lastRefresh))
|
||||
}
|
||||
|
||||
func BuildFallback(bundle EvidenceBundle) []CampaignBrief {
|
||||
briefs := make([]CampaignBrief, 0, len(bundle.Opportunities)+len(bundle.Risks))
|
||||
for _, opportunity := range bundle.Opportunities {
|
||||
briefs = append(briefs, CampaignBrief{
|
||||
HeadlineFA: "یک فرصت مناسب در راه است: " + opportunity.EventTitleFA,
|
||||
GuidanceFA: "دادههای قبلی شما نشان میدهد این مناسبت میتواند برای برنامهریزی فروش ارزشمند باشد.",
|
||||
OfferFA: "پیشنهاد خود را متناسب با نیاز مشتریان این مناسبت آماده کنید.",
|
||||
MessageFA: "برای این مناسبت یک پیشنهاد ویژه برای شما آماده کردهایم.",
|
||||
PreparationFA: "موجودی، پیام کمپین و مسیر پرداخت را پیش از شروع مناسبت بررسی کنید.",
|
||||
LimitationFA: "این پیشنهاد بر پایه الگوهای گذشته است و نتیجه قطعی نیست.",
|
||||
EvidenceIDs: []string{opportunity.EvidenceID},
|
||||
})
|
||||
}
|
||||
for _, risk := range bundle.Risks {
|
||||
briefs = append(briefs, CampaignBrief{
|
||||
HeadlineFA: "برای یک بازه آرامتر آماده باشید",
|
||||
GuidanceFA: "الگوی گذشته احتمال کاهش فعالیت را در این بازه نشان میدهد.",
|
||||
OfferFA: "یک پیشنهاد سبک برای حفظ ارتباط با مشتریان در نظر بگیرید.",
|
||||
MessageFA: "این روزها هم کنار شما هستیم؛ پیشنهادهای تازه را ببینید.",
|
||||
PreparationFA: "پیام یادآوری و تجربه پرداخت را پیش از این بازه بازبینی کنید.",
|
||||
LimitationFA: "این هشدار بر پایه الگوهای گذشته است و نتیجه قطعی نیست.",
|
||||
EvidenceIDs: []string{risk.EvidenceID},
|
||||
})
|
||||
}
|
||||
return briefs
|
||||
}
|
||||
|
||||
func EvidenceID(kind, stableKey string) string {
|
||||
digest := sha256.Sum256([]byte(kind + "\x00" + stableKey))
|
||||
return fmt.Sprintf("%s_%s", kind, hex.EncodeToString(digest[:8]))
|
||||
}
|
||||
|
||||
func tehranLocation() *time.Location {
|
||||
location, err := time.LoadLocation("Asia/Tehran")
|
||||
if err != nil {
|
||||
return time.FixedZone("Asia/Tehran", 3*60*60+30*60)
|
||||
}
|
||||
return location
|
||||
}
|
||||
155
internal/growth/types.go
Normal file
155
internal/growth/types.go
Normal file
@@ -0,0 +1,155 @@
|
||||
package growth
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
CatalogVersion = "1405.2"
|
||||
AnalyticsVersion = "1.1.0"
|
||||
DefaultModelConfigVersion = "avalai-responses-v1-tools4-turns2-output1600"
|
||||
|
||||
MinBaselineObservations = 4
|
||||
MinBaselineSessions = 100
|
||||
MaterialChangeBPS = 2_000
|
||||
MinSimilarityBPS = 6_000
|
||||
)
|
||||
|
||||
type Confidence string
|
||||
|
||||
const (
|
||||
ConfidenceHigh Confidence = "HIGH"
|
||||
ConfidenceMedium Confidence = "MEDIUM"
|
||||
ConfidenceInsufficientData Confidence = "INSUFFICIENT_DATA"
|
||||
)
|
||||
|
||||
type SuppressionReason string
|
||||
|
||||
const (
|
||||
SuppressionInsufficientObservations SuppressionReason = "INSUFFICIENT_BASELINE_OBSERVATIONS"
|
||||
SuppressionInsufficientBaselineSessions SuppressionReason = "INSUFFICIENT_BASELINE_SESSIONS"
|
||||
SuppressionInsufficientHistory SuppressionReason = "INSUFFICIENT_HISTORY"
|
||||
SuppressionIncompleteEventWindow SuppressionReason = "INCOMPLETE_EVENT_WINDOW"
|
||||
SuppressionInsufficientCohort SuppressionReason = "INSUFFICIENT_COHORT"
|
||||
SuppressionTerminalScope SuppressionReason = "TERMINAL_SCOPE_UNSUPPORTED"
|
||||
)
|
||||
|
||||
type DailyPoint struct {
|
||||
Date time.Time `json:"date"`
|
||||
SessionCount int64 `json:"sessionCount"`
|
||||
PaidSessionCount int64 `json:"paidSessionCount"`
|
||||
PaidAmountRial int64 `json:"paidAmountRial"`
|
||||
EventIDs []string `json:"eventIds,omitempty"`
|
||||
}
|
||||
|
||||
type EventKind string
|
||||
|
||||
const (
|
||||
EventOfficial EventKind = "OFFICIAL"
|
||||
EventCommercial EventKind = "COMMERCIAL"
|
||||
)
|
||||
|
||||
type Event struct {
|
||||
ID string `json:"id"`
|
||||
TitleFA string `json:"titleFa"`
|
||||
StartDate time.Time `json:"startDate"`
|
||||
EndDate time.Time `json:"endDate"`
|
||||
Family string `json:"family"`
|
||||
Tags []string `json:"tags"`
|
||||
Source string `json:"source"`
|
||||
SourceVersion string `json:"sourceVersion"`
|
||||
Categories []string `json:"categories"`
|
||||
Kind EventKind `json:"kind"`
|
||||
OfficialHoliday bool `json:"officialHoliday"`
|
||||
Universal bool `json:"universal"`
|
||||
}
|
||||
|
||||
type EventEffect struct {
|
||||
EvidenceID string `json:"evidenceId"`
|
||||
EventID string `json:"eventId"`
|
||||
Eligible bool `json:"eligible"`
|
||||
SuppressionReason SuppressionReason `json:"suppressionReason,omitempty"`
|
||||
ObservationCount int `json:"observationCount"`
|
||||
BaselineSessionCount int64 `json:"baselineSessionCount"`
|
||||
BaselinePaidSessions int64 `json:"baselinePaidSessions"`
|
||||
BaselineAmountRial int64 `json:"baselineAmountRial"`
|
||||
ActualSessionCount int64 `json:"actualSessionCount"`
|
||||
ActualPaidSessions int64 `json:"actualPaidSessions"`
|
||||
ActualAmountRial int64 `json:"actualAmountRial"`
|
||||
SessionUpliftBPS int64 `json:"sessionUpliftBps"`
|
||||
PaidSessionUpliftBPS int64 `json:"paidSessionUpliftBps"`
|
||||
AmountUpliftBPS int64 `json:"amountUpliftBps"`
|
||||
MaterialWin bool `json:"materialWin"`
|
||||
MaterialDecline bool `json:"materialDecline"`
|
||||
}
|
||||
|
||||
type EventMatch struct {
|
||||
Event Event `json:"event"`
|
||||
ScoreBPS int64 `json:"scoreBps"`
|
||||
MatchBasis string `json:"matchBasis"`
|
||||
}
|
||||
|
||||
type Range struct {
|
||||
P10 int64 `json:"p10"`
|
||||
P50 int64 `json:"p50"`
|
||||
P90 int64 `json:"p90"`
|
||||
}
|
||||
|
||||
type ForecastPoint struct {
|
||||
Date time.Time `json:"date"`
|
||||
BaselineSessions int64 `json:"baselineSessions"`
|
||||
BaselineAmountRial int64 `json:"baselineAmountRial"`
|
||||
PaidSessions Range `json:"paidSessions"`
|
||||
PaidAmountRial Range `json:"paidAmountRial"`
|
||||
EventIDs []string `json:"eventIds,omitempty"`
|
||||
}
|
||||
|
||||
type BacktestResult struct {
|
||||
Origins int `json:"origins"`
|
||||
SessionMAPEBPS int64 `json:"sessionMapeBps"`
|
||||
AmountMAPEBPS int64 `json:"amountMapeBps"`
|
||||
IntervalCoverageBPS int64 `json:"intervalCoverageBps"`
|
||||
}
|
||||
|
||||
type Forecast struct {
|
||||
StartDate time.Time `json:"startDate"`
|
||||
HorizonDays int `json:"horizonDays"`
|
||||
Confidence Confidence `json:"confidence"`
|
||||
SuppressionReason SuppressionReason `json:"suppressionReason,omitempty"`
|
||||
Points []ForecastPoint `json:"points"`
|
||||
Backtest BacktestResult `json:"backtest"`
|
||||
}
|
||||
|
||||
type Opportunity struct {
|
||||
EvidenceID string `json:"evidenceId"`
|
||||
EventID string `json:"eventId"`
|
||||
EventTitleFA string `json:"eventTitleFa"`
|
||||
Date time.Time `json:"date"`
|
||||
SimilarityBPS int64 `json:"similarityBps"`
|
||||
ExpectedSessions Range `json:"expectedSessions"`
|
||||
ExpectedAmountRial Range `json:"expectedAmountRial"`
|
||||
HistoricalEvidenceID string `json:"historicalEvidenceId"`
|
||||
}
|
||||
|
||||
type Risk struct {
|
||||
EvidenceID string `json:"evidenceId"`
|
||||
Date time.Time `json:"date"`
|
||||
EndDate time.Time `json:"endDate"`
|
||||
SessionDeclineBPS int64 `json:"sessionDeclineBps"`
|
||||
AmountDeclineBPS int64 `json:"amountDeclineBps"`
|
||||
ExpectedSessions Range `json:"expectedSessions"`
|
||||
ExpectedAmountRial Range `json:"expectedAmountRial"`
|
||||
}
|
||||
|
||||
type EvidenceBundle struct {
|
||||
Opportunities []Opportunity `json:"opportunities"`
|
||||
Risks []Risk `json:"risks"`
|
||||
}
|
||||
|
||||
type CampaignBrief struct {
|
||||
HeadlineFA string `json:"headlineFa"`
|
||||
GuidanceFA string `json:"guidanceFa"`
|
||||
OfferFA string `json:"offerFa"`
|
||||
MessageFA string `json:"messageFa"`
|
||||
PreparationFA string `json:"preparationFa"`
|
||||
LimitationFA string `json:"limitationFa"`
|
||||
EvidenceIDs []string `json:"evidenceIds"`
|
||||
}
|
||||
219
internal/growthreport/adapter.go
Normal file
219
internal/growthreport/adapter.go
Normal file
@@ -0,0 +1,219 @@
|
||||
package growthreport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/growth"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type HistoricalWin struct {
|
||||
growth.EventEffect
|
||||
EventTitleFA string `json:"eventTitleFa"`
|
||||
StartDate time.Time `json:"startDate"`
|
||||
EndDate time.Time `json:"endDate"`
|
||||
}
|
||||
|
||||
type PeerEvidence struct {
|
||||
State growth.PeerState `json:"state"`
|
||||
Reason growth.SuppressionReason `json:"reason,omitempty"`
|
||||
EligiblePeerCount int `json:"eligiblePeerCount"`
|
||||
CombinedPeerSessionCount int64 `json:"combinedPeerSessionCount"`
|
||||
MedianPaidSessions int64 `json:"medianPaidSessions"`
|
||||
PercentileBPS int64 `json:"percentileBps"`
|
||||
}
|
||||
|
||||
type Evidence struct {
|
||||
Forecast growth.Forecast `json:"forecast"`
|
||||
HistoricalWins []HistoricalWin `json:"historicalWins"`
|
||||
Opportunities []growth.Opportunity `json:"opportunities"`
|
||||
Risks []growth.Risk `json:"risks"`
|
||||
Peer *PeerEvidence `json:"peer,omitempty"`
|
||||
FallbackBriefs []growth.CampaignBrief `json:"fallbackBriefs"`
|
||||
}
|
||||
|
||||
type Analysis struct {
|
||||
Evidence Evidence
|
||||
Encoded json.RawMessage
|
||||
KnownEvidence map[string]struct{}
|
||||
Fallback AICopy
|
||||
ShouldCallAI bool
|
||||
}
|
||||
|
||||
type Builder interface {
|
||||
Build(ctx context.Context, merchantID int64, terminalID string) (Analysis, error)
|
||||
}
|
||||
|
||||
type PostgreSQLBuilder struct{ pool *pgxpool.Pool }
|
||||
|
||||
func NewPostgreSQLBuilder(pool *pgxpool.Pool) *PostgreSQLBuilder {
|
||||
return &PostgreSQLBuilder{pool: pool}
|
||||
}
|
||||
|
||||
func (b *PostgreSQLBuilder) Build(ctx context.Context, merchantID int64, terminalID string) (Analysis, error) {
|
||||
catalog := growth.Catalog1405()
|
||||
var category string
|
||||
if err := b.pool.QueryRow(ctx, `SELECT category_title FROM merchants WHERE id=$1`, merchantID).Scan(&category); err != nil {
|
||||
return Analysis{}, fmt.Errorf("loading merchant category: %w", err)
|
||||
}
|
||||
rows, err := b.pool.Query(ctx, `WITH bounds AS (SELECT min(created_at AT TIME ZONE 'Asia/Tehran')::date first_day,max(created_at AT TIME ZONE 'Asia/Tehran')::date last_day FROM payment_sessions),
|
||||
days AS (SELECT generate_series(first_day,last_day,interval '1 day')::date local_date FROM bounds), scoped AS (
|
||||
SELECT (ps.created_at AT TIME ZONE 'Asia/Tehran')::date local_date,count(*) sessions,count(*) FILTER(WHERE ps.status IN ('Paid','Verified')) paid_sessions,coalesce(sum(ps.amount_rial) FILTER(WHERE ps.status IN ('Paid','Verified')),0) paid_amount
|
||||
FROM payment_sessions ps WHERE ps.merchant_id=$1 AND ($2='' OR ps.terminal_id=(SELECT id FROM terminals WHERE merchant_id=$1 AND source_key=$2)) GROUP BY 1)
|
||||
SELECT d.local_date,coalesce(s.sessions,0),coalesce(s.paid_sessions,0),coalesce(s.paid_amount,0) FROM days d LEFT JOIN scoped s USING(local_date) ORDER BY d.local_date`, merchantID, terminalID)
|
||||
if err != nil {
|
||||
return Analysis{}, fmt.Errorf("loading growth history: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
history := make([]growth.DailyPoint, 0, 366)
|
||||
for rows.Next() {
|
||||
var point growth.DailyPoint
|
||||
if err := rows.Scan(&point.Date, &point.SessionCount, &point.PaidSessionCount, &point.PaidAmountRial); err != nil {
|
||||
return Analysis{}, fmt.Errorf("scanning growth history: %w", err)
|
||||
}
|
||||
for _, event := range catalog {
|
||||
if !point.Date.Before(event.StartDate) && !point.Date.After(event.EndDate) {
|
||||
point.EventIDs = append(point.EventIDs, event.ID)
|
||||
}
|
||||
}
|
||||
history = append(history, point)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return Analysis{}, fmt.Errorf("iterating growth history: %w", err)
|
||||
}
|
||||
var latest time.Time
|
||||
if err := b.pool.QueryRow(ctx, `SELECT max(created_at AT TIME ZONE 'Asia/Tehran')::date FROM payment_sessions`).Scan(&latest); err != nil {
|
||||
return Analysis{}, fmt.Errorf("loading dataset watermark date: %w", err)
|
||||
}
|
||||
forecastStart := latest.AddDate(0, 0, 1)
|
||||
peer, err := b.loadPeer(ctx, merchantID, terminalID)
|
||||
if err != nil {
|
||||
return Analysis{}, err
|
||||
}
|
||||
deterministic := growth.BuildDeterministicReport(growth.ReportInput{History: history, Catalog: catalog, ForecastStart: forecastStart, HorizonDays: 90, Category: category, Peer: &peer})
|
||||
events := make(map[string]growth.Event, len(catalog))
|
||||
for _, event := range catalog {
|
||||
events[event.ID] = event
|
||||
}
|
||||
evidence := Evidence{Forecast: deterministic.Forecast, Opportunities: deterministic.Opportunities, Risks: deterministic.Risks, FallbackBriefs: deterministic.FallbackBriefs}
|
||||
for _, effect := range deterministic.HistoricalWins {
|
||||
event := events[effect.EventID]
|
||||
evidence.HistoricalWins = append(evidence.HistoricalWins, HistoricalWin{EventEffect: effect, EventTitleFA: event.TitleFA, StartDate: event.StartDate, EndDate: event.EndDate})
|
||||
}
|
||||
if deterministic.Peer != nil {
|
||||
evidence.Peer = &PeerEvidence{State: deterministic.Peer.State, Reason: deterministic.Peer.Reason, EligiblePeerCount: deterministic.Peer.EligiblePeerCount, CombinedPeerSessionCount: deterministic.Peer.CombinedPeerSessions, MedianPaidSessions: deterministic.Peer.MedianPaidSessions, PercentileBPS: deterministic.Peer.PercentileBPS}
|
||||
}
|
||||
encoded, err := json.Marshal(evidence)
|
||||
if err != nil {
|
||||
return Analysis{}, fmt.Errorf("encoding deterministic growth evidence: %w", err)
|
||||
}
|
||||
known := map[string]struct{}{}
|
||||
for _, win := range evidence.HistoricalWins {
|
||||
known[win.EvidenceID] = struct{}{}
|
||||
}
|
||||
for _, opportunity := range evidence.Opportunities {
|
||||
known[opportunity.EvidenceID] = struct{}{}
|
||||
}
|
||||
for _, risk := range evidence.Risks {
|
||||
known[risk.EvidenceID] = struct{}{}
|
||||
}
|
||||
return Analysis{Evidence: evidence, Encoded: encoded, KnownEvidence: known, Fallback: convertFallback(deterministic.FallbackBriefs), ShouldCallAI: deterministic.Forecast.Confidence != growth.ConfidenceInsufficientData && (len(deterministic.Opportunities) > 0 || len(deterministic.Risks) > 0)}, nil
|
||||
}
|
||||
|
||||
func (b *PostgreSQLBuilder) loadPeer(ctx context.Context, merchantID int64, terminalID string) (growth.PeerInput, error) {
|
||||
if terminalID != "" {
|
||||
return growth.PeerInput{TerminalScoped: true}, nil
|
||||
}
|
||||
var own int64
|
||||
if err := b.pool.QueryRow(ctx, `SELECT count(*) FROM payment_sessions WHERE merchant_id=$1 AND status IN ('Paid','Verified')`, merchantID).Scan(&own); err != nil {
|
||||
return growth.PeerInput{}, fmt.Errorf("loading merchant peer value: %w", err)
|
||||
}
|
||||
rows, err := b.pool.Query(ctx, `SELECT ps.merchant_id,count(*) FILTER(WHERE ps.status IN ('Paid','Verified')),count(*) FROM payment_sessions ps JOIN merchants m ON m.id=ps.merchant_id WHERE m.category_id=(SELECT category_id FROM merchants WHERE id=$1) AND ps.merchant_id<>$1 GROUP BY ps.merchant_id`, merchantID)
|
||||
if err != nil {
|
||||
return growth.PeerInput{}, fmt.Errorf("loading peer values: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
input := growth.PeerInput{MerchantPaidSessions: own}
|
||||
for rows.Next() {
|
||||
var peer growth.PeerMerchantValue
|
||||
var id int64
|
||||
if err := rows.Scan(&id, &peer.PaidSessions, &peer.SessionCount); err != nil {
|
||||
return growth.PeerInput{}, fmt.Errorf("scanning peer value: %w", err)
|
||||
}
|
||||
input.Peers = append(input.Peers, peer)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return growth.PeerInput{}, fmt.Errorf("iterating peer values: %w", err)
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
|
||||
func convertFallback(items []growth.CampaignBrief) AICopy {
|
||||
result := AICopy{Briefs: make([]CampaignBrief, 0, len(items))}
|
||||
for _, item := range items {
|
||||
result.Briefs = append(result.Briefs, CampaignBrief{Headline: item.HeadlineFA, Interpretation: item.GuidanceFA, OfferSuggestion: item.OfferFA, MessageSuggestion: item.MessageFA, PreparationAction: item.PreparationFA, Limitation: item.LimitationFA, EvidenceIDs: item.EvidenceIDs})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
type EvidenceTools struct{ analysis Analysis }
|
||||
|
||||
func NewEvidenceTools(analysis Analysis) EvidenceTools { return EvidenceTools{analysis: analysis} }
|
||||
func (t EvidenceTools) Execute(_ context.Context, name string, arguments json.RawMessage) (json.RawMessage, error) {
|
||||
var args struct {
|
||||
EvidenceID string `json:"evidenceId"`
|
||||
}
|
||||
if err := json.Unmarshal(arguments, &args); err != nil {
|
||||
return nil, fmt.Errorf("invalid tool arguments: %w", err)
|
||||
}
|
||||
if len(args.EvidenceID) > 128 {
|
||||
return nil, fmt.Errorf("growth tool: invalid evidence id")
|
||||
}
|
||||
switch name {
|
||||
case "get_growth_context":
|
||||
return json.Marshal(map[string]any{"confidence": t.analysis.Evidence.Forecast.Confidence, "opportunityEvidenceIds": evidenceIDs(t.analysis.Evidence.Opportunities), "riskEvidenceIds": riskEvidenceIDs(t.analysis.Evidence.Risks)})
|
||||
case "get_peer_context":
|
||||
if t.analysis.Evidence.Peer == nil {
|
||||
return []byte(`{"state":"SUPPRESSED"}`), nil
|
||||
}
|
||||
return json.Marshal(t.analysis.Evidence.Peer)
|
||||
case "get_event_evidence":
|
||||
for _, item := range t.analysis.Evidence.HistoricalWins {
|
||||
if item.EvidenceID == args.EvidenceID {
|
||||
return json.Marshal(item)
|
||||
}
|
||||
}
|
||||
for _, item := range t.analysis.Evidence.Opportunities {
|
||||
if item.EvidenceID == args.EvidenceID {
|
||||
return json.Marshal(item)
|
||||
}
|
||||
}
|
||||
case "get_campaign_playbook":
|
||||
for _, item := range t.analysis.Fallback.Briefs {
|
||||
for _, id := range item.EvidenceIDs {
|
||||
if id == args.EvidenceID {
|
||||
return json.Marshal(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, errorsNewUnknownEvidence()
|
||||
}
|
||||
func evidenceIDs(items []growth.Opportunity) []string {
|
||||
result := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
result = append(result, item.EvidenceID)
|
||||
}
|
||||
return result
|
||||
}
|
||||
func riskEvidenceIDs(items []growth.Risk) []string {
|
||||
result := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
result = append(result, item.EvidenceID)
|
||||
}
|
||||
return result
|
||||
}
|
||||
func errorsNewUnknownEvidence() error { return fmt.Errorf("growth tool: unknown evidence") }
|
||||
372
internal/growthreport/avalai.go
Normal file
372
internal/growthreport/avalai.go
Normal file
@@ -0,0 +1,372 @@
|
||||
package growthreport
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
const maxAvalAIResponseBytes = 1 << 20
|
||||
|
||||
type AvalAIConfig struct {
|
||||
BaseURL string
|
||||
APIKey string
|
||||
Model string
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
type AvalAIClient struct {
|
||||
config AvalAIConfig
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
type GrowthAnalyticsTools interface {
|
||||
Execute(ctx context.Context, name string, arguments json.RawMessage) (json.RawMessage, error)
|
||||
}
|
||||
|
||||
func NewAvalAIClient(config AvalAIConfig, client *http.Client) *AvalAIClient {
|
||||
if config.BaseURL == "" {
|
||||
config.BaseURL = "https://api.avalai.ir"
|
||||
}
|
||||
if config.Timeout <= 0 || config.Timeout > 30*time.Second {
|
||||
config.Timeout = 30 * time.Second
|
||||
}
|
||||
if client == nil {
|
||||
client = &http.Client{}
|
||||
}
|
||||
return &AvalAIClient{config: config, client: client}
|
||||
}
|
||||
|
||||
func (c *AvalAIClient) Generate(ctx context.Context, evidence json.RawMessage, known map[string]struct{}) (AICopy, Usage, error) {
|
||||
return c.GenerateWithTools(ctx, evidence, known, nil)
|
||||
}
|
||||
|
||||
func (c *AvalAIClient) GenerateWithTools(ctx context.Context, evidence json.RawMessage, known map[string]struct{}, executor GrowthAnalyticsTools) (AICopy, Usage, error) {
|
||||
if c.config.APIKey == "" || c.config.Model == "" {
|
||||
return AICopy{}, Usage{}, errors.New("avalai: disabled")
|
||||
}
|
||||
base := map[string]any{
|
||||
"model": c.config.Model, "store": false, "parallel_tool_calls": false,
|
||||
"max_output_tokens": 1600, "max_tool_calls": 4,
|
||||
"instructions": "شما تحلیلگر رشد فارسی هستید. فقط بر اساس شواهد ارائهشده پیشنهاد کیفی بدهید. هیچ عدد، تاریخ یا ادعای تازهای ننویسید. شناسه شواهد را دقیق ارجاع دهید.",
|
||||
"tools": growthTools(),
|
||||
"text": map[string]any{"format": map[string]any{"type": "json_schema", "name": "growth_copy", "strict": true, "schema": aiCopySchema()}},
|
||||
}
|
||||
var last error
|
||||
var total Usage
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
copy, usage, transient, err := c.workflow(ctx, base, evidence, known, executor)
|
||||
total = mergeUsage(total, usage)
|
||||
if err == nil {
|
||||
return copy, total, nil
|
||||
}
|
||||
last = err
|
||||
if !transient || ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
return AICopy{}, total, last
|
||||
}
|
||||
|
||||
type responseEnvelope struct {
|
||||
Status string `json:"status"`
|
||||
Output []json.RawMessage `json:"output"`
|
||||
Usage struct {
|
||||
InputTokens int64 `json:"input_tokens"`
|
||||
OutputTokens int64 `json:"output_tokens"`
|
||||
} `json:"usage"`
|
||||
EstimatedCost json.RawMessage `json:"estimated_cost"`
|
||||
ProviderRequestID string `json:"-"`
|
||||
}
|
||||
|
||||
type outputItem struct {
|
||||
Type string `json:"type"`
|
||||
CallID string `json:"call_id"`
|
||||
Name string `json:"name"`
|
||||
Arguments json.RawMessage `json:"arguments"`
|
||||
Content []struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
Refusal string `json:"refusal"`
|
||||
} `json:"content"`
|
||||
}
|
||||
|
||||
func (c *AvalAIClient) workflow(ctx context.Context, base map[string]any, evidence json.RawMessage, known map[string]struct{}, executor GrowthAnalyticsTools) (AICopy, Usage, bool, error) {
|
||||
firstBody := cloneRequest(base)
|
||||
firstBody["input"] = string(evidence)
|
||||
first, usage, transient, err := c.do(ctx, firstBody)
|
||||
if err != nil {
|
||||
return AICopy{}, usage, transient, err
|
||||
}
|
||||
calls := make([]outputItem, 0, 4)
|
||||
for _, raw := range first.Output {
|
||||
var item outputItem
|
||||
if json.Unmarshal(raw, &item) == nil && item.Type == "function_call" {
|
||||
calls = append(calls, item)
|
||||
}
|
||||
}
|
||||
final := first
|
||||
if len(calls) > 0 {
|
||||
if executor == nil {
|
||||
return AICopy{}, usage, false, errors.New("avalai: unexpected tool call")
|
||||
}
|
||||
if len(calls) > 4 {
|
||||
return AICopy{}, usage, false, errors.New("avalai: tool call limit exceeded")
|
||||
}
|
||||
input := make([]any, 0, len(first.Output)+len(calls)+1)
|
||||
input = append(input, map[string]any{"role": "user", "content": string(evidence)})
|
||||
for _, raw := range first.Output {
|
||||
var value any
|
||||
if json.Unmarshal(raw, &value) == nil {
|
||||
input = append(input, value)
|
||||
}
|
||||
}
|
||||
for _, call := range calls {
|
||||
if !allowedTool(call.Name) {
|
||||
return AICopy{}, usage, false, errors.New("avalai: unknown tool")
|
||||
}
|
||||
result, err := executor.Execute(ctx, call.Name, call.Arguments)
|
||||
if err != nil {
|
||||
return AICopy{}, usage, false, fmt.Errorf("executing growth tool: %w", err)
|
||||
}
|
||||
input = append(input, map[string]any{"type": "function_call_output", "call_id": call.CallID, "output": string(result)})
|
||||
}
|
||||
secondBody := cloneRequest(base)
|
||||
secondBody["input"] = input
|
||||
second, secondUsage, secondTransient, err := c.do(ctx, secondBody)
|
||||
usage = mergeUsage(usage, secondUsage)
|
||||
if err != nil {
|
||||
return AICopy{}, usage, secondTransient, err
|
||||
}
|
||||
final = second
|
||||
for _, raw := range second.Output {
|
||||
var item outputItem
|
||||
if json.Unmarshal(raw, &item) == nil && item.Type == "function_call" {
|
||||
return AICopy{}, usage, false, errors.New("avalai: model exceeded two turns")
|
||||
}
|
||||
}
|
||||
}
|
||||
copy, err := parseAICopy(final, known)
|
||||
if err != nil {
|
||||
return AICopy{}, usage, false, err
|
||||
}
|
||||
return copy, usage, false, nil
|
||||
}
|
||||
|
||||
func (c *AvalAIClient) do(parent context.Context, bodyValue map[string]any) (responseEnvelope, Usage, bool, error) {
|
||||
body, err := json.Marshal(bodyValue)
|
||||
if err != nil {
|
||||
return responseEnvelope{}, Usage{}, false, fmt.Errorf("encoding AvalAI request: %w", err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(parent, c.config.Timeout)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(c.config.BaseURL, "/")+"/v1/responses", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return responseEnvelope{}, Usage{}, false, fmt.Errorf("creating AvalAI request: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.config.APIKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
started := time.Now()
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return responseEnvelope{}, Usage{ProviderRequests: 1, LatencyMS: time.Since(started).Milliseconds()}, true, fmt.Errorf("calling AvalAI: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
responseBody, err := io.ReadAll(io.LimitReader(resp.Body, maxAvalAIResponseBytes+1))
|
||||
if err != nil {
|
||||
return responseEnvelope{}, Usage{ProviderRequests: 1, LatencyMS: time.Since(started).Milliseconds(), ProviderRequestID: resp.Header.Get("X-Request-ID")}, true, fmt.Errorf("reading AvalAI response: %w", err)
|
||||
}
|
||||
if len(responseBody) > maxAvalAIResponseBytes {
|
||||
return responseEnvelope{}, Usage{ProviderRequests: 1, LatencyMS: time.Since(started).Milliseconds(), ProviderRequestID: resp.Header.Get("X-Request-ID")}, false, errors.New("avalai: response too large")
|
||||
}
|
||||
var result responseEnvelope
|
||||
decodeErr := json.Unmarshal(responseBody, &result)
|
||||
result.ProviderRequestID = resp.Header.Get("X-Request-ID")
|
||||
usage := Usage{InputTokens: result.Usage.InputTokens, OutputTokens: result.Usage.OutputTokens, LatencyMS: time.Since(started).Milliseconds(), ProviderRequests: 1, ProviderRequestID: result.ProviderRequestID, EstimatedCost: costString(result.EstimatedCost)}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return result, usage, resp.StatusCode == 429 || resp.StatusCode >= 500, fmt.Errorf("avalai: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
if decodeErr != nil {
|
||||
return responseEnvelope{}, usage, false, fmt.Errorf("decoding AvalAI response: %w", decodeErr)
|
||||
}
|
||||
if result.Status != "completed" {
|
||||
return result, usage, false, fmt.Errorf("avalai: incomplete response status %q", result.Status)
|
||||
}
|
||||
return result, usage, false, nil
|
||||
}
|
||||
|
||||
func mergeUsage(total, next Usage) Usage {
|
||||
total.InputTokens += next.InputTokens
|
||||
total.OutputTokens += next.OutputTokens
|
||||
total.LatencyMS += next.LatencyMS
|
||||
total.ProviderRequests += next.ProviderRequests
|
||||
total.ReservedTokens += next.ReservedTokens
|
||||
if next.ProviderRequestID != "" {
|
||||
total.ProviderRequestID = next.ProviderRequestID
|
||||
}
|
||||
total.EstimatedCost = addDecimalStrings(total.EstimatedCost, next.EstimatedCost)
|
||||
return total
|
||||
}
|
||||
|
||||
func addDecimalStrings(left, right string) string {
|
||||
if left == "" {
|
||||
return right
|
||||
}
|
||||
if right == "" {
|
||||
return left
|
||||
}
|
||||
a, okA := new(big.Rat).SetString(left)
|
||||
b, okB := new(big.Rat).SetString(right)
|
||||
if !okA || !okB {
|
||||
return left
|
||||
}
|
||||
text := new(big.Rat).Add(a, b).FloatString(12)
|
||||
text = strings.TrimRight(strings.TrimRight(text, "0"), ".")
|
||||
return text
|
||||
}
|
||||
|
||||
func costString(raw json.RawMessage) string {
|
||||
if len(raw) == 0 {
|
||||
return ""
|
||||
}
|
||||
var text string
|
||||
if json.Unmarshal(raw, &text) == nil {
|
||||
if len(text) <= 64 {
|
||||
return text
|
||||
}
|
||||
return ""
|
||||
}
|
||||
text = strings.TrimSpace(string(raw))
|
||||
if len(text) > 64 {
|
||||
return ""
|
||||
}
|
||||
for _, r := range text {
|
||||
if !(unicode.IsDigit(r) || r == '.' || r == '-' || r == 'e' || r == 'E') {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
func parseAICopy(result responseEnvelope, known map[string]struct{}) (AICopy, error) {
|
||||
var text string
|
||||
for _, raw := range result.Output {
|
||||
var item outputItem
|
||||
if json.Unmarshal(raw, &item) == nil && item.Type == "message" {
|
||||
for _, content := range item.Content {
|
||||
if content.Refusal != "" {
|
||||
return AICopy{}, errors.New("avalai: refused")
|
||||
}
|
||||
if content.Type == "output_text" {
|
||||
text += content.Text
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var copy AICopy
|
||||
if text == "" || json.Unmarshal([]byte(text), ©) != nil {
|
||||
return AICopy{}, errors.New("avalai: invalid structured output")
|
||||
}
|
||||
if err := ValidateAICopy(copy, known); err != nil {
|
||||
return AICopy{}, err
|
||||
}
|
||||
return copy, nil
|
||||
}
|
||||
|
||||
func cloneRequest(base map[string]any) map[string]any {
|
||||
result := make(map[string]any, len(base)+1)
|
||||
for key, value := range base {
|
||||
result[key] = value
|
||||
}
|
||||
return result
|
||||
}
|
||||
func allowedTool(name string) bool {
|
||||
switch name {
|
||||
case "get_growth_context", "get_event_evidence", "get_peer_context", "get_campaign_playbook":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func ValidateAICopy(copy AICopy, known map[string]struct{}) error {
|
||||
if len(copy.Briefs) == 0 || len(copy.Briefs) > 5 {
|
||||
return errors.New("avalai: invalid brief count")
|
||||
}
|
||||
for _, brief := range copy.Briefs {
|
||||
fields := []string{brief.Headline, brief.Interpretation, brief.OfferSuggestion, brief.MessageSuggestion, brief.PreparationAction, brief.Limitation}
|
||||
for _, field := range fields {
|
||||
if len([]rune(field)) > 600 {
|
||||
return errors.New("avalai: field too long")
|
||||
}
|
||||
for _, r := range field {
|
||||
if unicode.IsDigit(r) {
|
||||
return errors.New("avalai: numeric claim rejected")
|
||||
}
|
||||
}
|
||||
if containsQuantityWord(field) {
|
||||
return errors.New("avalai: written numeric claim rejected")
|
||||
}
|
||||
lower := strings.ToLower(field)
|
||||
for _, forbidden := range []string{"payer", "card", "رمز", "شماره کارت", "sql", "merchant_id"} {
|
||||
if strings.Contains(lower, forbidden) {
|
||||
return errors.New("avalai: unsafe content rejected")
|
||||
}
|
||||
}
|
||||
}
|
||||
if brief.Headline == "" || len(brief.EvidenceIDs) == 0 {
|
||||
return errors.New("avalai: ungrounded brief")
|
||||
}
|
||||
for _, id := range brief.EvidenceIDs {
|
||||
if _, ok := known[id]; !ok {
|
||||
return errors.New("avalai: unknown evidence reference")
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func containsQuantityWord(value string) bool {
|
||||
quantityWords := map[string]struct{}{
|
||||
"zero": {}, "one": {}, "two": {}, "three": {}, "four": {}, "five": {}, "six": {}, "seven": {}, "eight": {}, "nine": {}, "ten": {}, "eleven": {}, "twelve": {}, "thirteen": {}, "fourteen": {}, "fifteen": {}, "sixteen": {}, "seventeen": {}, "eighteen": {}, "nineteen": {}, "twenty": {}, "thirty": {}, "forty": {}, "fifty": {}, "sixty": {}, "seventy": {}, "eighty": {}, "ninety": {}, "hundred": {}, "thousand": {}, "million": {}, "billion": {}, "percent": {}, "percentage": {}, "double": {}, "triple": {}, "half": {},
|
||||
"صفر": {}, "یک": {}, "دو": {}, "سه": {}, "چهار": {}, "پنج": {}, "شش": {}, "هفت": {}, "هشت": {}, "نه": {}, "ده": {}, "یازده": {}, "دوازده": {}, "سیزده": {}, "چهارده": {}, "پانزده": {}, "شانزده": {}, "هفده": {}, "هجده": {}, "نوزده": {}, "بیست": {}, "سی": {}, "چهل": {}, "پنجاه": {}, "شصت": {}, "هفتاد": {}, "هشتاد": {}, "نود": {}, "صد": {}, "هزار": {}, "میلیون": {}, "میلیارد": {}, "درصد": {}, "برابر": {}, "نصف": {},
|
||||
}
|
||||
for _, word := range strings.Fields(strings.ToLower(value)) {
|
||||
word = strings.Trim(word, ".,;:!?،؛؟٪%()[]{}«»\"'")
|
||||
if _, found := quantityWords[word]; found {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func growthTools() []map[string]any {
|
||||
names := []string{"get_growth_context", "get_event_evidence", "get_peer_context", "get_campaign_playbook"}
|
||||
tools := make([]map[string]any, 0, len(names))
|
||||
for _, name := range names {
|
||||
properties := map[string]any{}
|
||||
required := []string{}
|
||||
if name == "get_event_evidence" || name == "get_campaign_playbook" {
|
||||
properties["evidenceId"] = map[string]any{"type": "string", "minLength": 1, "maxLength": 128}
|
||||
required = []string{"evidenceId"}
|
||||
}
|
||||
tools = append(tools, map[string]any{"type": "function", "name": name, "description": "Return approved sanitized deterministic evidence for this authenticated report scope.", "strict": true, "parameters": map[string]any{"type": "object", "properties": properties, "required": required, "additionalProperties": false}})
|
||||
}
|
||||
return tools
|
||||
}
|
||||
|
||||
func aiCopySchema() map[string]any {
|
||||
briefProperties := map[string]any{}
|
||||
for _, name := range []string{"headline", "interpretation", "offerSuggestion", "messageSuggestion", "preparationAction", "limitation"} {
|
||||
briefProperties[name] = map[string]any{"type": "string"}
|
||||
}
|
||||
briefProperties["evidenceIds"] = map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "minItems": 1}
|
||||
return map[string]any{"type": "object", "properties": map[string]any{"briefs": map[string]any{"type": "array", "maxItems": 5, "items": map[string]any{"type": "object", "properties": briefProperties, "required": []string{"headline", "interpretation", "offerSuggestion", "messageSuggestion", "preparationAction", "limitation", "evidenceIds"}, "additionalProperties": false}}}, "required": []string{"briefs"}, "additionalProperties": false}
|
||||
}
|
||||
131
internal/growthreport/avalai_test.go
Normal file
131
internal/growthreport/avalai_test.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package growthreport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAvalAICountsFailedRetryRequestsAndUsage(t *testing.T) {
|
||||
t.Parallel()
|
||||
requests := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
requests++
|
||||
w.Header().Set("X-Request-ID", fmt.Sprintf("request-%d", requests))
|
||||
if requests == 1 {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
_, _ = w.Write([]byte(`{"usage":{"input_tokens":7,"output_tokens":3},"estimated_cost":"0.01"}`))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"status":"completed","output":[{"type":"message","content":[{"type":"output_text","text":"{\"briefs\":[{\"headline\":\"فرصت مناسب\",\"interpretation\":\"همراستا با شواهد است.\",\"offerSuggestion\":\"پیشنهاد آماده کنید.\",\"messageSuggestion\":\"پیام روشن ارسال کنید.\",\"preparationAction\":\"مسیر پرداخت را بررسی کنید.\",\"limitation\":\"نتیجه قطعی نیست.\",\"evidenceIds\":[\"event_teacher\"]}]}"}]}],"usage":{"input_tokens":11,"output_tokens":5},"estimated_cost":"0.02"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewAvalAIClient(AvalAIConfig{BaseURL: server.URL, APIKey: "test", Model: "test", Timeout: time.Second}, server.Client())
|
||||
_, usage, err := client.Generate(context.Background(), json.RawMessage(`{"evidence":"event_teacher"}`), map[string]struct{}{"event_teacher": {}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if requests != 2 || usage.ProviderRequests != 2 || usage.InputTokens != 18 || usage.OutputTokens != 8 || usage.EstimatedCost != "0.03" {
|
||||
t.Fatalf("requests=%d usage=%+v", requests, usage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAICopyRejectsNumbersAndUnknownEvidence(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
copy AICopy
|
||||
want bool
|
||||
}{
|
||||
{name: "grounded Persian copy", copy: AICopy{Briefs: []CampaignBrief{{Headline: "فرصت مناسبی پیش رو دارید", Interpretation: "این مناسبت با الگوی موفق گذشته همراستا است.", EvidenceIDs: []string{"event_teacher"}}}}, want: true},
|
||||
{name: "numeric claim", copy: AICopy{Briefs: []CampaignBrief{{Headline: "رشد ۲۰ درصدی", EvidenceIDs: []string{"event_teacher"}}}}, want: false},
|
||||
{name: "Persian number-word claim", copy: AICopy{Briefs: []CampaignBrief{{Headline: "رشد بیست درصدی", EvidenceIDs: []string{"event_teacher"}}}}, want: false},
|
||||
{name: "English number-word claim", copy: AICopy{Briefs: []CampaignBrief{{Headline: "growth by twenty percent", EvidenceIDs: []string{"event_teacher"}}}}, want: false},
|
||||
{name: "unknown evidence", copy: AICopy{Briefs: []CampaignBrief{{Headline: "فرصت مناسب", EvidenceIDs: []string{"unknown"}}}}, want: false},
|
||||
}
|
||||
known := map[string]struct{}{"event_teacher": {}}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ValidateAICopy(tt.copy, known)
|
||||
if (err == nil) != tt.want {
|
||||
t.Fatalf("ValidateAICopy() error = %v, valid want %v", err, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAvalAIClientUsesBoundedStatelessRequest(t *testing.T) {
|
||||
t.Parallel()
|
||||
var request map[string]any
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "Bearer test-key" {
|
||||
t.Errorf("authorization header missing")
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"status":"completed","output":[{"type":"message","content":[{"type":"output_text","text":"{\"briefs\":[{\"headline\":\"فرصت مناسبی پیش رو دارید\",\"interpretation\":\"با الگوی موفق گذشته همراستا است.\",\"offerSuggestion\":\"پیشنهاد مرتبط آماده کنید.\",\"messageSuggestion\":\"پیام کوتاه و روشن ارسال کنید.\",\"preparationAction\":\"ظرفیت پرداخت را بررسی کنید.\",\"limitation\":\"این پیشنهاد قطعی نیست.\",\"evidenceIds\":[\"event_teacher\"]}]}"}]}],"usage":{"input_tokens":10,"output_tokens":20}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewAvalAIClient(AvalAIConfig{BaseURL: server.URL, APIKey: "test-key", Model: "test-model", Timeout: time.Second}, server.Client())
|
||||
copy, usage, err := client.Generate(context.Background(), json.RawMessage(`{"evidenceIds":["event_teacher"]}`), map[string]struct{}{"event_teacher": {}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(copy.Briefs) != 1 || usage.InputTokens != 10 || usage.OutputTokens != 20 {
|
||||
t.Fatalf("Generate() = %+v, %+v", copy, usage)
|
||||
}
|
||||
if request["store"] != false || request["parallel_tool_calls"] != false {
|
||||
t.Fatalf("unsafe request controls: %#v", request)
|
||||
}
|
||||
if request["max_output_tokens"] != float64(1600) {
|
||||
t.Fatalf("max_output_tokens = %#v", request["max_output_tokens"])
|
||||
}
|
||||
tools, ok := request["tools"].([]any)
|
||||
if !ok || len(tools) != 4 {
|
||||
t.Fatalf("tools = %#v", request["tools"])
|
||||
}
|
||||
encoded, _ := json.Marshal(request)
|
||||
if strings.Contains(string(encoded), "merchantId") || strings.Contains(string(encoded), "payer") {
|
||||
t.Fatalf("sensitive field in request: %s", encoded)
|
||||
}
|
||||
}
|
||||
|
||||
type testTools struct{ calls int }
|
||||
|
||||
func (t *testTools) Execute(_ context.Context, name string, _ json.RawMessage) (json.RawMessage, error) {
|
||||
t.calls++
|
||||
return json.RawMessage(`{"state":"READY"}`), nil
|
||||
}
|
||||
|
||||
func TestAvalAIClientExecutesAllowlistedToolThenStopsAfterSecondTurn(t *testing.T) {
|
||||
t.Parallel()
|
||||
turn := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
turn++
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if turn == 1 {
|
||||
_, _ = w.Write([]byte(`{"status":"completed","output":[{"type":"function_call","call_id":"call_1","name":"get_growth_context","arguments":"{\"evidenceId\":\"event_teacher\"}"}],"usage":{"input_tokens":2,"output_tokens":1}}`))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"status":"completed","output":[{"type":"message","content":[{"type":"output_text","text":"{\"briefs\":[{\"headline\":\"فرصت مناسب\",\"interpretation\":\"همراستا با شواهد است.\",\"offerSuggestion\":\"پیشنهاد مرتبط آماده کنید.\",\"messageSuggestion\":\"پیام روشن ارسال کنید.\",\"preparationAction\":\"مسیر پرداخت را بررسی کنید.\",\"limitation\":\"نتیجه قطعی نیست.\",\"evidenceIds\":[\"event_teacher\"]}]}"}]}],"usage":{"input_tokens":3,"output_tokens":4}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
executor := &testTools{}
|
||||
client := NewAvalAIClient(AvalAIConfig{BaseURL: server.URL, APIKey: "key", Model: "model", Timeout: time.Second}, server.Client())
|
||||
_, usage, err := client.GenerateWithTools(context.Background(), json.RawMessage(`{}`), map[string]struct{}{"event_teacher": {}}, executor)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if executor.calls != 1 || turn != 2 || usage.InputTokens != 5 || usage.OutputTokens != 5 {
|
||||
t.Fatalf("calls=%d turns=%d usage=%+v", executor.calls, turn, usage)
|
||||
}
|
||||
}
|
||||
128
internal/growthreport/coordinator.go
Normal file
128
internal/growthreport/coordinator.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package growthreport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
var ErrRateLimited = errors.New("growth report: rate limited")
|
||||
var ErrBudgetExhausted = errors.New("growth report: budget exhausted")
|
||||
|
||||
type CoordinatorConfig struct {
|
||||
CreatePerMinute, UncachedPerDay, DailyRequests, DailyTokens int64
|
||||
MaxConcurrent int
|
||||
}
|
||||
type Coordinator struct {
|
||||
redis *redis.Client
|
||||
config CoordinatorConfig
|
||||
mu sync.Mutex
|
||||
counters map[string]int64
|
||||
expiries map[string]time.Time
|
||||
}
|
||||
|
||||
func NewCoordinator(client *redis.Client, config CoordinatorConfig) *Coordinator {
|
||||
if config.MaxConcurrent < 1 {
|
||||
config.MaxConcurrent = 4
|
||||
}
|
||||
return &Coordinator{redis: client, config: config, counters: map[string]int64{}, expiries: map[string]time.Time{}}
|
||||
}
|
||||
func (c *Coordinator) AllowCreate(ctx context.Context, userID int64, now time.Time) error {
|
||||
return c.increment(ctx, fmt.Sprintf("growth:create:%d:%s", userID, now.UTC().Format("200601021504")), c.config.CreatePerMinute, time.Minute, now)
|
||||
}
|
||||
func (c *Coordinator) AllowUncached(ctx context.Context, merchantID int64, now time.Time) error {
|
||||
return c.increment(ctx, fmt.Sprintf("growth:scope:%d:%s", merchantID, tehranDate(now)), c.config.UncachedPerDay, 36*time.Hour, now)
|
||||
}
|
||||
func (c *Coordinator) ReserveBudget(ctx context.Context, requests, tokens int64, now time.Time) error {
|
||||
if c.config.DailyRequests < 1 || c.config.DailyTokens < 1 {
|
||||
return ErrBudgetExhausted
|
||||
}
|
||||
if c.redis == nil || requests < 1 || tokens < 1 || requests > c.config.DailyRequests || tokens > c.config.DailyTokens {
|
||||
return ErrBudgetExhausted
|
||||
}
|
||||
script := redis.NewScript(`local req=tonumber(redis.call('GET',KEYS[1]) or '0');local tok=tonumber(redis.call('GET',KEYS[2]) or '0');local addReq=tonumber(ARGV[1]);local addTok=tonumber(ARGV[2]);if req+addReq>tonumber(ARGV[4]) or tok+addTok>tonumber(ARGV[5]) then return 0 end;redis.call('INCRBY',KEYS[1],addReq);redis.call('INCRBY',KEYS[2],addTok);redis.call('PEXPIRE',KEYS[1],ARGV[3]);redis.call('PEXPIRE',KEYS[2],ARGV[3]);return 1`)
|
||||
date := tehranDate(now)
|
||||
allowed, err := script.Run(ctx, c.redis, []string{"growth:budget:req:" + date, "growth:budget:tok:" + date}, requests, tokens, (36 * time.Hour).Milliseconds(), c.config.DailyRequests, c.config.DailyTokens).Int()
|
||||
if err != nil || allowed != 1 {
|
||||
return ErrBudgetExhausted
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Coordinator) incrementRedis(ctx context.Context, key string, amount, limit int64, ttl time.Duration) error {
|
||||
script := redis.NewScript(`local v=redis.call('INCRBY',KEYS[1],ARGV[1]);if v==tonumber(ARGV[1]) then redis.call('PEXPIRE',KEYS[1],ARGV[2]) end;if v>tonumber(ARGV[3]) then return 0 end;return 1`)
|
||||
value, err := script.Run(ctx, c.redis, []string{key}, amount, ttl.Milliseconds(), limit).Int()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if value == 0 {
|
||||
return ErrRateLimited
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (c *Coordinator) Enter(ctx context.Context) (func(), error) {
|
||||
if c.redis == nil {
|
||||
return nil, ErrBudgetExhausted
|
||||
}
|
||||
acquire := redis.NewScript(`local v=redis.call('INCR',KEYS[1]);if v==1 then redis.call('PEXPIRE',KEYS[1],ARGV[2]) end;if v>tonumber(ARGV[1]) then redis.call('DECR',KEYS[1]);return 0 end;return 1`)
|
||||
for {
|
||||
ok, err := acquire.Run(ctx, c.redis, []string{"growth:ai:concurrency"}, c.config.MaxConcurrent, (3 * time.Minute).Milliseconds()).Int()
|
||||
if err != nil {
|
||||
return nil, ErrBudgetExhausted
|
||||
}
|
||||
if ok == 1 {
|
||||
return func() {
|
||||
releaseCtx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
script := redis.NewScript(`local v=tonumber(redis.call('GET',KEYS[1]) or '0');if v>0 then return redis.call('DECR',KEYS[1]) end return 0`)
|
||||
_ = script.Run(releaseCtx, c.redis, []string{"growth:ai:concurrency"}).Err()
|
||||
}, nil
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
}
|
||||
func (c *Coordinator) increment(ctx context.Context, key string, limit int64, ttl time.Duration, now time.Time) error {
|
||||
return c.incrementBy(ctx, key, 1, limit, ttl, now)
|
||||
}
|
||||
func (c *Coordinator) incrementBy(ctx context.Context, key string, amount, limit int64, ttl time.Duration, now time.Time) error {
|
||||
if limit < 1 {
|
||||
return ErrRateLimited
|
||||
}
|
||||
if c.redis != nil {
|
||||
script := redis.NewScript(`local v=redis.call('INCRBY',KEYS[1],ARGV[1]);if v==tonumber(ARGV[1]) then redis.call('PEXPIRE',KEYS[1],ARGV[2]) end;if v>tonumber(ARGV[3]) then return 0 end;return 1`)
|
||||
value, err := script.Run(ctx, c.redis, []string{key}, amount, ttl.Milliseconds(), limit).Int()
|
||||
if err == nil {
|
||||
if value == 0 {
|
||||
return ErrRateLimited
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if expiry := c.expiries[key]; !expiry.After(now) {
|
||||
c.counters[key] = 0
|
||||
c.expiries[key] = now.Add(ttl)
|
||||
}
|
||||
next := c.counters[key] + amount
|
||||
if next > limit {
|
||||
return ErrRateLimited
|
||||
}
|
||||
c.counters[key] = next
|
||||
return nil
|
||||
}
|
||||
func tehranDate(now time.Time) string {
|
||||
location, err := time.LoadLocation("Asia/Tehran")
|
||||
if err != nil {
|
||||
location = time.FixedZone("Asia/Tehran", 12600)
|
||||
}
|
||||
return now.In(location).Format(time.DateOnly)
|
||||
}
|
||||
26
internal/growthreport/coordinator_test.go
Normal file
26
internal/growthreport/coordinator_test.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package growthreport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCoordinatorFallsBackToBoundedProcessCounters(t *testing.T) {
|
||||
t.Parallel()
|
||||
coordinator := NewCoordinator(nil, CoordinatorConfig{CreatePerMinute: 2, UncachedPerDay: 1, DailyRequests: 1, DailyTokens: 1600, MaxConcurrent: 1})
|
||||
now := time.Date(2026, 8, 21, 0, 0, 0, 0, time.UTC)
|
||||
if err := coordinator.AllowCreate(context.Background(), 7, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := coordinator.AllowCreate(context.Background(), 7, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := coordinator.AllowCreate(context.Background(), 7, now); !errors.Is(err, ErrRateLimited) {
|
||||
t.Fatalf("third request error=%v", err)
|
||||
}
|
||||
if err := coordinator.ReserveBudget(context.Background(), MaxProviderRequests, 1600, now); !errors.Is(err, ErrBudgetExhausted) {
|
||||
t.Fatalf("budget without Redis error=%v", err)
|
||||
}
|
||||
}
|
||||
95
internal/growthreport/model.go
Normal file
95
internal/growthreport/model.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package growthreport
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Status string
|
||||
|
||||
const (
|
||||
StatusQueued Status = "QUEUED"
|
||||
StatusAnalyzing Status = "ANALYZING"
|
||||
StatusGenerating Status = "GENERATING"
|
||||
StatusReady Status = "READY"
|
||||
StatusPartial Status = "PARTIAL"
|
||||
StatusFailed Status = "FAILED"
|
||||
)
|
||||
|
||||
func (s Status) Terminal() bool { return s == StatusReady || s == StatusPartial || s == StatusFailed }
|
||||
|
||||
type Scope struct {
|
||||
TerminalID *string `json:"terminalId"`
|
||||
}
|
||||
|
||||
type CampaignBrief struct {
|
||||
Headline string `json:"headline"`
|
||||
Interpretation string `json:"interpretation"`
|
||||
OfferSuggestion string `json:"offerSuggestion"`
|
||||
MessageSuggestion string `json:"messageSuggestion"`
|
||||
PreparationAction string `json:"preparationAction"`
|
||||
Limitation string `json:"limitation"`
|
||||
EvidenceIDs []string `json:"evidenceIds"`
|
||||
}
|
||||
|
||||
type AICopy struct {
|
||||
Briefs []CampaignBrief `json:"briefs"`
|
||||
}
|
||||
|
||||
type Usage struct {
|
||||
InputTokens int64 `json:"inputTokens"`
|
||||
OutputTokens int64 `json:"outputTokens"`
|
||||
LatencyMS int64 `json:"latencyMs"`
|
||||
ProviderRequests int64 `json:"providerRequests"`
|
||||
ReservedTokens int64 `json:"reservedTokens"`
|
||||
ProviderRequestID string `json:"providerRequestId,omitempty"`
|
||||
EstimatedCost string `json:"estimatedCost,omitempty"`
|
||||
}
|
||||
|
||||
type Report struct {
|
||||
ReportID string `json:"reportId"`
|
||||
Revision int `json:"revision"`
|
||||
Status Status `json:"status"`
|
||||
Scope Scope `json:"scope"`
|
||||
HorizonDays int `json:"horizonDays"`
|
||||
ForecastStartDate *time.Time `json:"forecastStartDate,omitempty"`
|
||||
DeterministicEvidence json.RawMessage `json:"deterministicEvidence,omitempty"`
|
||||
AICopy *AICopy `json:"aiCopy,omitempty"`
|
||||
FailureCode *string `json:"failureCode,omitempty"`
|
||||
Cached bool `json:"cached"`
|
||||
Stale bool `json:"stale"`
|
||||
NextRefreshAt time.Time `json:"nextRefreshAt"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
EventsURL string `json:"eventsUrl"`
|
||||
}
|
||||
|
||||
type Record struct {
|
||||
Report
|
||||
MerchantID int64
|
||||
TerminalDBID *int64
|
||||
Fingerprint string
|
||||
DatasetWatermark string
|
||||
CatalogVersion string
|
||||
AnalyticsVersion string
|
||||
PromptVersion string
|
||||
SchemaVersion string
|
||||
Model string
|
||||
Usage Usage
|
||||
ManualRefresh bool
|
||||
TehranRefreshDay *time.Time
|
||||
ClaimToken string
|
||||
}
|
||||
|
||||
type CreateRequest struct {
|
||||
TerminalID string `json:"terminalId"`
|
||||
Refresh bool `json:"refresh"`
|
||||
}
|
||||
|
||||
type RefreshLimitError struct{ NextRefreshAt time.Time }
|
||||
|
||||
func (e *RefreshLimitError) Error() string {
|
||||
return fmt.Sprintf("%s: next refresh at %s", ErrRefreshLimited, e.NextRefreshAt.Format(time.RFC3339))
|
||||
}
|
||||
func (e *RefreshLimitError) Unwrap() error { return ErrRefreshLimited }
|
||||
180
internal/growthreport/orchestrator.go
Normal file
180
internal/growthreport/orchestrator.go
Normal file
@@ -0,0 +1,180 @@
|
||||
package growthreport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/growth"
|
||||
)
|
||||
|
||||
var ErrDisabled = errors.New("growth report: disabled")
|
||||
|
||||
const MaxProviderRequests int64 = 4
|
||||
|
||||
type ServiceConfig struct {
|
||||
Enabled bool
|
||||
Model, PromptVersion, SchemaVersion string
|
||||
}
|
||||
type Service struct {
|
||||
repository *Repository
|
||||
coordinator *Coordinator
|
||||
config ServiceConfig
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewService(repository *Repository, coordinator *Coordinator, config ServiceConfig) *Service {
|
||||
return &Service{repository: repository, coordinator: coordinator, config: config, now: time.Now}
|
||||
}
|
||||
|
||||
func (s *Service) Create(ctx context.Context, userID, merchantID int64, request CreateRequest, idempotencyKey string) (Record, bool, error) {
|
||||
if !s.config.Enabled {
|
||||
return Record{}, false, ErrDisabled
|
||||
}
|
||||
now := s.now().UTC()
|
||||
if err := s.coordinator.AllowCreate(ctx, userID, now); err != nil {
|
||||
return Record{}, false, err
|
||||
}
|
||||
watermark, start, err := s.repository.DatasetMetadata(ctx)
|
||||
if err != nil {
|
||||
return Record{}, false, err
|
||||
}
|
||||
fingerprint := s.fingerprint(merchantID, request.TerminalID, watermark, start)
|
||||
var refreshDay *time.Time
|
||||
if request.Refresh {
|
||||
day := tehranMidnight(now)
|
||||
refreshDay = &day
|
||||
}
|
||||
record, cached, err := s.repository.CreateOrReuse(ctx, CreateRecord{ID: newReportID(), MerchantID: merchantID, TerminalID: request.TerminalID, Fingerprint: fingerprint, DatasetWatermark: watermark, ForecastStartDate: &start, CatalogVersion: growth.CatalogVersion, AnalyticsVersion: growth.AnalyticsVersion, PromptVersion: s.config.PromptVersion, SchemaVersion: s.config.SchemaVersion, Model: s.config.Model, ManualRefresh: request.Refresh, RefreshDay: refreshDay, IdempotencyKey: idempotencyKey, RequestHash: RequestHash(merchantID, request.TerminalID, request.Refresh)}, func() error {
|
||||
return s.coordinator.AllowUncached(ctx, merchantID, now)
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrRefreshLimited) {
|
||||
next, quotaErr := s.repository.NextRefreshAt(ctx, merchantID, now)
|
||||
if quotaErr != nil {
|
||||
return Record{}, false, quotaErr
|
||||
}
|
||||
return Record{}, false, &RefreshLimitError{NextRefreshAt: next}
|
||||
}
|
||||
return Record{}, false, err
|
||||
}
|
||||
record.Cached = cached
|
||||
record.NextRefreshAt, err = s.repository.NextRefreshAt(ctx, merchantID, now)
|
||||
return record, cached, err
|
||||
}
|
||||
func (s *Service) Get(ctx context.Context, merchantID int64, id string) (Record, error) {
|
||||
record, err := s.repository.Get(ctx, merchantID, id)
|
||||
if err == nil {
|
||||
record.NextRefreshAt, _ = s.repository.NextRefreshAt(ctx, merchantID, s.now())
|
||||
}
|
||||
return record, err
|
||||
}
|
||||
func (s *Service) Latest(ctx context.Context, merchantID int64, terminalID string) (Record, error) {
|
||||
record, err := s.repository.Latest(ctx, merchantID, terminalID)
|
||||
if err == nil {
|
||||
record.Cached = true
|
||||
watermark, start, metadataErr := s.repository.DatasetMetadata(ctx)
|
||||
if metadataErr != nil {
|
||||
record.Stale = true
|
||||
} else {
|
||||
record.Stale = record.Fingerprint != s.fingerprint(merchantID, terminalID, watermark, start)
|
||||
}
|
||||
record.NextRefreshAt, _ = s.repository.NextRefreshAt(ctx, merchantID, s.now())
|
||||
}
|
||||
return record, err
|
||||
}
|
||||
|
||||
func (s *Service) fingerprint(merchantID int64, terminalID, watermark string, start time.Time) string {
|
||||
return growth.Fingerprint(growth.FingerprintInput{MerchantID: merchantID, TerminalID: terminalID, DatasetWatermark: watermark, ForecastStart: start, HorizonDays: 90, CatalogVersion: growth.CatalogVersion, AnalyticsVersion: growth.AnalyticsVersion, PromptVersion: s.config.PromptVersion, SchemaVersion: s.config.SchemaVersion, Model: s.config.Model, ModelConfigVersion: growth.DefaultModelConfigVersion})
|
||||
}
|
||||
|
||||
type Processor struct {
|
||||
repository *Repository
|
||||
builder Builder
|
||||
avalai *AvalAIClient
|
||||
coordinator *Coordinator
|
||||
aiEnabled bool
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewProcessor(repository *Repository, builder Builder, avalai *AvalAIClient, coordinator *Coordinator, aiEnabled bool) *Processor {
|
||||
return &Processor{repository: repository, builder: builder, avalai: avalai, coordinator: coordinator, aiEnabled: aiEnabled, now: time.Now}
|
||||
}
|
||||
func (p *Processor) ProcessNext(ctx context.Context) error {
|
||||
record, err := p.repository.Claim(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
terminal := ""
|
||||
if record.Scope.TerminalID != nil {
|
||||
terminal = *record.Scope.TerminalID
|
||||
}
|
||||
analysis, err := p.builder.Build(ctx, record.MerchantID, terminal)
|
||||
if err != nil {
|
||||
_ = p.repository.CompleteClaimed(ctx, record.ReportID, record.ClaimToken, StatusFailed, json.RawMessage(`{}`), nil, Usage{}, "ANALYTICS_FAILED")
|
||||
return err
|
||||
}
|
||||
if err := p.repository.StoreEvidence(ctx, record.ReportID, record.ClaimToken, analysis.Encoded); err != nil {
|
||||
return err
|
||||
}
|
||||
copy := analysis.Fallback
|
||||
status := StatusPartial
|
||||
failure := "AI_DISABLED_OR_UNAVAILABLE"
|
||||
if analysis.ShouldCallAI && p.aiEnabled && p.avalai != nil {
|
||||
reservedTokens := EstimatedTokenReservation(analysis.Encoded)
|
||||
release, enterErr := p.coordinator.Enter(ctx)
|
||||
if enterErr == nil {
|
||||
defer release()
|
||||
if err := p.coordinator.ReserveBudget(ctx, MaxProviderRequests, reservedTokens, p.now()); err == nil {
|
||||
generated, usage, aiErr := p.avalai.GenerateWithTools(ctx, analysis.Encoded, analysis.KnownEvidence, NewEvidenceTools(analysis))
|
||||
usage.ReservedTokens = reservedTokens
|
||||
if aiErr == nil {
|
||||
slog.Info("growth AI generation complete", "report_id", record.ReportID, "provider_requests", usage.ProviderRequests, "reserved_tokens", usage.ReservedTokens, "input_tokens", usage.InputTokens, "output_tokens", usage.OutputTokens, "latency_ms", usage.LatencyMS, "estimated_cost", usage.EstimatedCost, "provider_request_id", usage.ProviderRequestID)
|
||||
return p.repository.CompleteClaimed(ctx, record.ReportID, record.ClaimToken, StatusReady, analysis.Encoded, &generated, usage, "")
|
||||
}
|
||||
slog.Warn("growth AI output rejected", "report_id", record.ReportID, "reason", "AI_REJECTED", "provider_requests", usage.ProviderRequests, "reserved_tokens", usage.ReservedTokens, "input_tokens", usage.InputTokens, "output_tokens", usage.OutputTokens, "latency_ms", usage.LatencyMS, "estimated_cost", usage.EstimatedCost, "provider_request_id", usage.ProviderRequestID)
|
||||
failure = "AI_REJECTED"
|
||||
return p.repository.CompleteClaimed(ctx, record.ReportID, record.ClaimToken, status, analysis.Encoded, ©, usage, failure)
|
||||
} else {
|
||||
failure = "AI_BUDGET_EXHAUSTED"
|
||||
slog.Warn("growth AI skipped", "report_id", record.ReportID, "reason", failure)
|
||||
}
|
||||
} else {
|
||||
failure = "AI_CONCURRENCY_UNAVAILABLE"
|
||||
slog.Warn("growth AI skipped", "report_id", record.ReportID, "reason", failure)
|
||||
}
|
||||
}
|
||||
if !analysis.ShouldCallAI || !p.aiEnabled {
|
||||
slog.Info("growth deterministic fallback used", "report_id", record.ReportID, "reason", failure)
|
||||
}
|
||||
return p.repository.CompleteClaimed(ctx, record.ReportID, record.ClaimToken, status, analysis.Encoded, ©, Usage{}, failure)
|
||||
}
|
||||
|
||||
// EstimatedTokenReservation reserves for two complete attempts, each with two
|
||||
// provider turns, before any network call. Bytes are deliberately counted as
|
||||
// tokens and the fixed allowance covers tool schemas, instructions, tool
|
||||
// outputs, and every turn's 1,600-token output ceiling.
|
||||
func EstimatedTokenReservation(evidence json.RawMessage) int64 {
|
||||
return int64(12*len(evidence) + 32000)
|
||||
}
|
||||
|
||||
func newReportID() string {
|
||||
data := make([]byte, 12)
|
||||
if _, err := rand.Read(data); err != nil {
|
||||
return fmt.Sprintf("growth_%d", time.Now().UnixNano())
|
||||
}
|
||||
return "growth_" + hex.EncodeToString(data)
|
||||
}
|
||||
func tehranMidnight(now time.Time) time.Time {
|
||||
location, err := time.LoadLocation("Asia/Tehran")
|
||||
if err != nil {
|
||||
location = time.FixedZone("Asia/Tehran", 12600)
|
||||
}
|
||||
local := now.In(location)
|
||||
return time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, location)
|
||||
}
|
||||
294
internal/growthreport/repository.go
Normal file
294
internal/growthreport/repository.go
Normal file
@@ -0,0 +1,294 @@
|
||||
package growthreport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("growth report: not found")
|
||||
ErrUnknownTerminal = errors.New("growth report: unknown terminal")
|
||||
ErrIdempotencyConflict = errors.New("growth report: idempotency conflict")
|
||||
ErrRefreshLimited = errors.New("growth report: refresh limited")
|
||||
)
|
||||
|
||||
type Repository struct{ pool *pgxpool.Pool }
|
||||
|
||||
const reportLeaseDuration = 3 * time.Minute
|
||||
|
||||
func NewRepository(pool *pgxpool.Pool) *Repository { return &Repository{pool: pool} }
|
||||
|
||||
type CreateRecord struct {
|
||||
ID string
|
||||
MerchantID int64
|
||||
TerminalID string
|
||||
Fingerprint string
|
||||
DatasetWatermark string
|
||||
ForecastStartDate *time.Time
|
||||
CatalogVersion string
|
||||
AnalyticsVersion string
|
||||
PromptVersion string
|
||||
SchemaVersion string
|
||||
Model string
|
||||
ManualRefresh bool
|
||||
RefreshDay *time.Time
|
||||
IdempotencyKey string
|
||||
RequestHash string
|
||||
}
|
||||
|
||||
func (r *Repository) CreateOrReuse(ctx context.Context, in CreateRecord, beforeInsert func() error) (Record, bool, error) {
|
||||
tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.Serializable})
|
||||
if err != nil {
|
||||
return Record{}, false, fmt.Errorf("beginning report transaction: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
lockKey := fmt.Sprintf("growth-revision:%d:%s", in.MerchantID, in.TerminalID)
|
||||
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,0))`, lockKey); err != nil {
|
||||
return Record{}, false, fmt.Errorf("locking growth report request: %w", err)
|
||||
}
|
||||
|
||||
var terminalDBID *int64
|
||||
if in.TerminalID != "" {
|
||||
var id int64
|
||||
if err := tx.QueryRow(ctx, `SELECT id FROM terminals WHERE merchant_id=$1 AND source_key=$2`, in.MerchantID, in.TerminalID).Scan(&id); errors.Is(err, pgx.ErrNoRows) {
|
||||
return Record{}, false, ErrUnknownTerminal
|
||||
} else if err != nil {
|
||||
return Record{}, false, fmt.Errorf("validating report terminal: %w", err)
|
||||
}
|
||||
terminalDBID = &id
|
||||
}
|
||||
if in.IdempotencyKey != "" {
|
||||
var requestHash, reportID string
|
||||
err := tx.QueryRow(ctx, `SELECT request_hash,report_id FROM growth_report_idempotency WHERE merchant_id=$1 AND idempotency_key=$2`, in.MerchantID, in.IdempotencyKey).Scan(&requestHash, &reportID)
|
||||
if err == nil {
|
||||
if requestHash != in.RequestHash {
|
||||
return Record{}, false, ErrIdempotencyConflict
|
||||
}
|
||||
record, err := getWith(ctx, tx, in.MerchantID, reportID)
|
||||
if err != nil {
|
||||
return Record{}, false, err
|
||||
}
|
||||
return record, true, nil
|
||||
}
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return Record{}, false, fmt.Errorf("reading report idempotency: %w", err)
|
||||
}
|
||||
}
|
||||
if !in.ManualRefresh {
|
||||
record, err := currentWith(ctx, tx, in.MerchantID, in.Fingerprint)
|
||||
if err == nil {
|
||||
return record, true, nil
|
||||
}
|
||||
if !errors.Is(err, ErrNotFound) {
|
||||
return Record{}, false, err
|
||||
}
|
||||
}
|
||||
if in.ManualRefresh && in.RefreshDay != nil {
|
||||
var exists bool
|
||||
if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM growth_reports WHERE merchant_id=$1 AND manual_refresh=true AND tehran_refresh_day=$2)`, in.MerchantID, in.RefreshDay).Scan(&exists); err != nil {
|
||||
return Record{}, false, fmt.Errorf("checking daily refresh: %w", err)
|
||||
}
|
||||
if exists {
|
||||
return Record{}, false, ErrRefreshLimited
|
||||
}
|
||||
}
|
||||
if beforeInsert != nil {
|
||||
if err := beforeInsert(); err != nil {
|
||||
return Record{}, false, err
|
||||
}
|
||||
}
|
||||
var revision int
|
||||
if err := tx.QueryRow(ctx, `SELECT coalesce(max(revision),0)+1 FROM growth_reports WHERE merchant_id=$1 AND terminal_source_key IS NOT DISTINCT FROM $2::text`, in.MerchantID, nilIfEmpty(in.TerminalID)).Scan(&revision); err != nil {
|
||||
return Record{}, false, fmt.Errorf("calculating growth report revision: %w", err)
|
||||
}
|
||||
_, err = tx.Exec(ctx, `INSERT INTO growth_reports(id,merchant_id,terminal_id,terminal_source_key,fingerprint,revision,status,forecast_start_date,dataset_watermark,catalog_version,analytics_version,prompt_version,schema_version,model,manual_refresh,tehran_refresh_day)
|
||||
VALUES($1,$2,$3,$4,$5,$6,'QUEUED',$7,$8,$9,$10,$11,$12,$13,$14,$15)`, in.ID, in.MerchantID, terminalDBID, nilIfEmpty(in.TerminalID), in.Fingerprint, revision, in.ForecastStartDate, in.DatasetWatermark, in.CatalogVersion, in.AnalyticsVersion, in.PromptVersion, in.SchemaVersion, in.Model, in.ManualRefresh, in.RefreshDay)
|
||||
if err != nil {
|
||||
if in.ManualRefresh && isUniqueViolation(err) {
|
||||
return Record{}, false, ErrRefreshLimited
|
||||
}
|
||||
return Record{}, false, fmt.Errorf("creating growth report: %w", err)
|
||||
}
|
||||
if in.IdempotencyKey != "" {
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO growth_report_idempotency(merchant_id,idempotency_key,request_hash,report_id) VALUES($1,$2,$3,$4)`, in.MerchantID, in.IdempotencyKey, in.RequestHash, in.ID); err != nil {
|
||||
return Record{}, false, fmt.Errorf("creating report idempotency: %w", err)
|
||||
}
|
||||
}
|
||||
record, err := getWith(ctx, tx, in.MerchantID, in.ID)
|
||||
if err != nil {
|
||||
return Record{}, false, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return Record{}, false, fmt.Errorf("committing growth report: %w", err)
|
||||
}
|
||||
return record, false, nil
|
||||
}
|
||||
|
||||
func (r *Repository) Get(ctx context.Context, merchantID int64, id string) (Record, error) {
|
||||
return getWith(ctx, r.pool, merchantID, id)
|
||||
}
|
||||
|
||||
func (r *Repository) Latest(ctx context.Context, merchantID int64, terminalID string) (Record, error) {
|
||||
return scanRecord(r.pool.QueryRow(ctx, reportSelect+` WHERE merchant_id=$1 AND terminal_source_key IS NOT DISTINCT FROM $2::text AND status IN ('READY','PARTIAL') ORDER BY created_at DESC LIMIT 1`, merchantID, nilIfEmpty(terminalID)))
|
||||
}
|
||||
|
||||
func (r *Repository) Claim(ctx context.Context) (Record, error) {
|
||||
// PostgreSQL is the durable queue and coordination authority. SKIP LOCKED plus
|
||||
// the state transition prevents two worker processes from generating one row.
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return Record{}, fmt.Errorf("beginning report claim: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
if _, err := tx.Exec(ctx, `UPDATE growth_reports SET status='QUEUED',lease_token=NULL,lease_expires_at=NULL,updated_at=now(),failure_code='WORKER_LEASE_EXPIRED' WHERE status IN ('ANALYZING','GENERATING') AND (lease_expires_at IS NULL OR lease_expires_at<now())`); err != nil {
|
||||
return Record{}, fmt.Errorf("recovering expired report leases: %w", err)
|
||||
}
|
||||
row := tx.QueryRow(ctx, reportSelect+` WHERE id=(SELECT id FROM growth_reports WHERE status='QUEUED' ORDER BY created_at FOR UPDATE SKIP LOCKED LIMIT 1)`)
|
||||
record, err := scanRecord(row)
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return Record{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Record{}, err
|
||||
}
|
||||
claimToken := newReportID()
|
||||
leaseExpiresAt := time.Now().UTC().Add(reportLeaseDuration)
|
||||
if _, err := tx.Exec(ctx, `UPDATE growth_reports SET status='ANALYZING',lease_token=$2,lease_expires_at=$3,updated_at=now() WHERE id=$1`, record.ReportID, claimToken, leaseExpiresAt); err != nil {
|
||||
return Record{}, fmt.Errorf("claiming growth report: %w", err)
|
||||
}
|
||||
record.Status = StatusAnalyzing
|
||||
record.ClaimToken = claimToken
|
||||
record.UpdatedAt = time.Now().UTC()
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return Record{}, fmt.Errorf("committing report claim: %w", err)
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (r *Repository) StoreEvidence(ctx context.Context, reportID, claimToken string, evidence json.RawMessage) error {
|
||||
command, err := r.pool.Exec(ctx, `UPDATE growth_reports SET deterministic_evidence=$3,status='GENERATING',lease_expires_at=now()+make_interval(secs => $4),updated_at=now() WHERE id=$1 AND lease_token=$2 AND status='ANALYZING'`, reportID, claimToken, evidence, int(reportLeaseDuration/time.Second))
|
||||
if err != nil {
|
||||
return fmt.Errorf("storing deterministic evidence: %w", err)
|
||||
}
|
||||
if command.RowsAffected() != 1 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) CompleteClaimed(ctx context.Context, reportID, claimToken string, status Status, evidence json.RawMessage, copy *AICopy, usage Usage, failureCode string) error {
|
||||
if status != StatusReady && status != StatusPartial && status != StatusFailed {
|
||||
return errors.New("growth report: invalid terminal status")
|
||||
}
|
||||
var ai any
|
||||
if copy != nil {
|
||||
ai = copy
|
||||
}
|
||||
command, err := r.pool.Exec(ctx, `UPDATE growth_reports SET status=$3,deterministic_evidence=$4,ai_response=$5,failure_code=$6,input_tokens=$7,output_tokens=$8,latency_ms=$9,provider_requests=$10,reserved_tokens=$11,provider_request_id=$12,estimated_cost=$13,lease_token=NULL,lease_expires_at=NULL,updated_at=now(),completed_at=now() WHERE id=$1 AND lease_token=$2`, reportID, claimToken, status, evidence, ai, nilIfEmpty(failureCode), usage.InputTokens, usage.OutputTokens, usage.LatencyMS, usage.ProviderRequests, usage.ReservedTokens, nilIfEmpty(usage.ProviderRequestID), nilIfEmpty(usage.EstimatedCost))
|
||||
if err != nil {
|
||||
return fmt.Errorf("completing growth report: %w", err)
|
||||
}
|
||||
if command.RowsAffected() != 1 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const reportSelect = `SELECT id,revision,merchant_id,terminal_id,terminal_source_key,fingerprint,status,horizon_days,forecast_start_date,dataset_watermark,catalog_version,analytics_version,prompt_version,schema_version,model,deterministic_evidence,ai_response,failure_code,input_tokens,output_tokens,latency_ms,provider_requests,reserved_tokens,provider_request_id,estimated_cost,manual_refresh,tehran_refresh_day,created_at,updated_at FROM growth_reports`
|
||||
|
||||
type rowScanner interface{ Scan(...any) error }
|
||||
type queryer interface {
|
||||
QueryRow(context.Context, string, ...any) pgx.Row
|
||||
}
|
||||
|
||||
func getWith(ctx context.Context, q queryer, merchantID int64, id string) (Record, error) {
|
||||
return scanRecord(q.QueryRow(ctx, reportSelect+` WHERE merchant_id=$1 AND id=$2`, merchantID, id))
|
||||
}
|
||||
func currentWith(ctx context.Context, q queryer, merchantID int64, fingerprint string) (Record, error) {
|
||||
return scanRecord(q.QueryRow(ctx, reportSelect+` WHERE merchant_id=$1 AND fingerprint=$2 AND manual_refresh=false AND status IN ('QUEUED','ANALYZING','GENERATING','READY','PARTIAL') ORDER BY created_at DESC LIMIT 1`, merchantID, fingerprint))
|
||||
}
|
||||
|
||||
func scanRecord(row rowScanner) (Record, error) {
|
||||
var r Record
|
||||
var status string
|
||||
var evidence []byte
|
||||
var ai []byte
|
||||
var terminalSource *string
|
||||
var providerRequestID, estimatedCost *string
|
||||
err := row.Scan(&r.ReportID, &r.Revision, &r.MerchantID, &r.TerminalDBID, &terminalSource, &r.Fingerprint, &status, &r.HorizonDays, &r.ForecastStartDate, &r.DatasetWatermark, &r.CatalogVersion, &r.AnalyticsVersion, &r.PromptVersion, &r.SchemaVersion, &r.Model, &evidence, &ai, &r.FailureCode, &r.Usage.InputTokens, &r.Usage.OutputTokens, &r.Usage.LatencyMS, &r.Usage.ProviderRequests, &r.Usage.ReservedTokens, &providerRequestID, &estimatedCost, &r.ManualRefresh, &r.TehranRefreshDay, &r.CreatedAt, &r.UpdatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Record{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Record{}, fmt.Errorf("scanning growth report: %w", err)
|
||||
}
|
||||
r.Status = Status(status)
|
||||
if providerRequestID != nil {
|
||||
r.Usage.ProviderRequestID = *providerRequestID
|
||||
}
|
||||
if estimatedCost != nil {
|
||||
r.Usage.EstimatedCost = *estimatedCost
|
||||
}
|
||||
if terminalSource != nil {
|
||||
r.Scope.TerminalID = terminalSource
|
||||
}
|
||||
r.DeterministicEvidence = json.RawMessage(evidence)
|
||||
r.EventsURL = "/api/v1/analytics/growth-reports/" + r.ReportID + "/events"
|
||||
if len(ai) > 0 {
|
||||
var copy AICopy
|
||||
if json.Unmarshal(ai, ©) == nil {
|
||||
r.AICopy = ©
|
||||
}
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func nilIfEmpty(value string) any {
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func isUniqueViolation(err error) bool {
|
||||
type sqlState interface{ SQLState() string }
|
||||
var state sqlState
|
||||
return errors.As(err, &state) && state.SQLState() == "23505"
|
||||
}
|
||||
|
||||
func (r *Repository) NextRefreshAt(ctx context.Context, merchantID int64, now time.Time) (time.Time, error) {
|
||||
var last *time.Time
|
||||
if err := r.pool.QueryRow(ctx, `SELECT max(created_at) FROM growth_reports WHERE merchant_id=$1 AND manual_refresh=true`, merchantID).Scan(&last); err != nil {
|
||||
return time.Time{}, fmt.Errorf("loading refresh quota: %w", err)
|
||||
}
|
||||
if last == nil {
|
||||
return now.UTC(), nil
|
||||
}
|
||||
return NextTehranRefresh(*last), nil
|
||||
}
|
||||
|
||||
func (r *Repository) DatasetMetadata(ctx context.Context) (string, time.Time, error) {
|
||||
var sha string
|
||||
var latest time.Time
|
||||
err := r.pool.QueryRow(ctx, `SELECT ir.source_sha256,max(ps.created_at AT TIME ZONE 'Asia/Tehran')::date
|
||||
FROM payment_sessions ps JOIN import_runs ir ON ir.id=ps.import_run_id WHERE ir.status='COMPLETED'
|
||||
GROUP BY ir.id,ir.source_sha256 ORDER BY ir.completed_at DESC LIMIT 1`).Scan(&sha, &latest)
|
||||
if err != nil {
|
||||
return "", time.Time{}, fmt.Errorf("loading growth dataset metadata: %w", err)
|
||||
}
|
||||
return sha + ":" + latest.Format(time.DateOnly), latest.AddDate(0, 0, 1), nil
|
||||
}
|
||||
|
||||
func (r *Repository) CleanupSuperseded(ctx context.Context, cutoff time.Time) (int64, error) {
|
||||
command, err := r.pool.Exec(ctx, `DELETE FROM growth_reports old WHERE old.created_at<$1 AND EXISTS(SELECT 1 FROM growth_reports newer WHERE newer.merchant_id=old.merchant_id AND newer.terminal_source_key IS NOT DISTINCT FROM old.terminal_source_key AND newer.id<>old.id AND newer.revision>old.revision AND newer.status IN ('READY','PARTIAL'))`, cutoff)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("cleaning superseded growth reports: %w", err)
|
||||
}
|
||||
return command.RowsAffected(), nil
|
||||
}
|
||||
81
internal/growthreport/repository_integration_test.go
Normal file
81
internal/growthreport/repository_integration_test.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package growthreport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func TestRepositoryRecoversExpiredActiveLeases(t *testing.T) {
|
||||
pool, merchantID := growthRepositoryTestPool(t)
|
||||
ctx := context.Background()
|
||||
now := time.Now().UTC()
|
||||
for index, status := range []Status{StatusAnalyzing, StatusGenerating} {
|
||||
id := fmt.Sprintf("growth_%024x", index+1)
|
||||
_, err := pool.Exec(ctx, `INSERT INTO growth_reports(id,merchant_id,fingerprint,status,forecast_start_date,dataset_watermark,catalog_version,analytics_version,prompt_version,schema_version,model,lease_token,lease_expires_at,created_at,updated_at) VALUES($1,$2,$3,$4,current_date,'test','test','test','test','test','test',$5,$6,$7,$7)`, id, merchantID, id, status, "expired-token", now.Add(-time.Minute), now.Add(time.Duration(index)*time.Second))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
repository := NewRepository(pool)
|
||||
first, err := repository.Claim(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first.Status != StatusAnalyzing || first.ClaimToken == "" || first.ClaimToken == "expired-token" {
|
||||
t.Fatalf("recovered claim=%+v", first)
|
||||
}
|
||||
if err := repository.CompleteClaimed(ctx, first.ReportID, "expired-token", StatusPartial, json.RawMessage(`{}`), nil, Usage{}, "stale"); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("stale completion error=%v", err)
|
||||
}
|
||||
second, err := repository.Claim(ctx)
|
||||
if err != nil || second.ReportID == first.ReportID || second.ClaimToken == "" {
|
||||
t.Fatalf("second claim=%+v error=%v", second, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepositoryChecksUncachedAllowanceBeforeInsert(t *testing.T) {
|
||||
pool, merchantID := growthRepositoryTestPool(t)
|
||||
repository := NewRepository(pool)
|
||||
start := time.Now().UTC()
|
||||
input := CreateRecord{ID: "growth_ffffffffffffffffffffffff", MerchantID: merchantID, Fingerprint: "blocked", DatasetWatermark: "test", ForecastStartDate: &start, CatalogVersion: "test", AnalyticsVersion: "test", PromptVersion: "test", SchemaVersion: "test", Model: "test"}
|
||||
_, _, err := repository.CreateOrReuse(context.Background(), input, func() error { return ErrRateLimited })
|
||||
if !errors.Is(err, ErrRateLimited) {
|
||||
t.Fatalf("CreateOrReuse error=%v", err)
|
||||
}
|
||||
var count int
|
||||
if err := pool.QueryRow(context.Background(), `SELECT count(*) FROM growth_reports WHERE id=$1`, input.ID).Scan(&count); err != nil || count != 0 {
|
||||
t.Fatalf("queued reports=%d error=%v", count, err)
|
||||
}
|
||||
}
|
||||
|
||||
func growthRepositoryTestPool(t *testing.T) (*pgxpool.Pool, int64) {
|
||||
t.Helper()
|
||||
databaseURL := os.Getenv("TEST_DATABASE_URL")
|
||||
if databaseURL == "" {
|
||||
t.Skip("TEST_DATABASE_URL is not configured")
|
||||
}
|
||||
pool, err := pgxpool.New(context.Background(), databaseURL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sourceKey := fmt.Sprintf("growth-test-%d", time.Now().UnixNano())
|
||||
var merchantID int64
|
||||
if err := pool.QueryRow(context.Background(), `INSERT INTO merchants(source_key,category_id,category_title,display_name) VALUES($1,1,'test','test') RETURNING id`, sourceKey).Scan(&merchantID); err != nil {
|
||||
pool.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(context.Background(), `DELETE FROM growth_reports WHERE merchant_id=$1`, merchantID)
|
||||
_, _ = pool.Exec(context.Background(), `DELETE FROM merchants WHERE id=$1`, merchantID)
|
||||
pool.Close()
|
||||
})
|
||||
return pool, merchantID
|
||||
}
|
||||
12
internal/growthreport/service.go
Normal file
12
internal/growthreport/service.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package growthreport
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func RequestHash(merchantID int64, terminalID string, refresh bool) string {
|
||||
sum := sha256.Sum256([]byte(fmt.Sprintf("%d\x00%s\x00%t", merchantID, terminalID, refresh)))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
51
internal/growthreport/service_test.go
Normal file
51
internal/growthreport/service_test.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package growthreport
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRequestHashIsStableAndScopeSensitive(t *testing.T) {
|
||||
t.Parallel()
|
||||
a := RequestHash(7, "T-1", false)
|
||||
if a != RequestHash(7, "T-1", false) {
|
||||
t.Fatal("RequestHash() is not stable")
|
||||
}
|
||||
if a == RequestHash(7, "T-2", false) || a == RequestHash(7, "T-1", true) {
|
||||
t.Fatal("RequestHash() ignored request scope")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextTehranRefreshUsesCalendarBoundary(t *testing.T) {
|
||||
t.Parallel()
|
||||
now := time.Date(2026, 8, 21, 20, 0, 0, 0, time.UTC)
|
||||
next := NextTehranRefresh(now)
|
||||
location, _ := time.LoadLocation("Asia/Tehran")
|
||||
local := next.In(location)
|
||||
if local.Hour() != 0 || local.Minute() != 0 || !next.After(now) {
|
||||
t.Fatalf("NextTehranRefresh() = %s", next)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEstimatedTokenReservationCoversEveryRetryAndTurn(t *testing.T) {
|
||||
t.Parallel()
|
||||
small := EstimatedTokenReservation([]byte("{}"))
|
||||
large := EstimatedTokenReservation(make([]byte, 1000))
|
||||
if small < 4*1600 || large <= small || large != 44000 {
|
||||
t.Fatalf("reservations small=%d large=%d", small, large)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceFingerprintChangesWithDatasetAndConfiguration(t *testing.T) {
|
||||
t.Parallel()
|
||||
start := time.Date(2026, 8, 21, 0, 0, 0, 0, time.UTC)
|
||||
service := &Service{config: ServiceConfig{Model: "model-a", PromptVersion: "prompt-a", SchemaVersion: "schema-a"}}
|
||||
current := service.fingerprint(7, "T-1", "watermark-a", start)
|
||||
if current == service.fingerprint(7, "T-1", "watermark-b", start) {
|
||||
t.Fatal("dataset watermark did not invalidate fingerprint")
|
||||
}
|
||||
service.config.PromptVersion = "prompt-b"
|
||||
if current == service.fingerprint(7, "T-1", "watermark-a", start) {
|
||||
t.Fatal("prompt version did not invalidate fingerprint")
|
||||
}
|
||||
}
|
||||
12
internal/growthreport/time.go
Normal file
12
internal/growthreport/time.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package growthreport
|
||||
|
||||
import "time"
|
||||
|
||||
func NextTehranRefresh(now time.Time) time.Time {
|
||||
location, err := time.LoadLocation("Asia/Tehran")
|
||||
if err != nil {
|
||||
location = time.FixedZone("Asia/Tehran", 3*60*60+30*60)
|
||||
}
|
||||
local := now.In(location)
|
||||
return time.Date(local.Year(), local.Month(), local.Day()+1, 0, 0, 0, 0, location).UTC()
|
||||
}
|
||||
77
migrations/000002_growth_analyst.sql
Normal file
77
migrations/000002_growth_analyst.sql
Normal file
@@ -0,0 +1,77 @@
|
||||
CREATE TABLE IF NOT EXISTS growth_reports (
|
||||
id text PRIMARY KEY,
|
||||
merchant_id bigint NOT NULL REFERENCES merchants(id),
|
||||
terminal_id bigint REFERENCES terminals(id),
|
||||
terminal_source_key text,
|
||||
fingerprint text NOT NULL,
|
||||
revision integer NOT NULL DEFAULT 1 CHECK (revision > 0),
|
||||
status text NOT NULL CHECK (status IN ('QUEUED','ANALYZING','GENERATING','READY','PARTIAL','FAILED')),
|
||||
horizon_days integer NOT NULL DEFAULT 90 CHECK (horizon_days = 90),
|
||||
forecast_start_date date,
|
||||
dataset_watermark text NOT NULL,
|
||||
catalog_version text NOT NULL,
|
||||
analytics_version text NOT NULL,
|
||||
prompt_version text NOT NULL,
|
||||
schema_version text NOT NULL,
|
||||
model text NOT NULL,
|
||||
deterministic_evidence jsonb,
|
||||
ai_response jsonb,
|
||||
failure_code text,
|
||||
input_tokens bigint NOT NULL DEFAULT 0 CHECK (input_tokens >= 0),
|
||||
output_tokens bigint NOT NULL DEFAULT 0 CHECK (output_tokens >= 0),
|
||||
latency_ms bigint NOT NULL DEFAULT 0 CHECK (latency_ms >= 0),
|
||||
provider_requests bigint NOT NULL DEFAULT 0 CHECK (provider_requests >= 0),
|
||||
reserved_tokens bigint NOT NULL DEFAULT 0 CHECK (reserved_tokens >= 0),
|
||||
provider_request_id text,
|
||||
estimated_cost text,
|
||||
lease_token text,
|
||||
lease_expires_at timestamptz,
|
||||
manual_refresh boolean NOT NULL DEFAULT false,
|
||||
tehran_refresh_day date,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
completed_at timestamptz
|
||||
);
|
||||
-- statement-break
|
||||
ALTER TABLE growth_reports ADD COLUMN IF NOT EXISTS provider_request_id text;
|
||||
-- statement-break
|
||||
ALTER TABLE growth_reports ADD COLUMN IF NOT EXISTS estimated_cost text;
|
||||
-- statement-break
|
||||
ALTER TABLE growth_reports ADD COLUMN IF NOT EXISTS provider_requests bigint NOT NULL DEFAULT 0 CHECK (provider_requests >= 0);
|
||||
-- statement-break
|
||||
ALTER TABLE growth_reports ADD COLUMN IF NOT EXISTS reserved_tokens bigint NOT NULL DEFAULT 0 CHECK (reserved_tokens >= 0);
|
||||
-- statement-break
|
||||
ALTER TABLE growth_reports ADD COLUMN IF NOT EXISTS lease_token text;
|
||||
-- statement-break
|
||||
ALTER TABLE growth_reports ADD COLUMN IF NOT EXISTS lease_expires_at timestamptz;
|
||||
-- statement-break
|
||||
ALTER TABLE growth_reports ALTER COLUMN deterministic_evidence DROP NOT NULL;
|
||||
-- statement-break
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS growth_reports_current_fingerprint_idx
|
||||
ON growth_reports (merchant_id, fingerprint)
|
||||
WHERE manual_refresh = false AND status IN ('QUEUED','ANALYZING','GENERATING','READY','PARTIAL');
|
||||
-- statement-break
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS growth_reports_daily_merchant_refresh_idx
|
||||
ON growth_reports (merchant_id, tehran_refresh_day)
|
||||
WHERE manual_refresh = true;
|
||||
-- statement-break
|
||||
CREATE INDEX IF NOT EXISTS growth_reports_scope_latest_idx
|
||||
ON growth_reports (merchant_id, terminal_id, revision DESC, created_at DESC);
|
||||
-- statement-break
|
||||
CREATE INDEX IF NOT EXISTS growth_reports_pending_idx
|
||||
ON growth_reports (created_at)
|
||||
WHERE status IN ('QUEUED','ANALYZING','GENERATING');
|
||||
-- statement-break
|
||||
CREATE TABLE IF NOT EXISTS growth_report_idempotency (
|
||||
merchant_id bigint NOT NULL REFERENCES merchants(id),
|
||||
idempotency_key text NOT NULL,
|
||||
request_hash text NOT NULL,
|
||||
report_id text NOT NULL REFERENCES growth_reports(id) ON DELETE CASCADE,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (merchant_id, idempotency_key)
|
||||
);
|
||||
-- statement-break
|
||||
CREATE INDEX IF NOT EXISTS growth_report_idempotency_created_idx
|
||||
ON growth_report_idempotency (created_at);
|
||||
-- statement-break
|
||||
INSERT INTO schema_migrations(version) VALUES (2) ON CONFLICT DO NOTHING;
|
||||
10
tasks/ai-growth-analyst.md
Normal file
10
tasks/ai-growth-analyst.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# AI Growth Analyst Implementation Tasks
|
||||
|
||||
- [x] G1: Add approved spec, ADR, OpenAPI resources, event catalog, report schema, and reversible migration.
|
||||
- [x] G2: Add pure tested daily aggregation, event effect, similarity, 90-day forecast, residual ranges, backtest confidence, risk, and peer-suppression logic.
|
||||
- [x] G3: Add PostgreSQL report repository, fingerprint/idempotency, Redis hot cache/locks/budgets, and Tehran-day limits.
|
||||
- [x] G4: Add bounded worker state machine and sanitized `GrowthAnalyticsTools` boundary.
|
||||
- [x] G5: Add AvalAI Responses client, strict tool/final schemas, output validation, retry/timeout, usage metadata, and deterministic Persian fallbacks.
|
||||
- [x] G6: Add authenticated create/latest/get/SSE handlers with terminal ownership, CSRF, idempotency, replay, and `429` refresh semantics.
|
||||
- [x] G7: Wire configuration/composition roots, feature flag, local environment templates, retention cleanup, safe logs, and operational documentation.
|
||||
- [ ] G8: Run unit/integration/contract/security/race/vet/build verification and real local PostgreSQL/Redis/API smoke without sending an AvalAI request unless a user-provided key is configured.
|
||||
Reference in New Issue
Block a user