using System.Net.Http; using System.Text; using System.Text.Json; using Anthropic.SDK; using Anthropic.SDK.Messaging; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using PowderCoating.Application.DTOs.AI; using PowderCoating.Application.Interfaces; namespace PowderCoating.Infrastructure.Services; public class AccountingAiService : IAccountingAiService { private readonly IConfiguration _config; private readonly ILogger _logger; private const string Model = "claude-sonnet-4-6"; private static readonly JsonSerializerOptions JsonOpts = new() { PropertyNameCaseInsensitive = true }; /// /// Initializes a new instance of with the application /// configuration and logger. The Anthropic API key is read lazily per call via /// so that configuration changes at runtime (e.g. via user secrets) /// are picked up without restarting the application. /// public AccountingAiService(IConfiguration config, ILogger logger) { _config = config; _logger = logger; } // ── Helper ──────────────────────────────────────────────────────────────── /// /// Reads the Anthropic API key from configuration and returns null if it is absent or /// still set to the placeholder value. Returning null (instead of throwing) allows each /// feature method to return a friendly error result rather than crash, which is important /// when the key has not yet been configured in a fresh tenant environment. /// private string? GetApiKey() { var key = _config["AI:Anthropic:ApiKey"]; return string.IsNullOrWhiteSpace(key) || key.StartsWith("your-") ? null : key; } /// /// Removes the optional ```json … ``` markdown code fence that Claude (and other /// models) sometimes wrap around JSON responses despite being instructed not to. Stripping /// it here lets downstream calls /// succeed without requiring each feature method to duplicate the cleanup logic. /// private static string StripJsonFences(string text) { // Remove optional ```json ... ``` wrapper that some models add var trimmed = text.Trim(); if (trimmed.StartsWith("```")) { var firstNewline = trimmed.IndexOf('\n'); if (firstNewline >= 0) trimmed = trimmed[(firstNewline + 1)..]; if (trimmed.EndsWith("```")) trimmed = trimmed[..^3]; } return trimmed.Trim(); } // Uses raw HTTP to call the Anthropic API with a document (PDF) content block, // since the SDK 4.0.0 does not yet have DocumentContent support. private static readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(30) }; /// /// Sends a Claude API request with a 60-second hard timeout enforced via a /// . The SDK itself does not provide a built-in /// per-call timeout, so wrapping every call here prevents AI slowdowns from holding /// up request threads indefinitely. Callers catch /// and return a user-friendly error result. /// private static async Task SendAsync(AnthropicClient client, MessageParameters parameters) { using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(60)); return await client.Messages.GetClaudeMessageAsync(parameters, cts.Token); } /// /// Sends a multimodal document (PDF) message to the Anthropic API using raw HTTP rather /// than the SDK, because Anthropic SDK 4.0.0 does not yet expose a DocumentContent /// block type. The document is base64-encoded and sent as an Anthropic-native document /// content block alongside a text instruction. The method extracts the first text block /// from the response envelope and returns it as a plain string for downstream JSON parsing. /// private static async Task ScanWithRawHttpAsync( string apiKey, string systemPrompt, string userText, byte[] fileData, string mediaType) { var payload = new { model = Model, max_tokens = 1024, system = systemPrompt, messages = new[] { new { role = "user", content = new object[] { new { type = "document", source = new { type = "base64", media_type = mediaType, data = Convert.ToBase64String(fileData) } }, new { type = "text", text = userText } } } } }; var json = JsonSerializer.Serialize(payload); using var request = new HttpRequestMessage(HttpMethod.Post, "https://api.anthropic.com/v1/messages"); request.Headers.Add("x-api-key", apiKey); request.Headers.Add("anthropic-version", "2023-06-01"); request.Content = new StringContent(json, Encoding.UTF8, "application/json"); using var response = await _http.SendAsync(request); var body = await response.Content.ReadAsStringAsync(); using var doc = JsonDocument.Parse(body); var content = doc.RootElement .GetProperty("content")[0] .GetProperty("text") .GetString() ?? ""; return content; } // ── Feature 1: Receipt Scanning ─────────────────────────────────────────── /// /// Analyzes a receipt or invoice image (JPEG, PNG, GIF, WebP, or PDF) using Claude vision /// and returns structured extraction data: vendor name, date, total, invoice number, and /// line items with suggested expense account mappings. /// /// PDFs are routed through because the Anthropic SDK /// 4.0.0 does not support the document content block type required for PDF input. Image /// formats use the standard SDK image content block. The system prompt embeds the company's /// full chart of accounts as JSON so Claude can return a concrete suggestedAccountId /// for each line item rather than a free-text category name, which avoids a second round-trip /// lookup. Account matching rules are kept broad (materials, utilities, equipment, freight, /// office) since receipts in a powder coating shop follow predictable categories. /// public async Task ScanReceiptAsync( byte[] imageData, string mimeType, List availableAccounts) { var apiKey = GetApiKey(); if (apiKey == null) return new ReceiptScanResult { Success = false, ErrorMessage = "Anthropic API key is not configured." }; try { var accountsJson = JsonSerializer.Serialize(availableAccounts.Select(a => new { a.Id, a.AccountNumber, a.Name, a.AccountType, a.AccountSubType })); var systemPrompt = @"You are a receipt/invoice scanner for a powder coating business accounting system. Analyze the provided receipt or invoice image and extract all data. Respond ONLY with a valid JSON object — no markdown, no explanation. Schema: { ""vendorName"": ""string or null"", ""date"": ""ISO 8601 date (YYYY-MM-DD) or null"", ""total"": number or null, ""invoiceNumber"": ""string or null"", ""lineItems"": [ { ""description"": ""string"", ""amount"": number, ""suggestedAccountId"": number or null, ""suggestedAccountName"": ""string or null"" } ] } Account matching rules: - Materials, powder, supplies → match to a CostOfGoods or Materials account - Utilities (electricity, gas, water) → match to a Utilities expense account - Equipment, tools → match to Equipment or Supplies account - Freight, shipping → match to Freight or Shipping account - Office supplies → match to Office Supplies account - If unsure, leave suggestedAccountId null Use the provided accounts list to find the best match ID."; var userText = $"Please scan this receipt/invoice and extract all data.\n\nAvailable accounts:\n{accountsJson}"; string rawText1; var normalizedMime = mimeType.ToLowerInvariant(); if (normalizedMime == "application/pdf") { // PDFs use the Anthropic document API (not supported by SDK 4.0.0, use raw HTTP) rawText1 = await ScanWithRawHttpAsync(apiKey, systemPrompt, userText, imageData, "application/pdf"); } else { var imageMime = normalizedMime switch { "image/jpg" => "image/jpeg", "image/jpeg" => "image/jpeg", "image/png" => "image/png", "image/gif" => "image/gif", "image/webp" => "image/webp", _ => "image/jpeg" }; var client = new AnthropicClient(apiKey); var messageParams = new MessageParameters { Model = Model, MaxTokens = 1024, SystemMessage = systemPrompt, Messages = new List { new Message { Role = RoleType.User, Content = new List { new ImageContent { Source = new ImageSource { MediaType = imageMime, Data = Convert.ToBase64String(imageData) } }, new TextContent { Text = userText } } } } }; var response = await SendAsync(client, messageParams); rawText1 = response.FirstMessage?.Text ?? response.Content?.OfType().FirstOrDefault()?.Text ?? ""; } if (string.IsNullOrWhiteSpace(rawText1)) return new ReceiptScanResult { Success = false, ErrorMessage = "Empty response from AI." }; var raw = StripJsonFences(rawText1); var parsed = JsonSerializer.Deserialize(raw, JsonOpts); if (parsed == null) return new ReceiptScanResult { Success = false, ErrorMessage = "Could not parse AI response." }; return new ReceiptScanResult { Success = true, VendorName = parsed.VendorName, Date = parsed.Date, Total = parsed.Total, InvoiceNumber = parsed.InvoiceNumber, LineItems = parsed.LineItems.Select(li => new ScannedLineItem { Description = li.Description, Amount = li.Amount, SuggestedAccountId = li.SuggestedAccountId, SuggestedAccountName = li.SuggestedAccountName }).ToList() }; } catch (OperationCanceledException) { _logger.LogWarning("Claude AI receipt scan timed out after 60 seconds"); return new ReceiptScanResult { Success = false, ErrorMessage = "The AI service did not respond in time. Please try again." }; } catch (Exception ex) { _logger.LogError(ex, "Error scanning receipt with AI"); return new ReceiptScanResult { Success = false, ErrorMessage = "An error occurred while scanning the receipt." }; } } // ── Feature 2: AR Follow-up Email Drafts ───────────────────────────────── /// /// Drafts a professional accounts-receivable follow-up email for one or more overdue /// invoices using Claude, with the tone calibrated automatically to the aging bucket: /// gentle for ≤30 days, firm for 31–60 days, and serious/urgent for 61+ days. This /// avoids a one-size-fits-all message that would either annoy recent late payers or /// under-communicate urgency to severely overdue accounts. The prompt explicitly forbids /// placeholder text (e.g., "[phone number]") so the drafted email is ready to send as-is /// without the user needing to fill in blanks. All invoice numbers and the total owing /// are included so the customer has full context without needing to call in. /// public async Task DraftFollowUpEmailAsync(ArFollowUpRequest request) { var apiKey = GetApiKey(); if (apiKey == null) return new ArFollowUpResult { Success = false, ErrorMessage = "Anthropic API key is not configured." }; try { var tone = request.DaysOverdue <= 30 ? "gentle and friendly — a polite reminder" : request.DaysOverdue <= 60 ? "firm and professional — clearly requesting action" : "serious and direct — emphasizing urgency and potential consequences"; var invoiceList = string.Join("\n", request.Invoices.Select(i => $" - Invoice {i.InvoiceNumber}: ${i.Amount:F2} ({i.DaysOverdue} days overdue)")); var systemPrompt = @"You are an accounts receivable specialist for a powder coating business. Draft professional follow-up emails for overdue invoices. Respond ONLY with a valid JSON object — no markdown, no explanation. Schema: { ""subject"": ""string"", ""body"": ""string (plain text, use \n for line breaks)"" }"; var userPrompt = $@"Draft a follow-up email for an overdue account. Tone: {tone}. Customer: {request.CustomerName} Our company: {request.CompanyName} Total amount owed: ${request.AmountOwed:F2} Days overdue: {request.DaysOverdue} days Outstanding invoices: {invoiceList} Requirements: - Address the customer by name - List all invoice numbers - State the total amount due clearly - Match the tone: {tone} - Sign off with {request.CompanyName} — Accounts Receivable - Keep it concise (under 200 words for body) - Do NOT include any placeholders like [phone number] — omit contact details if unknown"; var client = new AnthropicClient(apiKey); var messageParams = new MessageParameters { Model = Model, MaxTokens = 1024, SystemMessage = systemPrompt, Messages = new List { new Message { Role = RoleType.User, Content = new List { new TextContent { Text = userPrompt } } } } }; var response = await SendAsync(client, messageParams); var rawText2 = response.FirstMessage?.Text ?? response.Content?.OfType().FirstOrDefault()?.Text ?? ""; if (string.IsNullOrWhiteSpace(rawText2)) return new ArFollowUpResult { Success = false, ErrorMessage = "Empty response from AI." }; var raw = StripJsonFences(rawText2); var parsed = JsonSerializer.Deserialize(raw, JsonOpts); if (parsed == null) return new ArFollowUpResult { Success = false, ErrorMessage = "Could not parse AI response." }; return new ArFollowUpResult { Success = true, Subject = parsed.Subject, Body = parsed.Body }; } catch (OperationCanceledException) { _logger.LogWarning("Claude AI AR follow-up draft timed out after 60 seconds"); return new ArFollowUpResult { Success = false, ErrorMessage = "The AI service did not respond in time. Please try again." }; } catch (Exception ex) { _logger.LogError(ex, "Error drafting AR follow-up email with AI"); return new ArFollowUpResult { Success = false, ErrorMessage = "An error occurred while drafting the email." }; } } // ── Feature 3: Smart Account Categorization ─────────────────────────────── /// /// Suggests the best-matching expense account from the company's chart of accounts for /// a single bill line item, given vendor name, description, and amount. Returns the /// primary suggestion plus up to three ranked alternatives with confidence scores and /// one-sentence reasoning for each. Alternatives with confidence below 0.3 are suppressed /// to avoid presenting noise as options. This is called on blur of each line-item /// description field in the Bills and Expenses forms; a keyword cache in the calling /// controller avoids repeated API calls for identical descriptions within the same session. /// The chart of accounts is serialized and sent in the user prompt (not the system prompt) /// so that it varies per company without requiring a per-company system prompt build. /// public async Task SuggestAccountAsync(AccountSuggestionRequest request) { var apiKey = GetApiKey(); if (apiKey == null) return new AccountSuggestionResult { Success = false, ErrorMessage = "Anthropic API key is not configured." }; try { var accountsJson = JsonSerializer.Serialize(request.AvailableAccounts.Select(a => new { a.Id, a.AccountNumber, a.Name, a.AccountType, a.AccountSubType })); var systemPrompt = @"You are an accounting expert for a powder coating business. Given a vendor, description, and amount, suggest the best matching expense account from the provided list. Respond ONLY with a valid JSON object — no markdown, no explanation. Schema: { ""suggestedAccountId"": number or null, ""suggestedAccountName"": ""string or null"", ""reasoning"": ""string — one sentence explaining the match"", ""alternatives"": [ { ""accountId"": number, ""accountName"": ""string"", ""confidence"": number (0.0 to 1.0), ""reasoning"": ""string"" } ] } Return up to 3 alternatives (not including the primary suggestion). Confidence: 1.0 = perfect match, 0.0 = no match. Only include alternatives with confidence > 0.3."; var userPrompt = new StringBuilder(); userPrompt.AppendLine("Suggest the best expense account for this transaction:"); if (!string.IsNullOrWhiteSpace(request.VendorName)) userPrompt.AppendLine($"Vendor: {request.VendorName}"); if (!string.IsNullOrWhiteSpace(request.Description)) userPrompt.AppendLine($"Description: {request.Description}"); userPrompt.AppendLine($"Amount: ${request.Amount:F2}"); userPrompt.AppendLine(); userPrompt.AppendLine("Available accounts:"); userPrompt.AppendLine(accountsJson); var client = new AnthropicClient(apiKey); var messageParams = new MessageParameters { Model = Model, MaxTokens = 1024, SystemMessage = systemPrompt, Messages = new List { new Message { Role = RoleType.User, Content = new List { new TextContent { Text = userPrompt.ToString() } } } } }; var response = await SendAsync(client, messageParams); var rawText3 = response.FirstMessage?.Text ?? response.Content?.OfType().FirstOrDefault()?.Text ?? ""; if (string.IsNullOrWhiteSpace(rawText3)) return new AccountSuggestionResult { Success = false, ErrorMessage = "Empty response from AI." }; var raw = StripJsonFences(rawText3); var parsed = JsonSerializer.Deserialize(raw, JsonOpts); if (parsed == null) return new AccountSuggestionResult { Success = false, ErrorMessage = "Could not parse AI response." }; return new AccountSuggestionResult { Success = true, SuggestedAccountId = parsed.SuggestedAccountId, SuggestedAccountName = parsed.SuggestedAccountName, Reasoning = parsed.Reasoning, Alternatives = parsed.Alternatives.Select(a => new AccountSuggestion { AccountId = a.AccountId, AccountName = a.AccountName, Confidence = a.Confidence, Reasoning = a.Reasoning }).ToList() }; } catch (OperationCanceledException) { _logger.LogWarning("Claude AI account suggestion timed out after 60 seconds"); return new AccountSuggestionResult { Success = false, ErrorMessage = "The AI service did not respond in time. Please try again." }; } catch (Exception ex) { _logger.LogError(ex, "Error suggesting account with AI"); return new AccountSuggestionResult { Success = false, ErrorMessage = "An error occurred while suggesting an account." }; } } // ── Feature 4: Plain-English Financial Summary ──────────────────────────── /// /// Generates a short plain-English financial health summary (4–6 bullet points) and an /// overall sentiment classification ("positive", "neutral", or "concerning") from /// month-to-date financial metrics. Only the top 8 expense categories by amount are sent /// to Claude to keep the prompt compact and focus attention on material line items rather /// than minor cost buckets. Revenue change versus the prior month is pre-calculated as a /// percentage before being included in the prompt because Claude handles numeric reasoning /// better when the computation is already done. The system prompt explicitly bans markdown /// formatting inside bullets so the output renders cleanly in the HTML card view without /// additional sanitization. Sentiment is validated server-side against the three allowed /// values and defaults to "neutral" if Claude returns anything outside the expected set. /// public async Task GenerateFinancialSummaryAsync(FinancialSummaryRequest request) { var apiKey = GetApiKey(); if (apiKey == null) return new FinancialSummaryResult { Success = false, ErrorMessage = "Anthropic API key is not configured." }; try { var systemPrompt = @"You are a financial advisor for a powder coating business. Analyze the provided financial data and generate a plain-English summary. Respond ONLY with a valid JSON object — no markdown, no explanation. Schema: { ""bullets"": [""string"", ...], // 4 to 6 plain-English bullet points ""sentiment"": ""positive"" | ""neutral"" | ""concerning"" } Rules: - Each bullet must be one clear sentence in plain English — no accounting jargon - Cover revenue trends, expense levels, AR health, and any notable risks or opportunities - sentiment = ""positive"" if the business is clearly profitable and AR is healthy - sentiment = ""concerning"" if net income is negative, AR overdue is high, or expenses grew significantly - sentiment = ""neutral"" otherwise - Do NOT use markdown formatting inside the bullets (no **bold**, no #headings)"; var expensesJson = JsonSerializer.Serialize(request.ExpensesByCategory .OrderByDescending(e => e.Amount) .Take(8) .Select(e => new { e.Category, Amount = e.Amount })); var revenueChange = request.PriorMonthRevenue > 0 ? (request.TotalRevenue - request.PriorMonthRevenue) / request.PriorMonthRevenue * 100m : 0m; var userPrompt = $@"Generate a financial health summary for {request.CompanyName}. Period: {request.Period} Total Revenue: ${request.TotalRevenue:F2} Total Expenses: ${request.TotalExpenses:F2} Net Income: ${request.NetIncome:F2} Prior Month Revenue: ${request.PriorMonthRevenue:F2} Prior Month Expenses: ${request.PriorMonthExpenses:F2} Revenue Change vs Prior Month: {revenueChange:F1}% Accounts Receivable: - Total Outstanding: ${request.TotalArOutstanding:F2} - Amount Overdue >30 days: ${request.ArOverdue30Days:F2} - Overdue Invoice Count: {request.OverdueInvoiceCount} Top Expense Categories: {expensesJson}"; var client = new AnthropicClient(apiKey); var messageParams = new MessageParameters { Model = Model, MaxTokens = 1024, SystemMessage = systemPrompt, Messages = new List { new Message { Role = RoleType.User, Content = new List { new TextContent { Text = userPrompt } } } } }; var response = await SendAsync(client, messageParams); var rawText4 = response.FirstMessage?.Text ?? response.Content?.OfType().FirstOrDefault()?.Text ?? ""; if (string.IsNullOrWhiteSpace(rawText4)) return new FinancialSummaryResult { Success = false, ErrorMessage = "Empty response from AI." }; var raw = StripJsonFences(rawText4); var parsed = JsonSerializer.Deserialize(raw, JsonOpts); if (parsed == null) return new FinancialSummaryResult { Success = false, ErrorMessage = "Could not parse AI response." }; var validSentiments = new[] { "positive", "neutral", "concerning" }; var sentiment = validSentiments.Contains(parsed.Sentiment?.ToLowerInvariant()) ? parsed.Sentiment!.ToLowerInvariant() : "neutral"; return new FinancialSummaryResult { Success = true, Bullets = parsed.Bullets ?? new List(), Sentiment = sentiment }; } catch (OperationCanceledException) { _logger.LogWarning("Claude AI financial summary timed out after 60 seconds"); return new FinancialSummaryResult { Success = false, ErrorMessage = "The AI service did not respond in time. Please try again." }; } catch (Exception ex) { _logger.LogError(ex, "Error generating financial summary with AI"); return new FinancialSummaryResult { Success = false, ErrorMessage = "An error occurred while generating the summary." }; } } // ── Feature 5: Cash Flow Forecast ──────────────────────────────────────── /// /// Generates a 30/60/90-day cash flow forecast from open AR invoices, outstanding vendor /// bills, and the active job pipeline. The three data sets are serialized as JSON and /// passed verbatim in the user prompt so Claude can reason over the actual invoice due /// dates and amounts rather than summary statistics. Active jobs are included in the /// 60–90 day window because jobs nearing completion represent probable inflows that would /// not appear in open AR yet. The MaxTokens is raised to 1500 (versus 1024 for /// other features) to accommodate the larger structured output with three period objects /// and multiple key-item arrays. The outlook enum ("strong", "moderate", "tight", /// "concerning") is validated server-side and defaults to "moderate" on unexpected values. /// public async Task GenerateCashFlowForecastAsync(CashFlowForecastRequest request) { var apiKey = GetApiKey(); if (apiKey == null) return new CashFlowForecastResult { Success = false, ErrorMessage = "Anthropic API key is not configured." }; try { var systemPrompt = @"You are a cash flow analyst for a powder coating business. Given open AR invoices, outstanding vendor bills, and active jobs in the pipeline, project the business's 30, 60, and 90-day cash position. Respond ONLY with a valid JSON object — no markdown, no explanation. Schema: { ""next30Days"": { ""expectedInflows"": number, ""expectedOutflows"": number, ""netCashFlow"": number, ""keyItems"": [""string"", ...] }, ""next60Days"": { same structure }, ""next90Days"": { same structure }, ""insights"": [""string"", ...], ""outlook"": ""strong"" | ""moderate"" | ""tight"" | ""concerning"" } Rules: - Base inflow estimates on invoice due dates and typical days-to-pay patterns provided - Include pipeline jobs as probable inflows in the 60-90 day window (jobs near completion sooner) - Base outflow estimates on bill due dates - keyItems: 2-4 specific items driving the period's numbers (e.g. ""$3,200 from Acme Corp invoice INV-2601-0012 due in 15 days"") - insights: 3-5 plain-English observations about cash position, timing risks, or opportunities - outlook: overall 90-day cash health assessment - All dollar amounts must be numbers (not strings) - Do NOT include currency symbols inside number fields"; var arJson = JsonSerializer.Serialize(request.OpenInvoices); var apJson = JsonSerializer.Serialize(request.OpenBills); var jobJson = JsonSerializer.Serialize(request.ActiveJobs); var userPrompt = $@"Generate a 30/60/90-day cash flow forecast for {request.CompanyName} as of {request.AsOfDate}. Open AR Invoices (money coming in): {arJson} Outstanding Bills / AP (money going out): {apJson} Active Job Pipeline (expected future invoicing): {jobJson}"; var client = new AnthropicClient(apiKey); var messageParams = new MessageParameters { Model = Model, MaxTokens = 1500, SystemMessage = systemPrompt, Messages = new List { new Message { Role = RoleType.User, Content = new List { new TextContent { Text = userPrompt } } } } }; var response = await SendAsync(client, messageParams); var rawText = response.FirstMessage?.Text ?? response.Content?.OfType().FirstOrDefault()?.Text ?? ""; if (string.IsNullOrWhiteSpace(rawText)) return new CashFlowForecastResult { Success = false, ErrorMessage = "Empty response from AI." }; var raw = StripJsonFences(rawText); var parsed = JsonSerializer.Deserialize(raw, JsonOpts); if (parsed == null) return new CashFlowForecastResult { Success = false, ErrorMessage = "Could not parse AI response." }; var validOutlooks = new[] { "strong", "moderate", "tight", "concerning" }; var outlook = validOutlooks.Contains(parsed.Outlook?.ToLowerInvariant()) ? parsed.Outlook!.ToLowerInvariant() : "moderate"; return new CashFlowForecastResult { Success = true, Next30Days = new CashFlowPeriod { ExpectedInflows = parsed.Next30Days.ExpectedInflows, ExpectedOutflows = parsed.Next30Days.ExpectedOutflows, NetCashFlow = parsed.Next30Days.NetCashFlow, KeyItems = parsed.Next30Days.KeyItems ?? new() }, Next60Days = new CashFlowPeriod { ExpectedInflows = parsed.Next60Days.ExpectedInflows, ExpectedOutflows = parsed.Next60Days.ExpectedOutflows, NetCashFlow = parsed.Next60Days.NetCashFlow, KeyItems = parsed.Next60Days.KeyItems ?? new() }, Next90Days = new CashFlowPeriod { ExpectedInflows = parsed.Next90Days.ExpectedInflows, ExpectedOutflows = parsed.Next90Days.ExpectedOutflows, NetCashFlow = parsed.Next90Days.NetCashFlow, KeyItems = parsed.Next90Days.KeyItems ?? new() }, Insights = parsed.Insights ?? new(), Outlook = outlook }; } catch (OperationCanceledException) { _logger.LogWarning("Claude AI cash flow forecast timed out after 60 seconds"); return new CashFlowForecastResult { Success = false, ErrorMessage = "The AI service did not respond in time. Please try again." }; } catch (Exception ex) { _logger.LogError(ex, "Error generating cash flow forecast with AI"); return new CashFlowForecastResult { Success = false, ErrorMessage = "An error occurred while generating the forecast." }; } } // ── Feature 6: Anomaly / Duplicate Detection ───────────────────────────── /// /// Scans the last 90 days of vendor bills for four categories of anomaly: potential /// duplicate payments (same vendor and near-identical amount within 30 days, or repeated /// invoice number), amount spikes (a bill more than 2.5× the vendor's average), unusual /// new vendors (first-time vendor with a bill over $500), and account overruns (an expense /// account running more than 50% above its historical monthly average). Severity levels /// are critical (duplicates, extreme overruns), warning (spikes, unusual vendors, moderate /// overruns), and info. The thresholds (2.5×, 50%, $500) are embedded in the system prompt /// and were chosen to reduce false positives in a typical powder coating shop where /// occasional large material orders are normal. When no anomalies are found Claude is /// instructed to return an empty flags array rather than null, preventing null-reference /// errors on the flags enumeration. Severity is normalized to lowercase after /// parsing to guard against Claude casing inconsistencies (e.g., "Critical" vs "critical"). /// public async Task DetectAnomaliesAsync(AnomalyDetectionRequest request) { var apiKey = GetApiKey(); if (apiKey == null) return new AnomalyDetectionResult { Success = false, ErrorMessage = "Anthropic API key is not configured." }; try { var systemPrompt = @"You are an accounting auditor for a powder coating business. Analyze the provided bills, vendor history, and account trends for anomalies. Respond ONLY with a valid JSON object — no markdown, no explanation. Schema: { ""flags"": [ { ""type"": ""duplicate"" | ""amount_spike"" | ""unusual_vendor"" | ""account_overrun"", ""severity"": ""critical"" | ""warning"" | ""info"", ""title"": ""string — short headline"", ""description"": ""string — 1-2 sentences explaining what was found"", ""recommendedAction"": ""string — one clear action the user should take, or null"", ""billNumber"": ""string or null — bill number if this flag relates to a specific bill"" } ] } Detection rules: - duplicate: same vendor + same or near-identical amount within 30 days, or same vendor invoice number appearing more than once - amount_spike: a single bill is more than 2.5x that vendor's average invoice amount - unusual_vendor: a vendor that has never appeared before (not in history) with a bill over $500 - account_overrun: an expense account running more than 50% above its average monthly spend this month - severity critical: duplicates (risk of double payment), account_overrun > 100% over average - severity warning: amount_spike, account_overrun 50-100% over average, unusual_vendor - severity info: minor anomalies worth noting but low risk - Only flag real anomalies — do not flag items when data is insufficient to draw a conclusion - If no anomalies are found, return an empty flags array"; var billsJson = JsonSerializer.Serialize(request.RecentBills); var historyJson = JsonSerializer.Serialize(request.VendorHistory); var trendsJson = JsonSerializer.Serialize(request.AccountTrends); var userPrompt = $@"Analyze the following data for anomalies for {request.CompanyName}. Recent Bills (last 90 days): {billsJson} Vendor History (averages): {historyJson} Account Spend Trends (this month vs historical): {trendsJson}"; var client = new AnthropicClient(apiKey); var messageParams = new MessageParameters { Model = Model, MaxTokens = 1500, SystemMessage = systemPrompt, Messages = new List { new Message { Role = RoleType.User, Content = new List { new TextContent { Text = userPrompt } } } } }; var response = await SendAsync(client, messageParams); var rawText = response.FirstMessage?.Text ?? response.Content?.OfType().FirstOrDefault()?.Text ?? ""; if (string.IsNullOrWhiteSpace(rawText)) return new AnomalyDetectionResult { Success = false, ErrorMessage = "Empty response from AI." }; var raw = StripJsonFences(rawText); var parsed = JsonSerializer.Deserialize(raw, JsonOpts); if (parsed == null) return new AnomalyDetectionResult { Success = false, ErrorMessage = "Could not parse AI response." }; var flags = (parsed.Flags ?? new()).Select(f => new AnomalyFlag { Type = f.Type, Severity = f.Severity?.ToLowerInvariant() ?? "warning", Title = f.Title, Description = f.Description, RecommendedAction = f.RecommendedAction, BillNumber = f.BillNumber }).ToList(); return new AnomalyDetectionResult { Success = true, Flags = flags, CriticalCount = flags.Count(f => f.Severity == "critical"), WarningCount = flags.Count(f => f.Severity == "warning"), InfoCount = flags.Count(f => f.Severity == "info") }; } catch (OperationCanceledException) { _logger.LogWarning("Claude AI anomaly detection timed out after 60 seconds"); return new AnomalyDetectionResult { Success = false, ErrorMessage = "The AI service did not respond in time. Please try again." }; } catch (Exception ex) { _logger.LogError(ex, "Error running anomaly detection with AI"); return new AnomalyDetectionResult { Success = false, ErrorMessage = "An error occurred while running the analysis." }; } } }