mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-21 00:02:19 +00:00
* feat: index advisor findings by subject and parent entity so a list page fetches its whole surface once and every row resolves its own advice from the shared cache instead of firing a request per row * feat: rebuild the advisor fix drawer as a three-screen resolution flow (why it fired with the measured evidence, the exact before and after, then an animated outcome with undo) with a progress rail and direction-aware transitions, and deep-link manual fixes to the screen where they are made * feat: add AdvisorRowFlag, the inline per-row advisor indicator that renders on the mailbox or campaign the problem is about and opens that row's findings in an anchored panel instead of making the reader join a card list against a table * feat: add AdvisorSummaryBar, a one-line collapsible page summary that replaces the stack of advisor cards above a list, counts the distinct rows implicated rather than the findings, and forces itself open only for critical or workspace-level advice no row flag can carry * feat: put advisor advice on the mailbox row it is about in the accounts list, replace the card stack above the table with the collapsible summary bar, and support ?mailbox=<id> so a finding can deep-link straight to the mailbox detail instead of the top of the list * feat: flag advisor findings on the campaign row in the campaigns list, including step-level copy problems which index onto their parent campaign since a step has no row of its own, and add the collapsible summary bar above the list * feat: move the deliverability and contacts pages onto the collapsible advisor summary bar so their findings stop pushing the numbers they describe below the fold * feat: add an ordered Steps field to advisor findings, persisted as text[] and always refreshed from the current build, and write real how-to steps for the deliverability checks that have no one-click fix (bounce rate, spam placement, tracking domain, and per-record SPF/DKIM/DMARC instructions) * feat: write ordered how-to steps for the manual advisor findings where the remedy alone leaves someone stuck (broken template syntax, missing first-name fallback, unsubscribed contacts still enrolled, a campaign with no resolvable sender, and a mailbox that lost warmup pool standing) and correct the personalization detail that named a merge syntax this product does not use * feat: show a mailbox's advisor findings at the top of its detail drawer, which is where both the row flag and the ?mailbox deep link now land * feat: open the resolution flow from findings that have no one-click fix too, since the ordered how-to lives there and a card with no Fix button previously left the steps unreachable * docs: document the per-row advisor flags, the collapsible page summary, the three-screen resolution flow, and the ordered manual steps for findings with no one-click fix * feat: align the advisor summary bar to the px-5 page gutter used by SectionBar and the list rows on all four surfaces, instead of sitting flush against the edge while the table it describes is indented * fix: stop the resolution drawer collapsing to zero height between screens by switching the step transition to popLayout with a layout-animated container, so the dialog resizes into the next screen instead of snapping shut and reopening * feat: wire the advisor repository, narrator, service, tool registration, and background runner into the backend boot path so findings evaluate on a schedule and the assistant can read them * docs: register the advisor guide in the sidebar, add its endpoint scope table to the API reference, and document the sandbox advisor showcase * fix: darken the advisor nav badge to solid orange-600 on white instead of a pale amber-100 chip that read as a disabled control beside the sidebar's saturated indicators, and drop the critical badge to rose-600 so the two stay in the same weight class * fix: use orange-500 for the advisor nav badge, matching the high-severity dot on the row it points at, rather than the darker orange-600 * feat: add an Auto safety class to advisor actions and mark the seven fixes autopilot may apply unattended (the cap cuts, the send-gap widen, the campaign limit matches, and the unsubscribe header), with a test pinning the boundary so nothing that halts sending or generates new outbound mail can drift into it * feat: add advisor autopilot, which applies the auto-safe fixes unattended as the member who switched it on, resolving their live permissions each run so it fails closed when they leave the org, bounded to 10 changes per evaluation and audited per fix like any hand-made change * feat: add the advisor agent fix, a bounded per-finding agent run that resolves the problems a settings change cannot (broken template syntax, bulk-reading copy, shared-inbox lists) as the calling member inside a tool allowlist scoped to the finding's category, metered per iteration and marked applied only when it actually called a write tool * feat: surface autopilot and the agent fix in the dashboard, adding the workspace toggle that names exactly which changes it may make, an Auto chip on the findings it is allowed to take, and an agent-fix path in the resolution drawer that reports the tools it actually called rather than only its own account of them * docs: document the agent fix and autopilot, naming the exact set of changes autopilot may make, that it acts as the member who enabled it and stops when they leave, and why the agent-fix endpoint is JWT only * fix: gate the agent fix per detector instead of per category, so a missing DMARC record no longer offers a Fix-with-agent button it can never satisfy and then reports failure; findings whose fix lives in DNS or a provider console now show their manual steps, and the client is told which is which via agent_fixable * feat: soften the advisor surfaces to translucent washes, replacing the filled nav badge with a tinted pill that carries its colour in the text, frosting the row panel and the resolution drawer, and turning the severity chips and cards into layers the page shows through * docs: correct the agent-fix scope to name the findings it cannot resolve, and why a DNS record shows steps instead of a button * feat: ship the actual DNS records for the findings that live outside the platform, with the provider's SPF include resolved, the DMARC record scoped to the sending domain and starting at p=none, the DKIM host plus the console that generates its value, and a tracking CNAME pointing at this install's own tracking host * feat: render advisor snippets as labelled copy-button rows so a DNS record is one click per field rather than a text-selection exercise, with no copy affordance on a value the server could not supply * docs: document the pasteable DNS records and the guarantee that every check offers a fix, an agent, or ordered steps * fix: bump golang.org/x/text to 0.39.0 to clear CVE-2026-56852, a HIGH-severity infinite loop in norm.Iter that Trivy started failing the security scan on
247 lines
9.3 KiB
Go
247 lines
9.3 KiB
Go
package advisor
|
||
|
||
import (
|
||
"context"
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"fmt"
|
||
"strings"
|
||
|
||
"github.com/google/uuid"
|
||
|
||
"github.com/warmbly/warmbly/internal/models"
|
||
"github.com/warmbly/warmbly/internal/pkg/generation"
|
||
)
|
||
|
||
// Narrator rewrites a finding's card copy from the evidence the detector
|
||
// already computed.
|
||
//
|
||
// It is strictly a rewriter. It cannot create, suppress, reclassify, or
|
||
// re-severity a finding, and it never sees anything except the detector's own
|
||
// description and evidence map. That boundary is what makes it safe to leave on
|
||
// by default: the worst a bad completion can do is produce a clumsy sentence,
|
||
// never wrong advice.
|
||
//
|
||
// Narration is free to the org. Detection is pure Go and always runs, the
|
||
// results are cached per (detector, evidence shape), and every finding ships
|
||
// with complete deterministic copy already, so an unconfigured provider, an
|
||
// empty credit balance, or a provider outage all degrade to "the cards read
|
||
// slightly plainer" rather than "the Advisor stopped working".
|
||
type Narrator struct {
|
||
provider generation.Provider
|
||
// about grounds each rewrite in what the detector actually looks for, so
|
||
// the copy explains the underlying rule instead of restating the numbers.
|
||
about map[string]string
|
||
// voice is the org's writing grounding (product, ICP, house voice), used
|
||
// only so the advice sounds like it belongs in their workspace.
|
||
voice VoiceSource
|
||
cache NarrationCache
|
||
// paid selects the model tier, matching every other AI surface.
|
||
tier TierSource
|
||
}
|
||
|
||
// VoiceSource renders the org's voice grounding. Optional.
|
||
type VoiceSource interface {
|
||
VoiceInstructions(ctx context.Context, orgID uuid.UUID) string
|
||
}
|
||
|
||
// TierSource reports whether an org is on a paid plan, which picks the model.
|
||
type TierSource interface {
|
||
IsPaid(ctx context.Context, orgID uuid.UUID) bool
|
||
}
|
||
|
||
// NarrationCache is the persistence the narrator reuses across runs and orgs
|
||
// with the same evidence shape.
|
||
type NarrationCache interface {
|
||
GetNarration(ctx context.Context, orgID uuid.UUID, cacheKey string) (title, detail, remedy string, ok bool)
|
||
PutNarration(ctx context.Context, orgID uuid.UUID, cacheKey, title, detail, remedy, model string) error
|
||
}
|
||
|
||
// NewNarrator wires a narrator. A nil provider yields a narrator that is a
|
||
// no-op, which is the correct behaviour for a self-hosted install with no LLM
|
||
// configured.
|
||
func NewNarrator(provider generation.Provider, voice VoiceSource, tier TierSource, cache NarrationCache) *Narrator {
|
||
return &Narrator{
|
||
provider: provider,
|
||
about: DetectorAbout(),
|
||
voice: voice,
|
||
tier: tier,
|
||
cache: cache,
|
||
}
|
||
}
|
||
|
||
// Enabled reports whether narration can run at all.
|
||
func (n *Narrator) Enabled() bool { return n != nil && n.provider != nil }
|
||
|
||
// narrationSystemPrompt is intentionally strict. The failure mode we are
|
||
// guarding against is not a refusal, it is enthusiasm: a model that turns a
|
||
// measured finding into marketing copy, invents a cause the evidence does not
|
||
// support, or hedges a critical finding into a suggestion.
|
||
const narrationSystemPrompt = `You rewrite one diagnostic finding for a cold-email platform's dashboard so it reads like a knowledgeable colleague explaining the problem, not like a linter.
|
||
|
||
You are given: what the check looks for and why it matters, and the exact evidence that made it fire. That is all you know. You may not introduce any fact, number, cause, or consequence that is not in the evidence or the check description.
|
||
|
||
Return ONLY a JSON object with exactly these keys:
|
||
{"title": "...", "detail": "...", "remedy": "..."}
|
||
|
||
title: one short line naming the specific thing that is wrong, including the mailbox, campaign, or step it concerns. Under 70 characters. No trailing period.
|
||
detail: two or three sentences. State what is happening with the actual numbers from the evidence, then why it matters to this sender. The reader knows what cold email is; do not explain the basics.
|
||
remedy: two or three sentences on what to do, concretely. If the evidence implies a specific number to change, say the number.
|
||
|
||
Rules:
|
||
- Write plainly. No marketing voice, no "unlock", "supercharge", "best practices", "pro tip", no exclamation marks.
|
||
- Do not use em dashes. Use a period, comma, colon, or parentheses.
|
||
- Do not open with "It looks like", "We noticed", "Great news", or any similar throat-clearing. Start with the substance.
|
||
- Do not soften a serious finding into a suggestion, and do not inflate a minor one into an emergency. Match the severity you are given.
|
||
- Do not tell the reader to "consider" or "try" something. Say what to do.
|
||
- Never mention that you are an AI, and never refer to "the system" or "the platform" in the third person.
|
||
- Never invent a cause. If the evidence does not say why something is happening, describe what is happening and what to do about it.`
|
||
|
||
// narrationInput is the exact payload the model sees.
|
||
type narrationInput struct {
|
||
Check string `json:"what_this_check_looks_for"`
|
||
Severity string `json:"severity"`
|
||
Subject string `json:"subject,omitempty"`
|
||
Evidence map[string]any `json:"evidence"`
|
||
}
|
||
|
||
type narrationOutput struct {
|
||
Title string `json:"title"`
|
||
Detail string `json:"detail"`
|
||
Remedy string `json:"remedy"`
|
||
}
|
||
|
||
// cacheKey identifies a reusable narration: same detector, same severity, same
|
||
// evidence shape. Two mailboxes with the same problem at the same magnitude
|
||
// legitimately get the same words, which is why one org's daily run is a
|
||
// handful of completions rather than one per finding.
|
||
func cacheKey(f *models.AdvisorFinding) string {
|
||
h := sha256.New()
|
||
h.Write([]byte(f.DetectorKey))
|
||
h.Write([]byte{0})
|
||
h.Write([]byte(f.Severity))
|
||
h.Write([]byte{0})
|
||
h.Write(f.Evidence)
|
||
return hex.EncodeToString(h.Sum(nil)[:12])
|
||
}
|
||
|
||
// Narrate rewrites the finding in place. It returns true when the copy was
|
||
// replaced. Any failure leaves the deterministic fallback untouched, which is
|
||
// why the caller can ignore the error.
|
||
func (n *Narrator) Narrate(ctx context.Context, orgID uuid.UUID, f *models.AdvisorFinding) bool {
|
||
if !n.Enabled() || f == nil || f.Narrated {
|
||
return false
|
||
}
|
||
|
||
key := cacheKey(f)
|
||
if n.cache != nil {
|
||
if title, detail, remedy, ok := n.cache.GetNarration(ctx, orgID, key); ok {
|
||
f.Title, f.Detail, f.Remedy, f.Narrated = title, detail, remedy, true
|
||
return true
|
||
}
|
||
}
|
||
|
||
var evidence map[string]any
|
||
if err := json.Unmarshal(f.Evidence, &evidence); err != nil {
|
||
evidence = map[string]any{}
|
||
}
|
||
payload, err := json.Marshal(narrationInput{
|
||
Check: n.about[f.DetectorKey],
|
||
Severity: string(f.Severity),
|
||
Subject: f.EntityLabel,
|
||
Evidence: evidence,
|
||
})
|
||
if err != nil {
|
||
return false
|
||
}
|
||
|
||
system := narrationSystemPrompt
|
||
if n.voice != nil {
|
||
if v := strings.TrimSpace(n.voice.VoiceInstructions(ctx, orgID)); v != "" {
|
||
// The org's voice grounding shapes tone only. It is appended after
|
||
// the hard rules so it can never override them.
|
||
system += "\n\nThis workspace's context, for tone and vocabulary only. It does not change any rule above:\n" + v
|
||
}
|
||
}
|
||
|
||
paid := false
|
||
if n.tier != nil {
|
||
paid = n.tier.IsPaid(ctx, orgID)
|
||
}
|
||
|
||
res, err := n.provider.Complete(ctx, generation.CompletionRequest{
|
||
System: system,
|
||
Prompt: string(payload),
|
||
Model: n.provider.ModelForTier(paid),
|
||
// Deterministic: the same finding should not get different wording on
|
||
// every run, and this is exposition, not creative writing.
|
||
Temperature: generation.Deterministic(),
|
||
MaxTokens: 400,
|
||
})
|
||
if err != nil || res == nil {
|
||
return false
|
||
}
|
||
|
||
out, ok := parseNarration(res.Text)
|
||
if !ok {
|
||
return false
|
||
}
|
||
|
||
f.Title, f.Detail, f.Remedy, f.Narrated = out.Title, out.Detail, out.Remedy, true
|
||
if n.cache != nil {
|
||
model := ""
|
||
if res != nil {
|
||
model = n.provider.Name()
|
||
}
|
||
_ = n.cache.PutNarration(ctx, orgID, key, out.Title, out.Detail, out.Remedy, model)
|
||
}
|
||
return true
|
||
}
|
||
|
||
// parseNarration extracts the JSON object, tolerating a model that wraps it in
|
||
// a fenced code block, and rejects any result that is not complete. A partial
|
||
// rewrite (good title, empty remedy) is worse than the fallback, so it is
|
||
// discarded wholesale.
|
||
func parseNarration(text string) (narrationOutput, bool) {
|
||
var out narrationOutput
|
||
trimmed := strings.TrimSpace(text)
|
||
if i := strings.Index(trimmed, "{"); i >= 0 {
|
||
if j := strings.LastIndex(trimmed, "}"); j > i {
|
||
trimmed = trimmed[i : j+1]
|
||
}
|
||
}
|
||
if err := json.Unmarshal([]byte(trimmed), &out); err != nil {
|
||
return out, false
|
||
}
|
||
|
||
out.Title = sanitize(out.Title)
|
||
out.Detail = sanitize(out.Detail)
|
||
out.Remedy = sanitize(out.Remedy)
|
||
if out.Title == "" || out.Detail == "" || out.Remedy == "" {
|
||
return out, false
|
||
}
|
||
// A title that ran away is a sign the model ignored the format; the
|
||
// fallback is better than a card that breaks the layout.
|
||
if len(out.Title) > 120 || len(out.Detail) > 700 || len(out.Remedy) > 700 {
|
||
return out, false
|
||
}
|
||
return out, true
|
||
}
|
||
|
||
// sanitize enforces the house style the prompt asks for, because a prompt rule
|
||
// is a request and this is the guarantee. Em dashes in particular read as
|
||
// machine-written, which is exactly the impression this feature must not give.
|
||
func sanitize(s string) string {
|
||
s = strings.TrimSpace(s)
|
||
s = strings.ReplaceAll(s, "—", ", ")
|
||
s = strings.ReplaceAll(s, " – ", ", ")
|
||
s = strings.ReplaceAll(s, " ", " ")
|
||
return strings.TrimSpace(s)
|
||
}
|
||
|
||
// String renders a finding for logs.
|
||
func describe(f *models.AdvisorFinding) string {
|
||
return fmt.Sprintf("%s/%s(%s)", f.DetectorKey, f.Severity, f.EntityLabel)
|
||
}
|