342 lines
16 KiB
C#
342 lines
16 KiB
C#
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 15–30 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; }
|
||
}
|
||
}
|