package models import ( "crypto/sha256" "encoding/hex" "encoding/json" "math" "time" "github.com/google/uuid" ) // AdvisorSeverity ranks how urgently a finding needs attention. It drives // ordering, the nav-tab badge tone, and whether a new finding is worth a toast. type AdvisorSeverity string const ( // AdvisorCritical means reputation or delivery is actively being damaged // right now (complaint/bounce rates in the documented hard-block bands, a // mailbox sending cold mail while quarantined from the warmup pool). AdvisorCritical AdvisorSeverity = "critical" // AdvisorHigh means a real problem that will become critical if ignored. AdvisorHigh AdvisorSeverity = "high" // AdvisorMedium means a meaningful improvement with no immediate risk. AdvisorMedium AdvisorSeverity = "medium" // AdvisorLow means a polish-level suggestion. AdvisorLow AdvisorSeverity = "low" ) // severityRank orders severities most-urgent-first for sorting and threshold // comparisons. var severityRank = map[AdvisorSeverity]int{ AdvisorCritical: 4, AdvisorHigh: 3, AdvisorMedium: 2, AdvisorLow: 1, } // Rank returns the numeric urgency of a severity (higher is more urgent, 0 for // an unknown value). func (s AdvisorSeverity) Rank() int { return severityRank[s] } // AtLeast reports whether s is at least as urgent as min. func (s AdvisorSeverity) AtLeast(min AdvisorSeverity) bool { return s.Rank() >= min.Rank() } // AdvisorCategory groups findings by the kind of problem they describe. Orgs // can mute a whole category. type AdvisorCategory string const ( AdvisorCategoryDeliverability AdvisorCategory = "deliverability" AdvisorCategoryMailbox AdvisorCategory = "mailbox" AdvisorCategoryWarmup AdvisorCategory = "warmup" AdvisorCategoryCampaign AdvisorCategory = "campaign" AdvisorCategoryCopy AdvisorCategory = "copy" AdvisorCategoryList AdvisorCategory = "list" ) // AdvisorSurface is the dashboard nav tab a finding belongs to. The badge count // and the inline strip both key off it, so advice always appears where the fix // is made rather than in a separate inbox. type AdvisorSurface string const ( AdvisorSurfaceCampaigns AdvisorSurface = "campaigns" AdvisorSurfaceMailboxes AdvisorSurface = "emails" AdvisorSurfaceDeliverability AdvisorSurface = "deliverability" AdvisorSurfaceContacts AdvisorSurface = "contacts" AdvisorSurfaceAnalytics AdvisorSurface = "analytics" AdvisorSurfaceSettings AdvisorSurface = "settings" ) // AdvisorStatus is a finding's lifecycle state. type AdvisorStatus string const ( // AdvisorStatusOpen is an active, unaddressed finding. AdvisorStatusOpen AdvisorStatus = "open" // AdvisorStatusSnoozed hides the finding until SnoozedUntil passes. The // detector keeps re-confirming it in the background. AdvisorStatusSnoozed AdvisorStatus = "snoozed" // AdvisorStatusDismissed means a member said this isn't a problem for them. // It stays dismissed until the underlying condition clears and recurs. AdvisorStatusDismissed AdvisorStatus = "dismissed" // AdvisorStatusApplied means the one-click fix ran. It stays in this state // until the next evaluation confirms the condition is gone (-> resolved) or // still present (-> reopened). AdvisorStatusApplied AdvisorStatus = "applied" // AdvisorStatusResolved means the detector no longer fires. AdvisorStatusResolved AdvisorStatus = "resolved" ) // AdvisorPreviewChange is one line of the before/after preview shown in the fix // drawer. Every one-click fix renders its full effect this way before the user // confirms; nothing is ever applied sight-unseen. type AdvisorPreviewChange struct { Field string `json:"field"` From string `json:"from"` To string `json:"to"` } // AdvisorAction is the one-click remedy attached to a finding. It executes // through the shared AI tool registry, which runs the tool AS the invoking user // with their permission bits enforced, so the Advisor can never apply a change // the member could not have made by hand. type AdvisorAction struct { // Tool is the aitools registry name (e.g. "update_mailbox"). Tool string `json:"tool"` // Args is the tool's JSON payload, fully materialized at detection time. Args json.RawMessage `json:"args"` // Label is the button text ("Lower the daily cap to 35"). Label string `json:"label"` // Auto marks a fix that autopilot may apply unattended. It is true only for // a bounded settings change that moves in the safe direction and can be // undone: lowering a cap, widening a gap, resuming warmup. Anything that // stops sending, edits copy, or cannot be reverted stays false and waits // for a person, however obvious the fix looks. Auto bool `json:"auto,omitempty"` // Preview is the exact before/after the drawer renders. Preview []AdvisorPreviewChange `json:"preview,omitempty"` // Undo, when set, is the tool call that reverts this one. Surfaced as // "Undo" on the applied card. Undo *AdvisorUndo `json:"undo,omitempty"` } // AdvisorUndo reverts an applied action. type AdvisorUndo struct { Tool string `json:"tool"` Args json.RawMessage `json:"args"` } // AdvisorFinding is one piece of advice about one entity. type AdvisorFinding struct { ID uuid.UUID `json:"id"` OrganizationID uuid.UUID `json:"organization_id"` Fingerprint string `json:"-"` DetectorKey string `json:"detector_key"` Category AdvisorCategory `json:"category"` Severity AdvisorSeverity `json:"severity"` Surface AdvisorSurface `json:"surface"` EntityType string `json:"entity_type,omitempty"` EntityID *uuid.UUID `json:"entity_id,omitempty"` EntityLabel string `json:"entity_label,omitempty"` // ParentType / ParentID name the entity this finding belongs to when it // differs from the subject: a step's copy problem belongs to its campaign, // and the campaign page is where someone goes looking for it. ParentType string `json:"parent_type,omitempty"` ParentID *uuid.UUID `json:"parent_id,omitempty"` Status AdvisorStatus `json:"status"` Impact int `json:"impact"` Title string `json:"title"` // GroupTitle names the finding when several of its kind are listed // together, with a {count} placeholder ("{count} mailboxes are capped above // the safe band"). Empty means this finding is always shown on its own. GroupTitle string `json:"group_title,omitempty"` Detail string `json:"detail"` Remedy string `json:"remedy"` // Steps is the ordered manual how-to, set only by checks with no one-click // fix. Empty means the remedy prose is the whole answer. Steps []string `json:"steps,omitempty"` // AgentFixable is true when an agent can resolve this by editing something // the platform owns. False for anything that lives outside it, like a DNS // record, so the client shows the steps rather than a button that cannot // succeed. Computed per request, never stored. AgentFixable bool `json:"agent_fixable"` // Snippets are the exact values to paste somewhere the platform cannot // reach: a DNS record, a CNAME target. Prose telling somebody to "add an // SPF record" is where most people stop; the record itself is the fix. Snippets []AdvisorSnippet `json:"snippets,omitempty"` // Narrated is false while the card still shows the built-in fallback copy // (AI unconfigured, or narration not run yet). The card is fully usable // either way; this only tells the client whether to offer "rewrite". Narrated bool `json:"narrated"` Evidence json.RawMessage `json:"evidence,omitempty"` Action *AdvisorAction `json:"action,omitempty"` FirstSeenAt time.Time `json:"first_seen_at"` LastSeenAt time.Time `json:"last_seen_at"` ResolvedAt *time.Time `json:"resolved_at,omitempty"` SnoozedUntil *time.Time `json:"snoozed_until,omitempty"` DismissedAt *time.Time `json:"dismissed_at,omitempty"` DismissReason string `json:"dismiss_reason,omitempty"` AppliedAt *time.Time `json:"applied_at,omitempty"` AppliedBy *uuid.UUID `json:"applied_by,omitempty"` AppliedResult string `json:"applied_result,omitempty"` } // EvidenceHash fingerprints the finding's evidence so a re-run can tell // "same problem, same numbers" (keep the narration) from "same problem, the // numbers moved" (re-narrate). Detectors round the values they put in Evidence // into bands, so ordinary drift does not churn the copy. func (f *AdvisorFinding) EvidenceHash() string { sum := sha256.Sum256(f.Evidence) return hex.EncodeToString(sum[:8]) } // AdvisorSurfaceCount is the per-nav-tab badge payload. type AdvisorSurfaceCount struct { Surface AdvisorSurface `json:"surface"` // Total is every open finding on this surface; Critical/High drive the // badge tone so a tab never screams about a low-severity nit. Total int `json:"total"` Critical int `json:"critical"` High int `json:"high"` } // AdvisorSummary is the org-wide rollup: what the badges show, plus a single // health score so the trend is legible at a glance. type AdvisorSummary struct { // Score is 100 minus the weighted cost of every open finding, floored at 0. Score int `json:"score"` Total int `json:"total"` Critical int `json:"critical"` High int `json:"high"` Medium int `json:"medium"` Low int `json:"low"` Surfaces []AdvisorSurfaceCount `json:"surfaces"` // LastRunAt is when the engine last evaluated this org (nil before the // first run). LastRunAt *time.Time `json:"last_run_at,omitempty"` } // advisorSeverityCost is how much each open finding weighs against the org's // Advisor score. Weighted so one critical finding matters more than a dozen // low-severity nits, which is the ordering a sender actually cares about. var advisorSeverityCost = map[AdvisorSeverity]float64{ AdvisorCritical: 25, AdvisorHigh: 10, AdvisorMedium: 4, AdvisorLow: 1, } // advisorScoreScale tunes how fast the score falls. A single critical finding // lands around 78; the curve then flattens, so the twentieth medium-severity // suggestion moves the number far less than the first critical one did. const advisorScoreScale = 100.0 // AdvisorScore folds open-finding counts into a 0-100 health score. // // The penalty saturates rather than accumulating linearly. A linear score hits // zero at around four high-severity findings, after which a workspace with four // problems and a workspace with forty look identical, and fixing three of them // changes nothing on screen. An exponential decay keeps the number honest // across the range a workspace actually lives in: it never reaches zero, and // every fix moves it until the tail flattens out. func AdvisorScore(critical, high, medium, low int) int { cost := float64(critical)*advisorSeverityCost[AdvisorCritical] + float64(high)*advisorSeverityCost[AdvisorHigh] + float64(medium)*advisorSeverityCost[AdvisorMedium] + float64(low)*advisorSeverityCost[AdvisorLow] if cost <= 0 { return 100 } score := int(math.Round(100 * math.Exp(-cost/advisorScoreScale))) if score < 1 { return 1 } return score } // AdvisorSettings holds the org's advisor controls. type AdvisorSettings struct { OrganizationID uuid.UUID `json:"organization_id"` Enabled bool `json:"enabled"` MutedCategories []string `json:"muted_categories"` MutedDetectors []string `json:"muted_detectors"` MinSeverity AdvisorSeverity `json:"min_severity"` // Autopilot applies auto-safe fixes on its own. Off by default. Autopilot bool `json:"autopilot"` // AutopilotActorID is the member autopilot acts as. Set automatically to // whoever switches it on; autopilot stops if they leave the org. AutopilotActorID *uuid.UUID `json:"autopilot_actor_id,omitempty"` UpdatedAt time.Time `json:"updated_at"` } // AutopilotMaxPerRun bounds how many fixes autopilot may apply in one // evaluation. A misconfiguration should cost a handful of reversible changes, // not a whole workspace rewritten while nobody was looking. const AutopilotMaxPerRun = 10 // DefaultAdvisorSettings is what an org gets before it has ever written a // settings row: everything on, nothing muted. func DefaultAdvisorSettings(orgID uuid.UUID) *AdvisorSettings { return &AdvisorSettings{ OrganizationID: orgID, Enabled: true, MutedCategories: []string{}, MutedDetectors: []string{}, MinSeverity: AdvisorLow, } } // AdvisorSnippet is one copy-pasteable value, rendered as a labelled field with // a copy button. Used for DNS records, where the whole difficulty is knowing // what to type. type AdvisorSnippet struct { // Label names the field in the DNS provider's own words ("Type", "Name", // "Value") or the thing being pasted. Label string `json:"label"` Value string `json:"value"` // Note is the one caveat that trips people up on this specific field, such // as a host some providers write as @ and others leave blank. Note string `json:"note,omitempty"` } // AdvisorAgentResult is what an agent fix reports back. type AdvisorAgentResult struct { FindingID uuid.UUID `json:"finding_id"` // Applied is true only when the agent called a tool that changed something. // A run that read the campaign and decided nothing was wrong reports false, // so the finding stays open. Applied bool `json:"applied"` // Summary is the agent's own account of what it did. Summary string `json:"summary"` // Steps are the tool calls it made, in order. This is the part the member // can check against the audit log. Steps []string `json:"steps,omitempty"` } // AdvisorFeedback is a member's verdict on one finding. type AdvisorFeedback struct { FindingID uuid.UUID `json:"finding_id"` Helpful bool `json:"helpful"` Reason string `json:"reason,omitempty"` } // AdvisorSnoozeRequest snoozes a finding for a bounded window. type AdvisorSnoozeRequest struct { // Days must be 1-90. Anything else is rejected: an unbounded snooze is a // dismissal wearing a disguise, and the two should stay distinguishable. Days int `json:"days" binding:"required,min=1,max=90"` } // AdvisorDismissRequest records why a member rejected the advice. The reason is // optional but feeds detector tuning. type AdvisorDismissRequest struct { Reason string `json:"reason"` }