Add AI Catalog Price Check feature
Claude reviews every active catalog item against the shop's own operating costs and returns a per-item verdict (below-cost / thin-margin / high / ok) with a suggested price range, cost floor, and assumptions. - New entity: CatalogPriceCheckReport (JSON blob, archived per company) - New service: IAiCatalogPriceCheckService / AiCatalogPriceCheckService batches items 25 at a time to stay within model context limits - Two new controller actions: GET AiPriceCheck (view report) + POST RunAiPriceCheck - AiPriceCheck view: summary cards (counts by verdict), color-coded item cards with Edit Price link, assumptions detail, and loading spinner on submit - AI Price Check button added to catalog Index header - Migration AddCatalogPriceCheckReport applied Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -260,6 +260,8 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>
|
||||
public DbSet<CatalogCategory> CatalogCategories { get; set; }
|
||||
/// <summary>Pre-priced service catalog items that can be added to quotes/jobs; tenant-filtered with soft delete.</summary>
|
||||
public DbSet<CatalogItem> CatalogItems { get; set; }
|
||||
/// <summary>Most-recent AI price-check report per company; tenant-filtered with soft delete.</summary>
|
||||
public DbSet<CatalogPriceCheckReport> CatalogPriceCheckReports { get; set; }
|
||||
|
||||
// Notifications
|
||||
/// <summary>Log of all outbound notifications (email, SMS, in-app) for audit and retry; tenant-filtered with soft delete.</summary>
|
||||
@@ -508,6 +510,8 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
modelBuilder.Entity<CatalogItem>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
modelBuilder.Entity<CatalogPriceCheckReport>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
|
||||
modelBuilder.Entity<Appointment>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
|
||||
Generated
+9295
File diff suppressed because it is too large
Load Diff
+88
@@ -0,0 +1,88 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddCatalogPriceCheckReport : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "CatalogPriceCheckReports",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
RunAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
ItemsChecked = table.Column<int>(type: "int", nullable: false),
|
||||
ResultsJson = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
OperatingCostsSummary = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
CompanyId = table.Column<int>(type: "int", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
|
||||
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
DeletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_CatalogPriceCheckReports", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 4, 25, 22, 34, 50, 1, DateTimeKind.Utc).AddTicks(6987));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 4, 25, 22, 34, 50, 1, DateTimeKind.Utc).AddTicks(6993));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 4, 25, 22, 34, 50, 1, DateTimeKind.Utc).AddTicks(6994));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "CatalogPriceCheckReports");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 4, 25, 18, 27, 8, 537, DateTimeKind.Utc).AddTicks(4555));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 4, 25, 18, 27, 8, 537, DateTimeKind.Utc).AddTicks(4562));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 4, 25, 18, 27, 8, 537, DateTimeKind.Utc).AddTicks(4563));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1465,6 +1465,57 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.ToTable("CatalogItems");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.CatalogPriceCheckReport", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("CompanyId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("CreatedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("DeletedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<int>("ItemsChecked")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("OperatingCostsSummary")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("ResultsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime>("RunAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("UpdatedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("CatalogPriceCheckReports");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.Company", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -5782,7 +5833,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 1,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 4, 25, 18, 27, 8, 537, DateTimeKind.Utc).AddTicks(4555),
|
||||
CreatedAt = new DateTime(2026, 4, 25, 22, 34, 50, 1, DateTimeKind.Utc).AddTicks(6987),
|
||||
Description = "Standard pricing for regular customers",
|
||||
DiscountPercent = 0m,
|
||||
IsActive = true,
|
||||
@@ -5793,7 +5844,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 2,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 4, 25, 18, 27, 8, 537, DateTimeKind.Utc).AddTicks(4562),
|
||||
CreatedAt = new DateTime(2026, 4, 25, 22, 34, 50, 1, DateTimeKind.Utc).AddTicks(6993),
|
||||
Description = "5% discount for preferred customers",
|
||||
DiscountPercent = 5m,
|
||||
IsActive = true,
|
||||
@@ -5804,7 +5855,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 3,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 4, 25, 18, 27, 8, 537, DateTimeKind.Utc).AddTicks(4563),
|
||||
CreatedAt = new DateTime(2026, 4, 25, 22, 34, 50, 1, DateTimeKind.Utc).AddTicks(6994),
|
||||
Description = "10% discount for premium customers",
|
||||
DiscountPercent = 10m,
|
||||
IsActive = true,
|
||||
|
||||
@@ -84,6 +84,7 @@ public class UnitOfWork : IUnitOfWork
|
||||
// Product Catalog
|
||||
private IRepository<CatalogCategory>? _catalogCategories;
|
||||
private IRepository<CatalogItem>? _catalogItems;
|
||||
private IRepository<CatalogPriceCheckReport>? _catalogPriceCheckReports;
|
||||
|
||||
// Notifications
|
||||
private IRepository<NotificationLog>? _notificationLogs;
|
||||
@@ -344,6 +345,10 @@ public class UnitOfWork : IUnitOfWork
|
||||
public IRepository<CatalogItem> CatalogItems =>
|
||||
_catalogItems ??= new Repository<CatalogItem>(_context);
|
||||
|
||||
/// <summary>Repository for <see cref="CatalogPriceCheckReport"/> AI price-check results archived per company.</summary>
|
||||
public IRepository<CatalogPriceCheckReport> CatalogPriceCheckReports =>
|
||||
_catalogPriceCheckReports ??= new Repository<CatalogPriceCheckReport>(_context);
|
||||
|
||||
// Notifications
|
||||
/// <summary>Repository for <see cref="NotificationLog"/> outbound notification audit records; tenant-filtered with soft delete.</summary>
|
||||
public IRepository<NotificationLog> NotificationLogs =>
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Sends catalog items to Claude in batches of 25 and collects per-item price verdicts.
|
||||
/// Each batch produces one Claude call so large catalogs stay within the model's context
|
||||
/// limits. Results across all batches are merged into a single flat list before returning.
|
||||
/// </summary>
|
||||
public class AiCatalogPriceCheckService : IAiCatalogPriceCheckService
|
||||
{
|
||||
private readonly IConfiguration _config;
|
||||
private readonly ILogger<AiCatalogPriceCheckService> _logger;
|
||||
|
||||
private const string Model = "claude-sonnet-4-6";
|
||||
private const int BatchSize = 25;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOpts = new() { PropertyNameCaseInsensitive = true };
|
||||
|
||||
public AiCatalogPriceCheckService(IConfiguration config, ILogger<AiCatalogPriceCheckService> logger)
|
||||
{
|
||||
_config = config;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
private string? GetApiKey()
|
||||
{
|
||||
var key = _config["AI:Anthropic:ApiKey"];
|
||||
return string.IsNullOrWhiteSpace(key) || key.StartsWith("your-") ? null : key;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Strips optional ```json ... ``` fences that Claude sometimes adds despite instructions.
|
||||
/// </summary>
|
||||
private static string StripJsonFences(string text)
|
||||
{
|
||||
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();
|
||||
}
|
||||
|
||||
private static async Task<MessageResponse> SendAsync(AnthropicClient client, MessageParameters parameters)
|
||||
{
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(90));
|
||||
return await client.Messages.GetClaudeMessageAsync(parameters, cts.Token);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<List<CatalogItemPriceVerdict>> AnalyzeAsync(
|
||||
List<CatalogItemForPriceCheck> items,
|
||||
ShopOperatingCostSummary costs,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var apiKey = GetApiKey();
|
||||
if (apiKey == null)
|
||||
{
|
||||
_logger.LogWarning("AI Catalog Price Check called but Anthropic API key is not configured.");
|
||||
return new List<CatalogItemPriceVerdict>();
|
||||
}
|
||||
|
||||
var client = new AnthropicClient(apiKey);
|
||||
var systemPrompt = BuildSystemPrompt(costs);
|
||||
var results = new List<CatalogItemPriceVerdict>();
|
||||
|
||||
// Process items in batches of BatchSize
|
||||
for (int batchStart = 0; batchStart < items.Count; batchStart += BatchSize)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var batch = items.Skip(batchStart).Take(BatchSize).ToList();
|
||||
var batchResults = await AnalyzeBatchAsync(client, systemPrompt, batch, costs);
|
||||
results.AddRange(batchResults);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private async Task<List<CatalogItemPriceVerdict>> AnalyzeBatchAsync(
|
||||
AnthropicClient client,
|
||||
string systemPrompt,
|
||||
List<CatalogItemForPriceCheck> batch,
|
||||
ShopOperatingCostSummary costs)
|
||||
{
|
||||
var userPrompt = BuildUserPrompt(batch);
|
||||
|
||||
var parameters = new MessageParameters
|
||||
{
|
||||
Model = Model,
|
||||
MaxTokens = 4096,
|
||||
SystemMessage = systemPrompt,
|
||||
Messages = new List<Message>
|
||||
{
|
||||
new() { Role = RoleType.User, Content = new List<ContentBase> { new TextContent { Text = userPrompt } } }
|
||||
}
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var response = await SendAsync(client, parameters);
|
||||
var raw = response.Content.OfType<TextContent>().FirstOrDefault()?.Text ?? "[]";
|
||||
var json = StripJsonFences(raw);
|
||||
|
||||
var claudeItems = JsonSerializer.Deserialize<List<ClaudePriceCheckItem>>(json, JsonOpts) ?? new();
|
||||
|
||||
return claudeItems.Select(ci =>
|
||||
{
|
||||
var source = batch.FirstOrDefault(b => b.Id == ci.catalogItemId);
|
||||
return new CatalogItemPriceVerdict
|
||||
{
|
||||
CatalogItemId = ci.catalogItemId,
|
||||
Name = source?.Name ?? $"Item #{ci.catalogItemId}",
|
||||
CurrentPrice = source?.CurrentPrice ?? 0,
|
||||
Assumptions = ci.assumptions,
|
||||
EstimatedSqFtMin = ci.estimatedSqFtMin,
|
||||
EstimatedSqFtMax = ci.estimatedSqFtMax,
|
||||
EstimatedMinutesMin = ci.estimatedMinutesMin,
|
||||
EstimatedMinutesMax = ci.estimatedMinutesMax,
|
||||
CostFloor = ci.costFloor,
|
||||
Verdict = ci.verdict,
|
||||
SuggestedPriceMin = ci.suggestedPriceMin,
|
||||
SuggestedPriceMax = ci.suggestedPriceMax,
|
||||
Confidence = ci.confidence,
|
||||
Reasoning = ci.reasoning
|
||||
};
|
||||
}).ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "AI catalog price check batch failed");
|
||||
// Return stub verdicts so the rest of the report still renders
|
||||
return batch.Select(item => new CatalogItemPriceVerdict
|
||||
{
|
||||
CatalogItemId = item.Id,
|
||||
Name = item.Name,
|
||||
CurrentPrice = item.CurrentPrice,
|
||||
Verdict = "ok",
|
||||
Confidence = "low",
|
||||
Assumptions = "Analysis unavailable for this item.",
|
||||
Reasoning = "An error occurred during analysis. Please re-run the price check."
|
||||
}).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildSystemPrompt(ShopOperatingCostSummary costs)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("You are a pricing consultant for a powder coating business. Your job is to review catalog items and flag potential pricing problems against the shop's actual operating costs.");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("## Shop Operating Costs");
|
||||
sb.AppendLine($"- Labor rate: ${costs.LaborRatePerHour:F2}/hr");
|
||||
sb.AppendLine($"- Oven operating cost: ${costs.OvenCostPerHour:F2}/hr");
|
||||
sb.AppendLine($"- Sandblaster cost: ${costs.SandblasterCostPerHour:F2}/hr");
|
||||
sb.AppendLine($"- Coating booth cost: ${costs.CoatingBoothCostPerHour:F2}/hr");
|
||||
sb.AppendLine($"- Powder material cost: ${costs.PowderCostPerSqFt:F2}/sqft");
|
||||
sb.AppendLine($"- Shop supplies surcharge: {costs.ShopSuppliesRatePercent:F1}%");
|
||||
if (costs.PricingMode == "margin")
|
||||
sb.AppendLine($"- Target gross margin: {costs.MarkupOrMarginPercent:F1}%");
|
||||
else
|
||||
sb.AppendLine($"- Markup on material: {costs.MarkupOrMarginPercent:F1}%");
|
||||
if (costs.ShopMinimumCharge > 0)
|
||||
sb.AppendLine($"- Shop minimum charge: ${costs.ShopMinimumCharge:F2}");
|
||||
if (!string.IsNullOrWhiteSpace(costs.AiContextProfile))
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("## Shop Profile");
|
||||
sb.AppendLine(costs.AiContextProfile);
|
||||
}
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("## Instructions");
|
||||
sb.AppendLine("For each item, use industry knowledge to estimate a plausible surface area and processing time. Then compute a cost floor = (labor + equipment + material) using the shop's rates above. Compare the cost floor to the item's current price and return a verdict.");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("Verdict values:");
|
||||
sb.AppendLine("- \"below-cost\" — price is at or below cost floor (the shop loses money)");
|
||||
sb.AppendLine("- \"low\" — price is above cost floor but margin is thin (< the shop's target margin)");
|
||||
sb.AppendLine("- \"high\" — price appears significantly above comparable market rates (risk of losing work)");
|
||||
sb.AppendLine("- \"ok\" — price is within a reasonable range");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("Confidence values:");
|
||||
sb.AppendLine("- \"high\" — item name clearly identifies part type and standard dimensions");
|
||||
sb.AppendLine("- \"medium\" — reasonable assumptions were possible");
|
||||
sb.AppendLine("- \"low\" — item is too vague to estimate reliably (e.g., 'Custom Part', 'Job Special')");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("If the item already has an ApproximateArea or EstimatedMinutes, use those instead of guessing.");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("Return ONLY a JSON array, no prose, no markdown fences. Use this exact schema for each element:");
|
||||
sb.AppendLine(@"{
|
||||
""catalogItemId"": <int>,
|
||||
""assumptions"": ""<what you assumed about size/complexity>"",
|
||||
""estimatedSqFtMin"": <decimal>,
|
||||
""estimatedSqFtMax"": <decimal>,
|
||||
""estimatedMinutesMin"": <int>,
|
||||
""estimatedMinutesMax"": <int>,
|
||||
""costFloor"": <decimal>,
|
||||
""verdict"": ""ok|low|high|below-cost"",
|
||||
""suggestedPriceMin"": <decimal>,
|
||||
""suggestedPriceMax"": <decimal>,
|
||||
""confidence"": ""high|medium|low"",
|
||||
""reasoning"": ""<1-2 sentence explanation>""
|
||||
}");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
// Local schema — mirrors the JSON shape Claude is asked to return. Kept private to
|
||||
// the Infrastructure layer because it's a transport detail, not a domain concept.
|
||||
private sealed class ClaudePriceCheckItem
|
||||
{
|
||||
public int catalogItemId { get; set; }
|
||||
public string assumptions { get; set; } = string.Empty;
|
||||
public decimal estimatedSqFtMin { get; set; }
|
||||
public decimal estimatedSqFtMax { get; set; }
|
||||
public int estimatedMinutesMin { get; set; }
|
||||
public int estimatedMinutesMax { get; set; }
|
||||
public decimal costFloor { get; set; }
|
||||
public string verdict { get; set; } = "ok";
|
||||
public decimal suggestedPriceMin { get; set; }
|
||||
public decimal suggestedPriceMax { get; set; }
|
||||
public string confidence { get; set; } = "medium";
|
||||
public string reasoning { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
private static string BuildUserPrompt(List<CatalogItemForPriceCheck> batch)
|
||||
{
|
||||
var itemsJson = JsonSerializer.Serialize(batch.Select(item => new
|
||||
{
|
||||
catalogItemId = item.Id,
|
||||
name = item.Name,
|
||||
description = item.Description,
|
||||
category = item.CategoryName,
|
||||
currentPrice = item.CurrentPrice,
|
||||
approximateAreaSqFt = item.ApproximateAreaSqFt,
|
||||
estimatedMinutes = item.EstimatedMinutes,
|
||||
requiresSandblasting = item.RequiresSandblasting,
|
||||
requiresMasking = item.RequiresMasking
|
||||
}), new JsonSerializerOptions { WriteIndented = false });
|
||||
|
||||
return $"Analyze these {batch.Count} catalog items and return the JSON verdict array:\n{itemsJson}";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user