906 lines
40 KiB
C#
906 lines
40 KiB
C#
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<AccountingAiService> _logger;
|
||
|
||
private const string Model = "claude-sonnet-4-6";
|
||
|
||
private static readonly JsonSerializerOptions JsonOpts = new()
|
||
{
|
||
PropertyNameCaseInsensitive = true
|
||
};
|
||
|
||
/// <summary>
|
||
/// Initializes a new instance of <see cref="AccountingAiService"/> with the application
|
||
/// configuration and logger. The Anthropic API key is read lazily per call via
|
||
/// <see cref="GetApiKey"/> so that configuration changes at runtime (e.g. via user secrets)
|
||
/// are picked up without restarting the application.
|
||
/// </summary>
|
||
public AccountingAiService(IConfiguration config, ILogger<AccountingAiService> logger)
|
||
{
|
||
_config = config;
|
||
_logger = logger;
|
||
}
|
||
|
||
// ── Helper ────────────────────────────────────────────────────────────────
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
private string? GetApiKey()
|
||
{
|
||
var key = _config["AI:Anthropic:ApiKey"];
|
||
return string.IsNullOrWhiteSpace(key) || key.StartsWith("your-") ? null : key;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Removes the optional <c>```json … ```</c> markdown code fence that Claude (and other
|
||
/// models) sometimes wrap around JSON responses despite being instructed not to. Stripping
|
||
/// it here lets downstream <see cref="System.Text.Json.JsonSerializer.Deserialize"/> calls
|
||
/// succeed without requiring each feature method to duplicate the cleanup logic.
|
||
/// </summary>
|
||
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) };
|
||
|
||
/// <summary>
|
||
/// Sends a Claude API request with a 60-second hard timeout enforced via a
|
||
/// <see cref="CancellationTokenSource"/>. 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 <see cref="OperationCanceledException"/>
|
||
/// and return a user-friendly error result.
|
||
/// </summary>
|
||
private static async Task<MessageResponse> SendAsync(AnthropicClient client, MessageParameters parameters)
|
||
{
|
||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(60));
|
||
return await client.Messages.GetClaudeMessageAsync(parameters, cts.Token);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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 <c>DocumentContent</c>
|
||
/// 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.
|
||
/// </summary>
|
||
private static async Task<string> 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 ───────────────────────────────────────────
|
||
|
||
/// <summary>
|
||
/// 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 <see cref="ScanWithRawHttpAsync"/> 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 <c>suggestedAccountId</c>
|
||
/// 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.
|
||
/// </summary>
|
||
public async Task<ReceiptScanResult> ScanReceiptAsync(
|
||
byte[] imageData,
|
||
string mimeType,
|
||
List<AccountSummary> 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<Message>
|
||
{
|
||
new Message
|
||
{
|
||
Role = RoleType.User,
|
||
Content = new List<ContentBase>
|
||
{
|
||
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<TextContent>().FirstOrDefault()?.Text
|
||
?? "";
|
||
}
|
||
if (string.IsNullOrWhiteSpace(rawText1))
|
||
return new ReceiptScanResult { Success = false, ErrorMessage = "Empty response from AI." };
|
||
|
||
var raw = StripJsonFences(rawText1);
|
||
var parsed = JsonSerializer.Deserialize<ClaudeReceiptResponse>(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 ─────────────────────────────────
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
public async Task<ArFollowUpResult> 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<Message>
|
||
{
|
||
new Message
|
||
{
|
||
Role = RoleType.User,
|
||
Content = new List<ContentBase> { new TextContent { Text = userPrompt } }
|
||
}
|
||
}
|
||
};
|
||
|
||
var response = await SendAsync(client, messageParams);
|
||
var rawText2 = response.FirstMessage?.Text
|
||
?? response.Content?.OfType<TextContent>().FirstOrDefault()?.Text
|
||
?? "";
|
||
if (string.IsNullOrWhiteSpace(rawText2))
|
||
return new ArFollowUpResult { Success = false, ErrorMessage = "Empty response from AI." };
|
||
|
||
var raw = StripJsonFences(rawText2);
|
||
var parsed = JsonSerializer.Deserialize<ClaudeArEmailResponse>(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 ───────────────────────────────
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
public async Task<AccountSuggestionResult> 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<Message>
|
||
{
|
||
new Message
|
||
{
|
||
Role = RoleType.User,
|
||
Content = new List<ContentBase> { new TextContent { Text = userPrompt.ToString() } }
|
||
}
|
||
}
|
||
};
|
||
|
||
var response = await SendAsync(client, messageParams);
|
||
var rawText3 = response.FirstMessage?.Text
|
||
?? response.Content?.OfType<TextContent>().FirstOrDefault()?.Text
|
||
?? "";
|
||
if (string.IsNullOrWhiteSpace(rawText3))
|
||
return new AccountSuggestionResult { Success = false, ErrorMessage = "Empty response from AI." };
|
||
|
||
var raw = StripJsonFences(rawText3);
|
||
var parsed = JsonSerializer.Deserialize<ClaudeAccountSuggestionResponse>(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 ────────────────────────────
|
||
|
||
/// <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.
|
||
/// </summary>
|
||
public async Task<FinancialSummaryResult> 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<Message>
|
||
{
|
||
new Message
|
||
{
|
||
Role = RoleType.User,
|
||
Content = new List<ContentBase> { new TextContent { Text = userPrompt } }
|
||
}
|
||
}
|
||
};
|
||
|
||
var response = await SendAsync(client, messageParams);
|
||
var rawText4 = response.FirstMessage?.Text
|
||
?? response.Content?.OfType<TextContent>().FirstOrDefault()?.Text
|
||
?? "";
|
||
if (string.IsNullOrWhiteSpace(rawText4))
|
||
return new FinancialSummaryResult { Success = false, ErrorMessage = "Empty response from AI." };
|
||
|
||
var raw = StripJsonFences(rawText4);
|
||
var parsed = JsonSerializer.Deserialize<ClaudeFinancialSummaryResponse>(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<string>(),
|
||
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 ────────────────────────────────────────
|
||
|
||
/// <summary>
|
||
/// 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 <c>MaxTokens</c> 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.
|
||
/// </summary>
|
||
public async Task<CashFlowForecastResult> 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<Message>
|
||
{
|
||
new Message
|
||
{
|
||
Role = RoleType.User,
|
||
Content = new List<ContentBase> { new TextContent { Text = userPrompt } }
|
||
}
|
||
}
|
||
};
|
||
|
||
var response = await SendAsync(client, messageParams);
|
||
var rawText = response.FirstMessage?.Text
|
||
?? response.Content?.OfType<TextContent>().FirstOrDefault()?.Text
|
||
?? "";
|
||
if (string.IsNullOrWhiteSpace(rawText))
|
||
return new CashFlowForecastResult { Success = false, ErrorMessage = "Empty response from AI." };
|
||
|
||
var raw = StripJsonFences(rawText);
|
||
var parsed = JsonSerializer.Deserialize<ClaudeCashFlowResponse>(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 ─────────────────────────────
|
||
|
||
/// <summary>
|
||
/// 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 <c>flags</c> enumeration. Severity is normalized to lowercase after
|
||
/// parsing to guard against Claude casing inconsistencies (e.g., "Critical" vs "critical").
|
||
/// </summary>
|
||
public async Task<AnomalyDetectionResult> 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<Message>
|
||
{
|
||
new Message
|
||
{
|
||
Role = RoleType.User,
|
||
Content = new List<ContentBase> { new TextContent { Text = userPrompt } }
|
||
}
|
||
}
|
||
};
|
||
|
||
var response = await SendAsync(client, messageParams);
|
||
var rawText = response.FirstMessage?.Text
|
||
?? response.Content?.OfType<TextContent>().FirstOrDefault()?.Text
|
||
?? "";
|
||
if (string.IsNullOrWhiteSpace(rawText))
|
||
return new AnomalyDetectionResult { Success = false, ErrorMessage = "Empty response from AI." };
|
||
|
||
var raw = StripJsonFences(rawText);
|
||
var parsed = JsonSerializer.Deserialize<ClaudeAnomalyResponse>(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." };
|
||
}
|
||
}
|
||
}
|