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. */