Initial commit
This commit is contained in:
@@ -0,0 +1,905 @@
|
||||
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." };
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user