Files
PowderCoatingLogix/src/PowderCoating.Infrastructure/Services/AiSchedulingService.cs
T
2026-04-23 21:38:24 -04:00

342 lines
16 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Text.Json;
using Anthropic.SDK;
using Anthropic.SDK.Messaging;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using PowderCoating.Application.DTOs.Scheduling;
using PowderCoating.Application.Interfaces;
namespace PowderCoating.Infrastructure.Services;
public class AiSchedulingService : IAiSchedulingService
{
private readonly IConfiguration _config;
private readonly ILogger<AiSchedulingService> _logger;
/// <summary>
/// System prompt that defines the oven batch scheduling rules Claude must follow.
/// Color grouping (rule 1) is the primary optimization because color changeovers require
/// purging the booth and gun, which costs 1530 minutes of downtime per changeover.
/// Temperature separation (rule 2) prevents under-cured or over-cured parts when items
/// with different powder chemistries share a cycle. The 30-minute cool-down between batches
/// on the same oven (rule 8) is a physical constraint — oven doors cannot be opened and
/// reloaded until the element cools enough to avoid flash-curing incoming items. Multi-coat
/// sequencing (rule 4) is strictly enforced in the prompt because violating it (coating
/// before primer cures) produces adhesion failures that require complete rework. The
/// response schema requires both <c>ovenId</c> and <c>ovenName</c> because the OvenCost
/// entity uses its own identity (not the Equipment table), and the name is needed for
/// display in the scheduler UI without an additional lookup.
/// </summary>
private const string SystemPrompt = @"You are an expert powder coating shop scheduler specializing in oven batch optimization.
Your task is to analyze a list of jobs waiting to go into the oven and organize them into optimal batches.
RULES:
1. Group items that share the same powder color (ColorName/ColorCode) together in one batch — this avoids costly color changeover purges between batches.
2. If cure temperatures are specified, never mix items that require significantly different temperatures (>25°F difference) in the same batch.
3. Never exceed the oven's MaxLoadSqFt capacity. If no capacity is set, assume unlimited but note it.
4. Multi-coat jobs (primer then top coat) MUST have their coats in separate batches — primer pass first, top coat pass only after the primer batch is completed.
5. Prioritize Rush and Urgent jobs first. Factor in DueDate — overdue jobs should be in the earliest batch.
6. Respect the optimization goal: maximize_throughput = pack each batch as full as possible; minimize_lateness = schedule by urgency/due date first; minimize_color_changes = minimize the number of distinct color groups across the schedule.
7. If multiple ovens are available, distribute batches across ovens to maximize throughput.
8. Suggest realistic start times starting from 07:00, with cool-down time of 30 minutes between batches on the same oven.
You must respond ONLY with a valid JSON object — no markdown, no explanation, just the JSON:
{
""batches"": [
{
""batchName"": ""string — e.g. 'Batch 1 — Gloss Black (RAL 9005)'"",
""ovenId"": number,
""ovenName"": ""string"",
""suggestedStartTime"": ""HH:mm"",
""estimatedCycleMinutes"": number,
""cureTemperatureF"": number or null,
""estimatedSqFt"": number,
""capacityUtilization"": number (0.0 to 1.0, or null if no capacity set),
""primaryColorName"": ""string or null"",
""primaryColorCode"": ""string or null"",
""rationale"": ""string — 1-2 sentences explaining why these items are grouped"",
""items"": [
{
""jobId"": number,
""jobItemId"": number,
""jobItemCoatId"": number,
""jobNumber"": ""string"",
""description"": ""string"",
""colorName"": ""string or null"",
""colorCode"": ""string or null"",
""surfaceAreaSqFt"": number,
""coatPassNumber"": number,
""coatName"": ""string"",
""priority"": ""string""
}
]
}
],
""summary"": ""string — 2-4 sentences describing the overall schedule and key decisions"",
""warnings"": [""string""] — list any overdue jobs, capacity issues, or missing data (empty array if none)
}";
/// <summary>
/// Initializes a new instance of <see cref="AiSchedulingService"/>. The Anthropic API key
/// is read per call (not at construction time) so that changes to configuration take effect
/// without a service restart.
/// </summary>
public AiSchedulingService(IConfiguration config, ILogger<AiSchedulingService> logger)
{
_config = config;
_logger = logger;
}
/// <summary>
/// Sends the current oven queue to Claude and returns a suggested batch schedule organized
/// by oven, start time, color group, and coat sequence. The request is validated before the
/// API call to return a fast, meaningful error if the job queue is empty. <c>MaxTokens</c>
/// is set to 4096 (the highest of all AI service methods in this application) because
/// scheduling responses grow linearly with the number of jobs and each batch object
/// contains a full item list, rationale, and metadata. The scheduling service does not
/// enforce its own timeout via <see cref="CancellationTokenSource"/> (unlike other AI
/// methods) because the Anthropic SDK's default network timeout is considered sufficient
/// for scheduling requests, which are initiated manually by a manager rather than triggered
/// inline during a customer-facing workflow. Errors from the API propagate as a failed
/// <see cref="BatchScheduleSuggestion"/> with the raw exception message included to aid
/// debugging during shop onboarding.
/// </summary>
public async Task<BatchScheduleSuggestion> SuggestBatchesAsync(BatchSchedulingRequest request)
{
var apiKey = _config["AI:Anthropic:ApiKey"];
if (string.IsNullOrWhiteSpace(apiKey) || apiKey.StartsWith("your-"))
{
return new BatchScheduleSuggestion
{
Success = false,
ErrorMessage = "Anthropic API key is not configured. Add AI:Anthropic:ApiKey to appsettings.json."
};
}
if (!request.Jobs.Any())
{
return new BatchScheduleSuggestion
{
Success = false,
ErrorMessage = "No jobs in the queue to schedule."
};
}
try
{
var client = new AnthropicClient(apiKey);
var userPrompt = BuildPrompt(request);
_logger.LogInformation("Sending oven scheduling request to Claude: {JobCount} jobs, {OvenCount} ovens",
request.Jobs.Count, request.Ovens.Count);
var messageRequest = new MessageParameters
{
Model = "claude-sonnet-4-6",
MaxTokens = 4096,
SystemMessage = SystemPrompt,
Messages = new List<Message>
{
new Message
{
Role = RoleType.User,
Content = new List<ContentBase> { new TextContent { Text = userPrompt } }
}
}
};
var response = await client.Messages.GetClaudeMessageAsync(messageRequest);
var rawText = response.FirstMessage?.Text
?? response.Content.OfType<TextContent>().FirstOrDefault()?.Text
?? "";
_logger.LogInformation("Claude scheduling response received ({Length} chars)", rawText.Length);
return ParseResponse(rawText);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error calling Claude AI for oven scheduling");
return new BatchScheduleSuggestion
{
Success = false,
ErrorMessage = $"AI service error: {ex.Message}"
};
}
}
/// <summary>
/// Serializes the available ovens (compact, single-line JSON) and the job queue (indented,
/// multi-line JSON) into a user prompt for Claude. The oven list is compact because it
/// rarely exceeds a handful of entries and does not need human readability. The job list
/// is indented so that Claude can more reliably parse complex nested coat and color
/// structures. Key scheduling constraints are re-stated as "Important notes" in the user
/// prompt (in addition to the system prompt rules) because reinforcing critical constraints
/// in both system and user messages improves Claude's adherence to multi-coat sequencing
/// and the <c>AlreadyBaked</c> skip rule. The note that <c>TotalSqFt</c> is pre-calculated
/// prevents Claude from re-deriving it incorrectly and potentially violating oven capacity.
/// </summary>
private static string BuildPrompt(BatchSchedulingRequest request)
{
var ovensJson = JsonSerializer.Serialize(request.Ovens, new JsonSerializerOptions { WriteIndented = false });
var jobsJson = JsonSerializer.Serialize(request.Jobs, new JsonSerializerOptions { WriteIndented = true });
return $@"Please create an optimized oven batch schedule.
Scheduling date: {request.ScheduleFromDate:yyyy-MM-dd}
Optimization goal: {request.OptimizationGoal}
Available ovens:
{ovensJson}
Jobs ready for the oven:
{jobsJson}
Important notes:
- Items with AlreadyBaked=true have already completed that coat — skip them.
- Items must be scheduled in coat sequence order (sequence 1 before sequence 2, etc.).
- A coat with sequence > 1 can only be scheduled AFTER sequence 1 for the same item is in a completed batch.
- For this schedule, assume all sequence-1 coats can be scheduled now, and plan sequence-2+ coats as separate later batches.
- If an item has no ColorName/ColorCode, it can be batched with any items of similar cure temperature.
- TotalSqFt is pre-calculated as SurfaceAreaSqFt × Quantity.
Respond with the JSON object only.";
}
/// <summary>
/// Deserializes Claude's raw JSON scheduling response into a <see cref="BatchScheduleSuggestion"/>.
/// Markdown code fences are stripped using the same brace-search approach as other AI
/// services. The <c>EstimatedCycleMinutes</c> field defaults to 45 minutes if Claude
/// returns zero or omits it, because 45 minutes is the typical minimum cure cycle for
/// standard powder coatings and an empty value would break the scheduler's timeline
/// rendering. <c>CapacityUtilization</c> defaults to 0 (not null) for display consistency
/// in the UI capacity bar even when no capacity limit is configured. Batch names fall back
/// to "Batch N" using the list index when Claude omits them, ensuring the UI always has a
/// human-readable label. Parsing errors surface the exception message in the error result
/// to aid diagnosing malformed responses during development and onboarding.
/// </summary>
private static BatchScheduleSuggestion ParseResponse(string rawText)
{
try
{
var json = rawText.Trim();
if (json.StartsWith("```"))
{
var start = json.IndexOf('{');
var end = json.LastIndexOf('}');
if (start >= 0 && end > start)
json = json[start..(end + 1)];
}
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
var parsed = JsonSerializer.Deserialize<ClaudeSchedulingResponse>(json, options);
if (parsed == null)
return new BatchScheduleSuggestion { Success = false, ErrorMessage = "AI returned an unexpected format." };
return new BatchScheduleSuggestion
{
Success = true,
Summary = parsed.Summary ?? string.Empty,
Warnings = parsed.Warnings ?? new List<string>(),
Batches = (parsed.Batches ?? new List<ClaudeBatch>()).Select(b => new SuggestedBatch
{
BatchName = b.BatchName ?? $"Batch {parsed.Batches!.IndexOf(b) + 1}",
EquipmentId = b.OvenId,
OvenName = b.OvenName ?? string.Empty,
SuggestedStartTime = b.SuggestedStartTime,
EstimatedCycleMinutes = b.EstimatedCycleMinutes > 0 ? b.EstimatedCycleMinutes : 45,
CureTemperatureF = b.CureTemperatureF,
EstimatedSqFt = b.EstimatedSqFt,
CapacityUtilization = b.CapacityUtilization ?? 0,
PrimaryColorName = b.PrimaryColorName,
PrimaryColorCode = b.PrimaryColorCode,
Rationale = b.Rationale ?? string.Empty,
Items = (b.Items ?? new List<ClaudeBatchItem>()).Select(i => new SuggestedBatchItem
{
JobId = i.JobId,
JobItemId = i.JobItemId,
JobItemCoatId = i.JobItemCoatId,
JobNumber = i.JobNumber ?? string.Empty,
Description = i.Description ?? string.Empty,
ColorName = i.ColorName,
ColorCode = i.ColorCode,
SurfaceAreaSqFt = i.SurfaceAreaSqFt,
CoatPassNumber = i.CoatPassNumber,
CoatName = i.CoatName ?? string.Empty,
Priority = i.Priority ?? "Normal"
}).ToList()
}).ToList()
};
}
catch (Exception ex)
{
return new BatchScheduleSuggestion
{
Success = false,
ErrorMessage = $"Failed to parse AI response: {ex.Message}"
};
}
}
// Internal classes for JSON deserialization
/// <summary>
/// Mirrors the top-level JSON object returned by Claude for a scheduling request.
/// Fields are nullable because <see cref="System.Text.Json.JsonSerializer.Deserialize"/> returns
/// null for missing keys rather than throwing, and <see cref="ParseResponse"/> applies safe
/// defaults when these are null.
/// </summary>
private class ClaudeSchedulingResponse
{
public List<ClaudeBatch>? Batches { get; set; }
public string? Summary { get; set; }
public List<string>? Warnings { get; set; }
}
/// <summary>
/// Represents one oven batch as returned by Claude. <c>OvenId</c> maps to
/// <see cref="PowderCoating.Core.Entities.OvenCost"/>.Id (not the Equipment table).
/// <c>CapacityUtilization</c> is nullable because Claude omits it when no oven capacity
/// was configured for the selected oven.
/// </summary>
private class ClaudeBatch
{
public string? BatchName { get; set; }
public int OvenId { get; set; }
public string? OvenName { get; set; }
public string? SuggestedStartTime { get; set; }
public int EstimatedCycleMinutes { get; set; }
public decimal? CureTemperatureF { get; set; }
public decimal EstimatedSqFt { get; set; }
public decimal? CapacityUtilization { get; set; }
public string? PrimaryColorName { get; set; }
public string? PrimaryColorCode { get; set; }
public string? Rationale { get; set; }
public List<ClaudeBatchItem>? Items { get; set; }
}
/// <summary>
/// Represents a single coat pass within a batch as returned by Claude.
/// <c>JobItemCoatId</c> is the primary key used by the OvenScheduler controller to
/// mark the specific coat as queued in the DB, preventing the same coat pass from
/// appearing in future scheduling suggestions.
/// </summary>
private class ClaudeBatchItem
{
public int JobId { get; set; }
public int JobItemId { get; set; }
public int JobItemCoatId { get; set; }
public string? JobNumber { get; set; }
public string? Description { get; set; }
public string? ColorName { get; set; }
public string? ColorCode { get; set; }
public decimal SurfaceAreaSqFt { get; set; }
public int CoatPassNumber { get; set; }
public string? CoatName { get; set; }
public string? Priority { get; set; }
}
}