fix: deliver validated AI insight responses

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

View File

@@ -7,6 +7,7 @@ import (
"time"
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/growth"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
@@ -27,12 +28,29 @@ type PeerEvidence struct {
}
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"`
Events []UpcomingEvent `json:"events"`
MerchantContext MerchantContext `json:"merchantContext"`
}
// UpcomingEvent is calendar metadata only. It deliberately contains no
// payment forecast, historical-performance, or peer-comparison fields.
type UpcomingEvent struct {
EvidenceID string `json:"evidenceId"`
EventID string `json:"eventId"`
EventTitleFA string `json:"eventTitleFa"`
Date time.Time `json:"date"`
EventType string `json:"eventType"`
OfficialHoliday bool `json:"officialHoliday"`
MerchantCategories []string `json:"merchantCategories"`
BehavioralChannels []string `json:"behavioralChannels"`
}
// MerchantContext is the minimal, non-identifying business profile provided to
// the model so it can rank only the reviewed calendar candidates.
type MerchantContext struct {
Category string `json:"category"`
Niches []string `json:"niches"`
SalesChannels []string `json:"salesChannels"`
}
type Analysis struct {
@@ -57,73 +75,131 @@ func NewPostgreSQLBuilder(pool *pgxpool.Pool) *PostgreSQLBuilder {
}
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 := tehranToday(b.now())
peer, err := b.loadPeer(ctx, merchantID, terminalID)
merchantContext, err := b.loadMerchantContext(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}
catalog, err := b.loadCatalog(ctx, merchantContext.terms(), forecastStart, 5)
if err != nil {
return Analysis{}, err
}
events := upcomingEvents(catalog, forecastStart, 5)
evidence := Evidence{Events: events, MerchantContext: merchantContext}
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 _, event := range evidence.Events {
known[event.EvidenceID] = struct{}{}
}
for _, opportunity := range evidence.Opportunities {
known[opportunity.EvidenceID] = struct{}{}
return Analysis{Evidence: evidence, Encoded: encoded, KnownEvidence: known, Fallback: AICopy{EventRecommendations: fallbackEventRecommendations(events)}, ShouldCallAI: len(events) == 5}, nil
}
func upcomingEvents(catalog []growth.Event, start time.Time, limit int) []UpcomingEvent {
result := make([]UpcomingEvent, 0, limit)
for _, event := range catalog {
if event.StartDate.Before(start) {
continue
}
result = append(result, UpcomingEvent{
EvidenceID: "event:" + event.ID,
EventID: event.ID,
EventTitleFA: event.TitleFA,
Date: event.StartDate,
EventType: event.Family,
OfficialHoliday: event.OfficialHoliday,
MerchantCategories: append([]string(nil), event.Categories...),
BehavioralChannels: append([]string(nil), event.Tags...),
})
if len(result) == limit {
break
}
}
for _, risk := range evidence.Risks {
known[risk.EvidenceID] = struct{}{}
return result
}
func fallbackEventRecommendations(events []UpcomingEvent) []EventRecommendation {
limit := min(len(events), 3)
result := make([]EventRecommendation, 0, limit)
for _, event := range events[:limit] {
result = append(result, EventRecommendation{EvidenceID: event.EvidenceID, RelevanceReason: "این مناسبت در تقویم بررسی‌شده به‌عنوان رویدادی مرتبط با کسب‌وکار ثبت شده است."})
}
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
return result
}
func (b *PostgreSQLBuilder) loadMerchantContext(ctx context.Context, merchantID int64, terminalID string) (MerchantContext, error) {
var context MerchantContext
if err := b.pool.QueryRow(ctx, `SELECT category_title FROM merchants WHERE id=$1`, merchantID).Scan(&context.Category); err != nil {
return MerchantContext{}, fmt.Errorf("loading merchant category: %w", err)
}
var profileNiches, profileChannels []byte
profileErr := b.pool.QueryRow(ctx, `SELECT p.niches,p.sales_channels FROM growth_coach_profiles p
JOIN terminals t ON t.id=p.terminal_id
WHERE p.merchant_id=$1 AND ($2='' OR t.source_key=$2)
ORDER BY p.version DESC LIMIT 1`, merchantID, terminalID).Scan(&profileNiches, &profileChannels)
if profileErr == nil {
_ = json.Unmarshal(profileNiches, &context.Niches)
_ = json.Unmarshal(profileChannels, &context.SalesChannels)
} else if profileErr != pgx.ErrNoRows {
return MerchantContext{}, fmt.Errorf("loading growth event profile: %w", profileErr)
}
if context.Niches == nil {
context.Niches = []string{}
}
if context.SalesChannels == nil {
context.SalesChannels = []string{}
}
return context, nil
}
func (c MerchantContext) terms() []string {
terms := make([]string, 0, 1+len(c.Niches)+len(c.SalesChannels))
terms = append(terms, c.Category)
terms = append(terms, c.Niches...)
return append(terms, c.SalesChannels...)
}
func (b *PostgreSQLBuilder) loadCatalog(ctx context.Context, terms []string, from time.Time, limit int) ([]growth.Event, error) {
rows, err := b.pool.Query(ctx, `SELECT e.event_key,e.title_fa,e.start_date,e.end_date,e.event_type,e.official_holiday,
e.source_uri,e.source_version,e.merchant_categories,e.behavioral_channels
FROM calendar_events e JOIN calendar_catalog_versions v ON v.id=e.catalog_version_id AND v.status='ACTIVE'
WHERE e.start_date >= $1
AND (e.merchant_categories ?| $2::text[] OR e.behavioral_channels ?| $2::text[]
OR (e.event_type IN('COMMERCIAL','SEASONAL','EDUCATION','ECONOMIC') AND e.relevance_bps>=7000)
OR (e.official_holiday AND e.relevance_bps>=7000))
ORDER BY e.start_date,e.end_date,e.event_key LIMIT $3`, from, terms, limit)
if err != nil {
return nil, fmt.Errorf("loading reviewed growth event catalog: %w", err)
}
defer rows.Close()
var catalog []growth.Event
for rows.Next() {
var event growth.Event
var eventType string
var categoriesJSON, channelsJSON []byte
if err := rows.Scan(&event.ID, &event.TitleFA, &event.StartDate, &event.EndDate, &eventType, &event.OfficialHoliday, &event.Source, &event.SourceVersion, &categoriesJSON, &channelsJSON); err != nil {
return nil, fmt.Errorf("scanning reviewed growth event: %w", err)
}
_ = json.Unmarshal(categoriesJSON, &event.Categories)
_ = json.Unmarshal(channelsJSON, &event.Tags)
event.Family = eventType
event.Universal = len(event.Categories) == 0
if eventType == "COMMERCIAL" || eventType == "SEASONAL" || eventType == "EDUCATION" || eventType == "ECONOMIC" {
event.Kind = growth.EventCommercial
} else {
event.Kind = growth.EventOfficial
}
catalog = append(catalog, event)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterating reviewed growth events: %w", err)
}
if err := growth.ValidateCatalog(catalog); err != nil {
return nil, fmt.Errorf("validating reviewed growth event catalog: %w", err)
}
return catalog, nil
}
func (b *PostgreSQLBuilder) loadPeer(ctx context.Context, merchantID int64, terminalID string) (growth.PeerInput, error) {
@@ -177,19 +253,11 @@ func (t EvidenceTools) Execute(_ context.Context, name string, arguments json.Ra
}
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)})
return json.Marshal(map[string]any{"mode": "EVENTS_ONLY", "merchantContext": t.analysis.Evidence.MerchantContext, "eventEvidenceIds": upcomingEventEvidenceIDs(t.analysis.Evidence.Events)})
case "get_peer_context":
if t.analysis.Evidence.Peer == nil {
return []byte(`{"state":"SUPPRESSED"}`), nil
}
return json.Marshal(t.analysis.Evidence.Peer)
return []byte(`{"state":"UNAVAILABLE","reason":"EVENTS_ONLY"}`), nil
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 {
for _, item := range t.analysis.Evidence.Events {
if item.EvidenceID == args.EvidenceID {
return json.Marshal(item)
}
@@ -205,14 +273,7 @@ func (t EvidenceTools) Execute(_ context.Context, name string, arguments json.Ra
}
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 {
func upcomingEventEvidenceIDs(items []UpcomingEvent) []string {
result := make([]string, 0, len(items))
for _, item := range items {
result = append(result, item.EvidenceID)

View File

@@ -0,0 +1,49 @@
package growthreport
import (
"testing"
"time"
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/growth"
)
func TestUpcomingEventsKeepsFiveChronologicalEligibleCandidates(t *testing.T) {
t.Parallel()
start := time.Date(2026, time.August, 22, 0, 0, 0, 0, time.UTC)
catalog := []growth.Event{
{ID: "past", TitleFA: "رویداد گذشته", StartDate: start.AddDate(0, 0, -1)},
{ID: "one", TitleFA: "رویداد اول", StartDate: start},
{ID: "two", TitleFA: "رویداد دوم", StartDate: start.AddDate(0, 0, 40)},
{ID: "three", TitleFA: "رویداد سوم", StartDate: start.AddDate(0, 0, 100)},
{ID: "four", TitleFA: "رویداد چهارم", StartDate: start.AddDate(0, 0, 190)},
{ID: "five", TitleFA: "رویداد پنجم", StartDate: start.AddDate(0, 0, 280), OfficialHoliday: true},
{ID: "six", TitleFA: "رویداد ششم", StartDate: start.AddDate(0, 0, 320)},
}
got := upcomingEvents(catalog, start, 5)
if len(got) != 5 {
t.Fatalf("upcomingEvents() returned %d events, want 5", len(got))
}
for index, want := range []string{"event:one", "event:two", "event:three", "event:four", "event:five"} {
if got[index].EvidenceID != want {
t.Fatalf("upcomingEvents()[%d] = %q, want %q", index, got[index].EvidenceID, want)
}
}
if !got[4].OfficialHoliday {
t.Fatal("eligible official holiday was removed from the candidate set")
}
}
func TestFallbackEventRecommendationsUseTheFirstThreeCandidates(t *testing.T) {
t.Parallel()
events := []UpcomingEvent{{EvidenceID: "event:first"}, {EvidenceID: "event:second"}, {EvidenceID: "event:third"}, {EvidenceID: "event:fourth"}}
got := fallbackEventRecommendations(events)
if len(got) != 3 || got[0].EvidenceID != "event:first" || got[2].EvidenceID != "event:third" {
t.Fatalf("fallbackEventRecommendations() = %+v", got)
}
for _, item := range got {
if item.RelevanceReason == "" {
t.Fatal("fallback recommendation lacks a relevance reason")
}
}
}

View File

@@ -42,6 +42,7 @@ func NewAvalAIClient(config AvalAIConfig, client *http.Client) *AvalAIClient {
if client == nil {
client = &http.Client{}
}
config.BaseURL = strings.TrimSuffix(strings.TrimRight(config.BaseURL, "/"), "/v1")
return &AvalAIClient{config: config, client: client}
}
@@ -56,7 +57,7 @@ func (c *AvalAIClient) GenerateWithTools(ctx context.Context, evidence json.RawM
base := map[string]any{
"model": c.config.Model, "store": false, "parallel_tool_calls": false,
"max_output_tokens": 1600, "max_tool_calls": 4,
"instructions": "شما تحلیل‌گر رشد فارسی هستید. فقط بر اساس شواهد ارائه‌شده پیشنهاد کیفی بدهید. هیچ عدد، تاریخ یا ادعای تازه‌ای ننویسید. شناسه شواهد را دقیق ارجاع دهید.",
"instructions": "شما تحلیل‌گر رشد فارسی هستید. فقط از رویدادهای بررسی‌شده در شواهد ارائه‌شده، دقیقاً سه رویداد متفاوت را به‌ترتیب ارتباط با کسب‌وکار انتخاب کنید. برای هر رویداد یک دلیل کوتاه فارسی و کیفی بنویسید. هیچ عدد، تاریخ یا ادعای تازه‌ای ننویسید و شناسه شواهد را دقیق ارجاع دهید.",
"tools": growthTools(),
"text": map[string]any{"format": map[string]any{"type": "json_schema", "name": "growth_copy", "strict": true, "schema": aiCopySchema()}},
}
@@ -297,50 +298,43 @@ func allowedTool(name string) bool {
}
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")
if len(copy.EventRecommendations) != 3 {
return errors.New("avalai: invalid event recommendation 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")
}
}
seen := make(map[string]struct{}, len(copy.EventRecommendations))
for _, recommendation := range copy.EventRecommendations {
if _, ok := known[recommendation.EvidenceID]; !ok {
return errors.New("avalai: unknown event evidence reference")
}
if brief.Headline == "" || len(brief.EvidenceIDs) == 0 {
return errors.New("avalai: ungrounded brief")
if _, duplicate := seen[recommendation.EvidenceID]; duplicate {
return errors.New("avalai: duplicate event evidence reference")
}
for _, id := range brief.EvidenceIDs {
if _, ok := known[id]; !ok {
return errors.New("avalai: unknown evidence reference")
}
seen[recommendation.EvidenceID] = struct{}{}
if !validPersianReason(recommendation.RelevanceReason) {
return errors.New("avalai: invalid event relevance reason")
}
}
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": {},
"صفر": {}, "یک": {}, "دو": {}, "سه": {}, "چهار": {}, "پنج": {}, "شش": {}, "هفت": {}, "هشت": {}, "نه": {}, "ده": {}, "یازده": {}, "دوازده": {}, "سیزده": {}, "چهارده": {}, "پانزده": {}, "شانزده": {}, "هفده": {}, "هجده": {}, "نوزده": {}, "بیست": {}, "سی": {}, "چهل": {}, "پنجاه": {}, "شصت": {}, "هفتاد": {}, "هشتاد": {}, "نود": {}, "صد": {}, "هزار": {}, "میلیون": {}, "میلیارد": {}, "درصد": {}, "برابر": {}, "نصف": {},
func validPersianReason(value string) bool {
value = strings.TrimSpace(value)
if len(value) == 0 || len(value) > 320 {
return false
}
for _, word := range strings.Fields(strings.ToLower(value)) {
word = strings.Trim(word, ".,;:!?،؛؟٪%()[]{}«»\"'")
if _, found := quantityWords[word]; found {
for _, r := range value {
if unicode.IsDigit(r) {
return false
}
}
for _, word := range strings.Fields(value) {
switch strings.Trim(word, ".,;:!?،؛؟٪%()[]{}«»\\\"'") {
case "صفر", "یک", "دو", "سه", "چهار", "پنج", "شش", "هفت", "هشت", "نه", "ده", "یازده", "دوازده", "سیزده", "چهارده", "پانزده", "بیست", "سی", "چهل", "پنجاه", "شصت", "هفتاد", "هشتاد", "نود", "صد", "هزار", "میلیون", "میلیارد", "درصد":
return false
}
}
for _, r := range value {
if r >= '\u0600' && r <= '\u06ff' && unicode.IsLetter(r) {
return true
}
}
@@ -363,10 +357,9 @@ func growthTools() []map[string]any {
}
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"}
recommendationProperties := map[string]any{
"evidenceId": map[string]any{"type": "string", "minLength": 1, "maxLength": 128},
"relevanceReason": map[string]any{"type": "string", "minLength": 1, "maxLength": 320},
}
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}
return map[string]any{"type": "object", "properties": map[string]any{"eventRecommendations": map[string]any{"type": "array", "minItems": 3, "maxItems": 3, "items": map[string]any{"type": "object", "properties": recommendationProperties, "required": []string{"evidenceId", "relevanceReason"}, "additionalProperties": false}}}, "required": []string{"eventRecommendations"}, "additionalProperties": false}
}

View File

@@ -22,12 +22,12 @@ func TestAvalAICountsFailedRetryRequestsAndUsage(t *testing.T) {
_, _ = 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"}`))
_, _ = w.Write([]byte(`{"status":"completed","output":[{"type":"message","content":[{"type":"output_text","text":"{\"eventRecommendations\":[{\"evidenceId\":\"event_teacher\",\"relevanceReason\":\"این مناسبت با الگوی فروش کسب‌وکار هم‌خوان است.\"},{\"evidenceId\":\"event_yalda\",\"relevanceReason\":\"این رویداد با کانال فروش شما مرتبط است.\"},{\"evidenceId\":\"event_nowruz\",\"relevanceReason\":\"این بازه برای برنامه‌ریزی عرضه مناسب است.\"}]}"}]}],"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": {}})
_, usage, err := client.Generate(context.Background(), json.RawMessage(`{"events":[]}`), map[string]struct{}{"event_teacher": {}, "event_yalda": {}, "event_nowruz": {}})
if err != nil {
t.Fatal(err)
}
@@ -36,25 +36,25 @@ func TestAvalAICountsFailedRetryRequestsAndUsage(t *testing.T) {
}
}
func TestValidateAICopyRejectsNumbersAndUnknownEvidence(t *testing.T) {
func TestValidateAICopyAcceptsExactlyThreeGroundedPersianRecommendations(t *testing.T) {
t.Parallel()
tests := []struct {
name string
copy AICopy
want bool
name string
copy AICopy
wantErr 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},
{name: "grounded Persian recommendations", copy: AICopy{EventRecommendations: []EventRecommendation{{EvidenceID: "event_teacher", RelevanceReason: "این مناسبت با الگوی فروش کسب‌وکار هم‌خوان است."}, {EvidenceID: "event_yalda", RelevanceReason: "این رویداد با کانال فروش شما مرتبط است."}, {EvidenceID: "event_nowruz", RelevanceReason: "این بازه برای برنامه‌ریزی عرضه مناسب است."}}}},
{name: "numeric claim", copy: AICopy{EventRecommendations: []EventRecommendation{{EvidenceID: "event_teacher", RelevanceReason: "رشد ۲۰ درصدی پیش‌بینی می‌شود."}, {EvidenceID: "event_yalda", RelevanceReason: "این رویداد با کانال فروش شما مرتبط است."}, {EvidenceID: "event_nowruz", RelevanceReason: "این بازه برای برنامه‌ریزی عرضه مناسب است."}}}, wantErr: true},
{name: "duplicate evidence", copy: AICopy{EventRecommendations: []EventRecommendation{{EvidenceID: "event_teacher", RelevanceReason: "این مناسبت با الگوی فروش کسب‌وکار هم‌خوان است."}, {EvidenceID: "event_teacher", RelevanceReason: "این رویداد با کانال فروش شما مرتبط است."}, {EvidenceID: "event_nowruz", RelevanceReason: "این بازه برای برنامه‌ریزی عرضه مناسب است."}}}, wantErr: true},
{name: "unknown evidence", copy: AICopy{EventRecommendations: []EventRecommendation{{EvidenceID: "event_teacher", RelevanceReason: "این مناسبت با الگوی فروش کسب‌وکار هم‌خوان است."}, {EvidenceID: "event_yalda", RelevanceReason: "این رویداد با کانال فروش شما مرتبط است."}, {EvidenceID: "unknown", RelevanceReason: "این بازه برای برنامه‌ریزی عرضه مناسب است."}}}, wantErr: true},
{name: "not exactly three", copy: AICopy{EventRecommendations: []EventRecommendation{{EvidenceID: "event_teacher", RelevanceReason: "این مناسبت با الگوی فروش کسب‌وکار هم‌خوان است."}}}, wantErr: true},
}
known := map[string]struct{}{"event_teacher": {}}
known := map[string]struct{}{"event_teacher": {}, "event_yalda": {}, "event_nowruz": {}}
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)
if (err != nil) != tt.wantErr {
t.Fatalf("ValidateAICopy() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
@@ -71,16 +71,16 @@ func TestAvalAIClientUsesBoundedStatelessRequest(t *testing.T) {
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}}`))
_, _ = w.Write([]byte(`{"status":"completed","output":[{"type":"message","content":[{"type":"output_text","text":"{\"eventRecommendations\":[{\"evidenceId\":\"event_teacher\",\"relevanceReason\":\"این مناسبت با الگوی فروش کسب‌وکار هم‌خوان است.\"},{\"evidenceId\":\"event_yalda\",\"relevanceReason\":\"این رویداد با کانال فروش شما مرتبط است.\"},{\"evidenceId\":\"event_nowruz\",\"relevanceReason\":\"این بازه برای برنامه‌ریزی عرضه مناسب است.\"}]}"}]}],"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": {}})
copy, usage, err := client.Generate(context.Background(), json.RawMessage(`{"events":[{"evidenceId":"event_teacher"}]}`), map[string]struct{}{"event_teacher": {}, "event_yalda": {}, "event_nowruz": {}})
if err != nil {
t.Fatal(err)
}
if len(copy.Briefs) != 1 || usage.InputTokens != 10 || usage.OutputTokens != 20 {
if len(copy.EventRecommendations) != 3 || usage.InputTokens != 10 || usage.OutputTokens != 20 {
t.Fatalf("Generate() = %+v, %+v", copy, usage)
}
if request["store"] != false || request["parallel_tool_calls"] != false {
@@ -116,12 +116,12 @@ func TestAvalAIClientExecutesAllowlistedToolThenStopsAfterSecondTurn(t *testing.
_, _ = 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}}`))
_, _ = w.Write([]byte(`{"status":"completed","output":[{"type":"message","content":[{"type":"output_text","text":"{\"eventRecommendations\":[{\"evidenceId\":\"event_teacher\",\"relevanceReason\":\"این مناسبت با الگوی فروش کسب‌وکار هم‌خوان است.\"},{\"evidenceId\":\"event_yalda\",\"relevanceReason\":\"این رویداد با کانال فروش شما مرتبط است.\"},{\"evidenceId\":\"event_nowruz\",\"relevanceReason\":\"این بازه برای برنامه‌ریزی عرضه مناسب است.\"}]}"}]}],"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)
_, usage, err := client.GenerateWithTools(context.Background(), json.RawMessage(`{}`), map[string]struct{}{"event_teacher": {}, "event_yalda": {}, "event_nowruz": {}}, executor)
if err != nil {
t.Fatal(err)
}

View File

@@ -33,8 +33,14 @@ type CampaignBrief struct {
EvidenceIDs []string `json:"evidenceIds"`
}
type EventRecommendation struct {
EvidenceID string `json:"evidenceId"`
RelevanceReason string `json:"relevanceReason"`
}
type AICopy struct {
Briefs []CampaignBrief `json:"briefs"`
EventRecommendations []EventRecommendation `json:"eventRecommendations"`
Briefs []CampaignBrief `json:"briefs,omitempty"`
}
type Usage struct {
@@ -53,7 +59,7 @@ type Report struct {
Status Status `json:"status"`
Scope Scope `json:"scope"`
HorizonDays int `json:"horizonDays"`
ForecastStartDate *time.Time `json:"forecastStartDate,omitempty"`
ForecastStartDate *time.Time `json:"-"`
DeterministicEvidence json.RawMessage `json:"deterministicEvidence,omitempty"`
AICopy *AICopy `json:"aiCopy,omitempty"`
FailureCode *string `json:"failureCode,omitempty"`

View File

@@ -50,14 +50,18 @@ func (s *Service) Create(ctx context.Context, userID, merchantID int64, request
if err != nil {
return Record{}, false, err
}
catalogVersion, err := s.repository.ActiveCatalogVersion(ctx)
if err != nil {
return Record{}, false, err
}
start := tehranToday(now)
fingerprint := s.fingerprint(merchantID, request.TerminalID, watermark, start)
fingerprint := s.fingerprint(merchantID, request.TerminalID, watermark, catalogVersion, 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 {
record, cached, err := s.repository.CreateOrReuse(ctx, CreateRecord{ID: newReportID(), MerchantID: merchantID, TerminalID: request.TerminalID, Fingerprint: fingerprint, DatasetWatermark: watermark, ForecastStartDate: &start, CatalogVersion: 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 {
if !hasAISummary {
return nil
}
@@ -97,15 +101,20 @@ func (s *Service) Latest(ctx context.Context, merchantID int64, terminalID strin
record.Stale = true
} else {
start := tehranToday(s.now())
record.Stale = record.Fingerprint != s.fingerprint(merchantID, terminalID, watermark, start)
catalogVersion, catalogErr := s.repository.ActiveCatalogVersion(ctx)
if catalogErr != nil {
record.Stale = true
} else {
record.Stale = record.Fingerprint != s.fingerprint(merchantID, terminalID, watermark, catalogVersion, start)
}
}
record.NextRefreshAt, _ = s.repository.NextRefreshAt(ctx, merchantID, terminalID, growth.AnalyticsVersion, 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})
func (s *Service) fingerprint(merchantID int64, terminalID, watermark, catalogVersion string, start time.Time) string {
return growth.Fingerprint(growth.FingerprintInput{MerchantID: merchantID, TerminalID: terminalID, DatasetWatermark: watermark, ForecastStart: start, HorizonDays: 90, CatalogVersion: catalogVersion, AnalyticsVersion: growth.AnalyticsVersion, PromptVersion: s.config.PromptVersion, SchemaVersion: s.config.SchemaVersion, Model: s.config.Model, ModelConfigVersion: growth.DefaultModelConfigVersion})
}
type Processor struct {
@@ -138,6 +147,11 @@ func (p *Processor) ProcessNext(ctx context.Context) error {
return err
}
copy := analysis.Fallback
if !analysis.ShouldCallAI {
status, failure := deterministicCompletion()
slog.Info("growth deterministic analysis complete", "report_id", record.ReportID, "reason", "NO_ACTIONABLE_SIGNALS")
return p.repository.CompleteClaimed(ctx, record.ReportID, record.ClaimToken, status, analysis.Encoded, &copy, Usage{}, failure)
}
status := StatusPartial
failure := "AI_DISABLED_OR_UNAVAILABLE"
if analysis.ShouldCallAI && p.aiEnabled && p.avalai != nil {
@@ -164,12 +178,16 @@ func (p *Processor) ProcessNext(ctx context.Context) error {
slog.Warn("growth AI skipped", "report_id", record.ReportID, "reason", failure)
}
}
if !analysis.ShouldCallAI || !p.aiEnabled {
if !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, &copy, Usage{}, failure)
}
func deterministicCompletion() (Status, string) {
return StatusReady, ""
}
// 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

View File

@@ -24,6 +24,14 @@ const reportLeaseDuration = 3 * time.Minute
func NewRepository(pool *pgxpool.Pool) *Repository { return &Repository{pool: pool} }
func (r *Repository) ActiveCatalogVersion(ctx context.Context) (string, error) {
var version string
if err := r.pool.QueryRow(ctx, `SELECT version FROM calendar_catalog_versions WHERE status='ACTIVE'`).Scan(&version); err != nil {
return "", fmt.Errorf("loading active event catalog version: %w", err)
}
return version, nil
}
type CreateRecord struct {
ID string
MerchantID int64

View File

@@ -46,16 +46,28 @@ func TestEstimatedTokenReservationCoversEveryRetryAndTurn(t *testing.T) {
}
}
func TestDeterministicOnlyCompletionIsReady(t *testing.T) {
t.Parallel()
status, failure := deterministicCompletion()
if status != StatusReady || failure != "" {
t.Fatalf("deterministicCompletion() = (%q, %q), want (%q, empty)", status, failure, StatusReady)
}
}
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) {
current := service.fingerprint(7, "T-1", "watermark-a", "catalog-a", start)
if current == service.fingerprint(7, "T-1", "watermark-b", "catalog-a", start) {
t.Fatal("dataset watermark did not invalidate fingerprint")
}
if current == service.fingerprint(7, "T-1", "watermark-a", "catalog-b", start) {
t.Fatal("catalog version did not invalidate fingerprint")
}
service.config.PromptVersion = "prompt-b"
if current == service.fingerprint(7, "T-1", "watermark-a", start) {
if current == service.fingerprint(7, "T-1", "watermark-a", "catalog-a", start) {
t.Fatal("prompt version did not invalidate fingerprint")
}
}