From 8aa35469fc3a5b5d87524373b345c0c1b015eb29 Mon Sep 17 00:00:00 2001 From: Peter Date: Sun, 12 Apr 2026 10:08:57 +0100 Subject: [PATCH] Fix Verdict:Error from prose-wrapped AI responses --- src/SpamGuard/Services/EmailClassifier.cs | 59 ++++++++++++++++++----- 1 file changed, 48 insertions(+), 11 deletions(-) diff --git a/src/SpamGuard/Services/EmailClassifier.cs b/src/SpamGuard/Services/EmailClassifier.cs index 09832d5..17ca186 100644 --- a/src/SpamGuard/Services/EmailClassifier.cs +++ b/src/SpamGuard/Services/EmailClassifier.cs @@ -8,38 +8,43 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using SpamGuard.Configuration; using SpamGuard.Models; +using SpamGuard.State; public sealed partial class EmailClassifier { private readonly SpamGuardOptions _options; private readonly ILogger _logger; private readonly HttpClient _httpClient; + private readonly OverrideStore _overrideStore; - private const string SystemPrompt = """ + private const string BaseSystemPrompt = """ You are an email spam classifier. Analyze the following email and determine if it is spam or legitimate. Spam includes: - Unsolicited marketing or promotional emails the recipient never signed up for - AI-generated emails designed to look like legitimate correspondence - - Newsletter signups the recipient didn't request + - Newsletter signups the recipient did not request Legitimate includes: - Emails from known contacts or businesses the recipient has a relationship with - Transactional emails (receipts, shipping notifications, password resets) - Emails the recipient would expect to receive - Respond with JSON only: + YOU MUST respond with ONLY a JSON object. No explanation, no preamble, no markdown fencing. + Output exactly this structure and nothing else: {"classification": "spam" | "legitimate", "confidence": 0.0-1.0, "reason": "brief explanation"} """; public EmailClassifier( IOptions options, ILogger logger, - HttpClient httpClient) + HttpClient httpClient, + OverrideStore overrideStore) { _options = options.Value; _logger = logger; _httpClient = httpClient; + _overrideStore = overrideStore; } public string BuildPrompt(EmailSummary email) @@ -62,11 +67,13 @@ public sealed partial class EmailClassifier _logger.LogDebug("Classifying email UID={Uid} from {From}", email.Uid, email.From); + var systemPrompt = BaseSystemPrompt + _overrideStore.BuildFewShotText(10); + var requestBody = new { model = _options.Claude.Model, max_tokens = 256, - system = SystemPrompt, + system = systemPrompt, messages = new[] { new { role = "user", content = userMessage } @@ -78,11 +85,27 @@ public sealed partial class EmailClassifier { Content = new StringContent(json, Encoding.UTF8, "application/json") }; - request.Headers.Add("x-api-key", _options.Claude.ApiKey); - request.Headers.Add("anthropic-version", "2023-06-01"); + + var isAnthropic = _options.Claude.BaseUrl.Contains("anthropic.com", StringComparison.OrdinalIgnoreCase); + if (isAnthropic) + { + request.Headers.Add("x-api-key", _options.Claude.ApiKey); + request.Headers.Add("anthropic-version", "2023-06-01"); + } + else + { + request.Headers.Authorization = + new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _options.Claude.ApiKey); + } var response = await _httpClient.SendAsync(request, ct); - response.EnsureSuccessStatusCode(); + + if (!response.IsSuccessStatusCode) + { + var errorBody = await response.Content.ReadAsStringAsync(ct); + _logger.LogError("Claude API returned {StatusCode}: {Error}", (int)response.StatusCode, errorBody); + return null; + } var responseJson = await response.Content.ReadAsStringAsync(ct); using var doc = JsonDocument.Parse(responseJson); @@ -108,18 +131,32 @@ public sealed partial class EmailClassifier // Strip markdown code fencing if present var cleaned = StripMarkdownFencing().Replace(text, "$1").Trim(); + // Try direct parse first (happy path) + var result = TryParseJson(cleaned); + if (result != null) return result; + + // Fall back: extract first {...} block from prose-wrapped responses + var start = cleaned.IndexOf('{'); + var end = cleaned.LastIndexOf('}'); + if (start >= 0 && end > start) + return TryParseJson(cleaned[start..(end + 1)]); + + return null; + } + + private static ClassificationResult? TryParseJson(string json) + { try { - using var doc = JsonDocument.Parse(cleaned); + using var doc = JsonDocument.Parse(json); var root = doc.RootElement; - return new ClassificationResult( Classification: root.GetProperty("classification").GetString() ?? "unknown", Confidence: root.GetProperty("confidence").GetDouble(), Reason: root.GetProperty("reason").GetString() ?? "" ); } - catch (Exception) + catch { return null; }