From ade18d1650741cd751ae0f47607736c136c52885 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Thu, 10 Sep 2026 09:16:35 -0700 Subject: [PATCH] feat: leave an AI finding's field empty when the model labelled neither half and nothing in the finding could be anchored in the copy, instead of defaulting it to the body and rendering a badge that sends the writer to the wrong box on the one panel whose whole purpose is saying which box to open --- .../docs/api/reference/deliverability-ops.mdx | 2 +- docs/public/openapi.json | 2 +- internal/pkg/spamcheck/spamcheck.go | 17 ++++++++++---- internal/pkg/spamcheck/spamcheck_test.go | 23 +++++++++++++++++++ .../api/models/app/campaigns/TemplateScore.ts | 5 +++- 5 files changed, 42 insertions(+), 7 deletions(-) diff --git a/docs/content/docs/api/reference/deliverability-ops.mdx b/docs/content/docs/api/reference/deliverability-ops.mdx index 664160f2..b6599612 100644 --- a/docs/content/docs/api/reference/deliverability-ops.mdx +++ b/docs/content/docs/api/reference/deliverability-ops.mdx @@ -810,7 +810,7 @@ At least one of the three must carry something, and the three together are cappe |-------|-------------| | `score` | Overall deliverability score, 0 to 100, higher is safer. Grounded on `rules`, never argued against it. | | `verdict` | One sentence on how the email will land. | -| `findings` | Located problems, most severe first. `severity` is `high`, `warn` or `info`; `field` is `subject` or `body`; `category` is one of `trigger_word`, `tone`, `formatting`, `links`, `structure`, `authenticity`. | +| `findings` | Located problems, most severe first. `severity` is `high`, `warn` or `info`; `field` is `subject` or `body`, absent only when the model labelled neither and nothing in the finding could be anchored in the copy; `category` is one of `trigger_word`, `tone`, `formatting`, `links`, `structure`, `authenticity`. | | `findings[].text` | The fragment quoted from your copy. Absent when the finding is about the email as a whole. | | `suggested_subject` | A rewritten subject, or absent when the current one is fine. | | `improvements` | Copy-level advice with no single fragment to quote. | diff --git a/docs/public/openapi.json b/docs/public/openapi.json index aba8dcc4..9444378a 100644 --- a/docs/public/openapi.json +++ b/docs/public/openapi.json @@ -29089,7 +29089,6 @@ "description": "One located problem the AI analysis found. `text` is verified against the submitted copy before it is returned, so it is never a fragment the caller did not send.", "required": [ "severity", - "field", "issue" ], "properties": { @@ -29103,6 +29102,7 @@ }, "field": { "type": "string", + "description": "Absent when the model labelled neither half and nothing in the finding could be anchored in the copy.", "enum": [ "subject", "body" diff --git a/internal/pkg/spamcheck/spamcheck.go b/internal/pkg/spamcheck/spamcheck.go index ccc9cb94..2917980d 100644 --- a/internal/pkg/spamcheck/spamcheck.go +++ b/internal/pkg/spamcheck/spamcheck.go @@ -52,8 +52,9 @@ type Input struct { type Finding struct { // Severity is "high", "warn" or "info". Severity string `json:"severity"` - // Field is "subject" or "body". - Field string `json:"field"` + // Field is "subject" or "body", empty when the model labelled neither and + // nothing in the finding could be anchored in the copy. + Field string `json:"field,omitempty"` // Text is the exact fragment quoted from the copy, empty when the finding // is about the email as a whole. Text string `json:"text,omitempty"` @@ -407,11 +408,19 @@ func severity(s string) string { } } +// field reads the half the model named. Anything it did not clearly label comes +// back empty rather than guessed: verify fills it in from the quote when there +// is one, and a badge naming the wrong box on a finding nothing anchored is the +// exact error this whole panel exists to avoid. func field(s string) string { - if strings.Contains(strings.ToLower(strings.TrimSpace(s)), "subject") { + switch v := strings.ToLower(strings.TrimSpace(s)); { + case strings.Contains(v, "subject"): return warmlint.FieldSubject + case strings.Contains(v, "body"): + return warmlint.FieldBody + default: + return "" } - return warmlint.FieldBody } func clamp(n int) int { diff --git a/internal/pkg/spamcheck/spamcheck_test.go b/internal/pkg/spamcheck/spamcheck_test.go index c47e5006..a837d860 100644 --- a/internal/pkg/spamcheck/spamcheck_test.go +++ b/internal/pkg/spamcheck/spamcheck_test.go @@ -317,3 +317,26 @@ func TestAnalyzeAcceptsAZeroScore(t *testing.T) { t.Errorf("score = %d, want the model's own 0", res.Score) } } + +// A finding the model left unlabelled must not be given a half at random. With +// a quote, verify anchors it; without one, it carries no field rather than a +// badge sending the writer to the wrong box. +func TestVerifyDoesNotGuessAnUnlabelledField(t *testing.T) { + res := analyzed(t, `{ + "score": 60, + "findings": [ + {"severity":"warn","text":"limited time offer","issue":"Anchored, so the half is known."}, + {"severity":"warn","issue":"Nothing to anchor and no label."} + ] + }`, "Quick question", testBody) + + if len(res.Findings) != 2 { + t.Fatalf("got %d findings: %+v", len(res.Findings), res.Findings) + } + if res.Findings[0].Field != "body" { + t.Errorf("anchored finding field = %q, want body", res.Findings[0].Field) + } + if res.Findings[1].Field != "" { + t.Errorf("unanchored, unlabelled finding field = %q, want empty", res.Findings[1].Field) + } +} diff --git a/web/src/lib/api/models/app/campaigns/TemplateScore.ts b/web/src/lib/api/models/app/campaigns/TemplateScore.ts index 028e2f55..581bb5e9 100644 --- a/web/src/lib/api/models/app/campaigns/TemplateScore.ts +++ b/web/src/lib/api/models/app/campaigns/TemplateScore.ts @@ -42,7 +42,10 @@ export interface ScoreTemplateRequest { // One located problem the AI analysis found in the copy. export interface SpamFinding { severity: "high" | "warn" | "info"; - field: TemplateField; + /** Absent when the model labelled neither half and nothing in the finding + * could be anchored in the copy, so no badge is shown rather than one + * naming the wrong box. */ + field?: TemplateField; /** The exact fragment quoted from the copy, absent when the finding is * about the email as a whole. Verified server-side against the template, * so it is never a sentence the writer did not write. */