Files
TornadoAce_CJOnStyle/TornadoAce_CJOnStyle/Services/PocAutomationEngine.cs

229 lines
9.0 KiB
C#

using System.Text;
using TornadoAce_CJOnStyle.Domain;
namespace TornadoAce_CJOnStyle.Services
{
public sealed class PocAutomationEngine
{
private readonly AutomationWorkspace workspace;
public PocAutomationEngine(AutomationWorkspace workspace)
{
this.workspace = workspace;
}
public CueSheetSelection SelectCueSheet(int index)
{
if (workspace.CueSheets.Count == 0)
{
throw new InvalidOperationException("큐시트 목 데이터가 없습니다.");
}
var normalizedIndex = ((index % workspace.CueSheets.Count) + workspace.CueSheets.Count) % workspace.CueSheets.Count;
return new CueSheetSelection(workspace.CueSheets[normalizedIndex], normalizedIndex + 1, workspace.CueSheets.Count);
}
public SceneDataSnapshot GenerateScene(CueSheetRecord cueSheet, SceneTemplateDefinition template)
{
return BuildSnapshot(cueSheet, template, BuildVisibleTextObjects(cueSheet, template));
}
public PocInspectionSummary InspectWorkspace(SceneTemplateDefinition template)
{
var results = workspace.CueSheets
.Select(cueSheet => Inspect(cueSheet, GenerateExtractedScene(cueSheet, template), template))
.ToArray();
var reviewCount = results.Count(result => result.Severity is "High" or "Medium");
var passedCount = results.Length - reviewCount;
var averageMatchRate = results.Length == 0 ? 100 : results.Average(result => result.MatchRate);
return new PocInspectionSummary(results, passedCount, reviewCount, averageMatchRate);
}
private InspectionResult Inspect(CueSheetRecord cueSheet, SceneDataSnapshot snapshot, SceneTemplateDefinition template)
{
var expectedObjects = BuildVisibleTextObjects(cueSheet, template);
var fieldResults = template.Mappings
.Select(mapping =>
{
var expectedValue = expectedObjects[mapping.SceneObjectName];
snapshot.VisibleTextObjects.TryGetValue(mapping.SceneObjectName, out var actualValue);
var score = CalculateSimilarity(expectedValue, actualValue ?? string.Empty);
var severity = ResolveFieldSeverity(mapping.SceneObjectName, score);
return new InspectionFieldResult(
mapping.CueSheetField,
mapping.SceneObjectName,
expectedValue,
actualValue ?? string.Empty,
score,
severity,
ResolveFieldDecision(severity));
})
.ToArray();
var matchRate = fieldResults.Length == 0 ? 100 : fieldResults.Average(field => field.MatchRate);
var hasImportantMismatch = fieldResults.Any(field => field.Severity == "High");
var mismatchCount = fieldResults.Count(field => field.MatchRate < 99.5);
var severity = hasImportantMismatch || mismatchCount > 1
? "High"
: mismatchCount == 1
? "Medium"
: "Low";
var decision = severity switch
{
"High" => "운영자 확인 필요",
"Medium" => "LLM 검토 후보",
_ => "1차 통과"
};
return new InspectionResult(cueSheet.CueSheetId, cueSheet.ProgramTitle, snapshot.SceneName, matchRate, severity, decision, fieldResults);
}
private SceneDataSnapshot GenerateExtractedScene(CueSheetRecord cueSheet, SceneTemplateDefinition template)
{
var visibleObjects = BuildVisibleTextObjects(cueSheet, template);
if (cueSheet.CueSheetId.EndsWith("-002", StringComparison.Ordinal))
{
visibleObjects["TXT_PRICE"] = "79,900원";
}
if (cueSheet.CueSheetId.EndsWith("-003", StringComparison.Ordinal))
{
visibleObjects["TXT_PROMOTION"] = "앱 주문 추가 적립 ※";
}
return BuildSnapshot(cueSheet, template, visibleObjects);
}
private static SceneDataSnapshot BuildSnapshot(
CueSheetRecord cueSheet,
SceneTemplateDefinition template,
IReadOnlyDictionary<string, string> visibleObjects)
{
var sceneName = $"{template.TemplateId}_{cueSheet.BroadcastTime:HHmm}.tscn";
return new SceneDataSnapshot(sceneName, template.TemplateId, visibleObjects, [BuildImagePath(cueSheet)]);
}
private static Dictionary<string, string> BuildVisibleTextObjects(CueSheetRecord cueSheet, SceneTemplateDefinition template)
{
return template.Mappings.ToDictionary(
mapping => mapping.SceneObjectName,
mapping => ReadCueSheetValue(cueSheet, mapping.CueSheetField),
StringComparer.OrdinalIgnoreCase);
}
private static string ReadCueSheetValue(CueSheetRecord cueSheet, string fieldName)
{
return fieldName switch
{
"programTitle" => cueSheet.ProgramTitle,
"productName" => cueSheet.ProductName,
"priceText" => cueSheet.PriceText,
"compositionText" => cueSheet.CompositionText,
"promotionText" => cueSheet.PromotionText,
_ => string.Empty
};
}
private static string BuildImagePath(CueSheetRecord cueSheet)
{
var directoryName = new string(cueSheet.ProductName
.Select(character => char.IsLetterOrDigit(character) ? character : '_')
.ToArray()).Trim('_');
return $@"D:\CJOnStyle\Products\{directoryName}\main.png";
}
private static bool IsImportantSceneObject(string sceneObjectName)
{
return sceneObjectName.Contains("PRICE", StringComparison.OrdinalIgnoreCase)
|| sceneObjectName.Contains("COMPOSITION", StringComparison.OrdinalIgnoreCase);
}
private static string ResolveFieldSeverity(string sceneObjectName, double matchRate)
{
if (matchRate >= 99.5)
{
return "Low";
}
return IsImportantSceneObject(sceneObjectName) ? "High" : "Medium";
}
private static string ResolveFieldDecision(string severity)
{
return severity switch
{
"High" => "운영자 확인",
"Medium" => "LLM 후보",
_ => "일치"
};
}
private static double CalculateSimilarity(string expected, string actual)
{
var normalizedExpected = Normalize(expected);
var normalizedActual = Normalize(actual);
if (normalizedExpected == normalizedActual)
{
return 100;
}
if (normalizedExpected.Length == 0 || normalizedActual.Length == 0)
{
return 0;
}
var distance = CalculateLevenshteinDistance(normalizedExpected, normalizedActual);
var maxLength = Math.Max(normalizedExpected.Length, normalizedActual.Length);
return Math.Max(0, 100 * (1 - distance / (double)maxLength));
}
private static string Normalize(string value)
{
var normalized = value.Normalize(NormalizationForm.FormKC);
var chars = normalized.Where(character => !char.IsWhiteSpace(character)).ToArray();
return new string(chars).ToUpperInvariant();
}
private static int CalculateLevenshteinDistance(string source, string target)
{
var previousRow = Enumerable.Range(0, target.Length + 1).ToArray();
var currentRow = new int[target.Length + 1];
for (var sourceIndex = 0; sourceIndex < source.Length; sourceIndex++)
{
currentRow[0] = sourceIndex + 1;
for (var targetIndex = 0; targetIndex < target.Length; targetIndex++)
{
var substitutionCost = source[sourceIndex] == target[targetIndex] ? 0 : 1;
currentRow[targetIndex + 1] = Math.Min(
Math.Min(
currentRow[targetIndex] + 1,
previousRow[targetIndex + 1] + 1),
previousRow[targetIndex] + substitutionCost);
}
(previousRow, currentRow) = (currentRow, previousRow);
}
return previousRow[target.Length];
}
}
public sealed record CueSheetSelection(CueSheetRecord Record, int Position, int TotalCount);
public sealed record PocInspectionSummary(
IReadOnlyList<InspectionResult> Results,
int PassedCount,
int ReviewCount,
double AverageMatchRate);
}