diff --git a/TornadoAce_CJOnStyle/Services/PocAutomationEngine.cs b/TornadoAce_CJOnStyle/Services/PocAutomationEngine.cs new file mode 100644 index 0000000..4c242b9 --- /dev/null +++ b/TornadoAce_CJOnStyle/Services/PocAutomationEngine.cs @@ -0,0 +1,200 @@ +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 fieldScores = expectedObjects + .Select(expected => + { + snapshot.VisibleTextObjects.TryGetValue(expected.Key, out var actualValue); + var score = CalculateSimilarity(expected.Value, actualValue ?? string.Empty); + return new FieldInspectionScore(expected.Key, score); + }) + .ToArray(); + + var matchRate = fieldScores.Length == 0 ? 100 : fieldScores.Average(field => field.Score); + var hasImportantMismatch = fieldScores.Any(field => field.Score < 99.5 && IsImportantSceneObject(field.SceneObjectName)); + var mismatchCount = fieldScores.Count(field => field.Score < 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); + } + + 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 visibleObjects) + { + var sceneName = $"{template.TemplateId}_{cueSheet.BroadcastTime:HHmm}.tscn"; + return new SceneDataSnapshot(sceneName, template.TemplateId, visibleObjects, [BuildImagePath(cueSheet)]); + } + + private static Dictionary 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 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]; + } + + private sealed record FieldInspectionScore(string SceneObjectName, double Score); + } + + public sealed record CueSheetSelection(CueSheetRecord Record, int Position, int TotalCount); + + public sealed record PocInspectionSummary( + IReadOnlyList Results, + int PassedCount, + int ReviewCount, + double AverageMatchRate); +} diff --git a/TornadoAce_CJOnStyle/ViewModels/MainViewModel.cs b/TornadoAce_CJOnStyle/ViewModels/MainViewModel.cs index 386bfe9..449f248 100644 --- a/TornadoAce_CJOnStyle/ViewModels/MainViewModel.cs +++ b/TornadoAce_CJOnStyle/ViewModels/MainViewModel.cs @@ -3,6 +3,7 @@ using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Media; using System.Collections.ObjectModel; +using TornadoAce_CJOnStyle.Domain; using TornadoAce_CJOnStyle.Services; using Windows.UI; @@ -11,19 +12,28 @@ namespace TornadoAce_CJOnStyle.ViewModels public partial class MainViewModel : BaseViewModel { private readonly AutomationWorkspace workspace; + private readonly PocAutomationEngine automationEngine; + private int selectedCueSheetIndex; + private CueSheetRecord activeCueSheet = null!; + private SceneDataSnapshot activeScene = null!; public MainViewModel() { Title = "CG 제작 자동화"; workspace = AutomationWorkspace.CreateDemo(); + automationEngine = new PocAutomationEngine(workspace); + var initialSelection = automationEngine.SelectCueSheet(selectedCueSheetIndex); + var initialInspectionSummary = automationEngine.InspectWorkspace(workspace.ActiveTemplate); + activeCueSheet = initialSelection.Record; + activeScene = automationEngine.GenerateScene(activeCueSheet, workspace.ActiveTemplate); BuildPhase = "INITIAL BUILD"; OperatorMessage = "큐시트 플랫폼, Tornado Scene 템플릿, 자동 검수 결과를 한 화면에서 확인하는 운영 콘솔 초안입니다."; - LastCueSheetSyncText = "목 데이터 기준 13:25"; + LastCueSheetSyncText = FormatCueSheetSelection(initialSelection); ActiveTemplateName = workspace.ActiveTemplate.TemplateName; - InspectionSummary = "1차 diff 실패 2건 / LLM 검토 대기 1건"; - SceneSnapshotLabel = $"{workspace.ActiveScene.SceneName} / visible text objects"; - SceneSnapshotText = workspace.ActiveScene.ToPreviewJson(); + InspectionSummary = FormatInspectionSummary(initialInspectionSummary); + SceneSnapshotLabel = $"{activeScene.SceneName} / visible text objects"; + SceneSnapshotText = activeScene.ToPreviewJson(); LoginOperatorName = "producer"; LaunchMessage = "TornadoAce"; LlmPolicyText = "사내 LLM 우선, 외부 서비스는 정책 승인 후"; @@ -33,7 +43,7 @@ namespace TornadoAce_CJOnStyle.ViewModels LoadPipelineSteps(); LoadFieldMappings(); LoadEndpoints(); - LoadInspectionQueue(); + LoadInspectionQueue(initialInspectionSummary.Results); } private string buildPhase = string.Empty; @@ -163,27 +173,39 @@ namespace TornadoAce_CJOnStyle.ViewModels [RelayCommand] private void RefreshCueSheet() { - LastCueSheetSyncText = $"목 데이터 동기화 {DateTime.Now:HH:mm:ss}"; - OperatorMessage = "큐시트 목 데이터를 다시 읽었습니다. 실제 API 연결 전까지 이 버튼은 화면 흐름 확인용으로 동작합니다."; - StatusCards[0].Metric = "12"; - StatusCards[0].Detail = "동기화 완료"; + selectedCueSheetIndex++; + var selection = automationEngine.SelectCueSheet(selectedCueSheetIndex); + activeCueSheet = selection.Record; + activeScene = automationEngine.GenerateScene(activeCueSheet, workspace.ActiveTemplate); + + LastCueSheetSyncText = FormatCueSheetSelection(selection); + OperatorMessage = $"{activeCueSheet.ProgramTitle} / {activeCueSheet.ProductName} 큐시트를 선택했습니다."; + StatusCards[0].Metric = selection.TotalCount.ToString(); + StatusCards[0].Detail = $"선택 {selection.Position}/{selection.TotalCount}"; + ApplySceneSnapshot(activeScene); } [RelayCommand] private void GenerateScene() { - OperatorMessage = "선택된 큐시트 레코드를 Tornado Scene 템플릿에 매핑하는 흐름을 확인했습니다."; - StatusCards[1].Metric = "7"; - StatusCards[1].Detail = "생성 후보"; + activeScene = automationEngine.GenerateScene(activeCueSheet, workspace.ActiveTemplate); + + OperatorMessage = $"{activeCueSheet.CueSheetId} 데이터를 {activeScene.SceneName} Scene 스냅샷으로 생성했습니다."; + StatusCards[1].Metric = workspace.ActiveTemplate.Mappings.Count.ToString(); + StatusCards[1].Detail = "필드 매핑 완료"; + ApplySceneSnapshot(activeScene); } [RelayCommand] private void RunInspection() { - OperatorMessage = "Scene 데이터와 큐시트 데이터를 비교하는 1차 검수 흐름을 확인했습니다."; - InspectionSummary = "1차 diff 실패 2건 / LLM 검토 대기 1건 / 통과 5건"; - StatusCards[2].Metric = "99.2%"; - StatusCards[2].Detail = "평균 일치율"; + var summary = automationEngine.InspectWorkspace(workspace.ActiveTemplate); + + OperatorMessage = "Scene 추출값을 큐시트와 비교했습니다. 가격/구성 불일치는 높은 중요도로 분류합니다."; + InspectionSummary = FormatInspectionSummary(summary); + StatusCards[2].Metric = $"{summary.AverageMatchRate:0.0}%"; + StatusCards[2].Detail = $"확인 {summary.ReviewCount}건"; + LoadInspectionQueue(summary.Results); } private void LoadStatusCards() @@ -219,9 +241,11 @@ namespace TornadoAce_CJOnStyle.ViewModels Endpoints.Add(new IntegrationEndpointViewModel("LLM 검수", "POST /api/inspection/llm", "Internal LLM", "정책 확인", Brush(0xFFB48CFF))); } - private void LoadInspectionQueue() + private void LoadInspectionQueue(IEnumerable results) { - foreach (var item in workspace.InspectionResults) + InspectionQueue.Clear(); + + foreach (var item in results) { InspectionQueue.Add(new InspectionQueueItemViewModel( item.ProgramTitle, @@ -233,6 +257,22 @@ namespace TornadoAce_CJOnStyle.ViewModels } } + private void ApplySceneSnapshot(SceneDataSnapshot scene) + { + SceneSnapshotLabel = $"{scene.SceneName} / {scene.VisibleTextObjects.Count} text objects"; + SceneSnapshotText = scene.ToPreviewJson(); + } + + private static string FormatCueSheetSelection(CueSheetSelection selection) + { + return $"목 데이터 {selection.Position}/{selection.TotalCount} · {selection.Record.BroadcastTime:HH:mm}"; + } + + private static string FormatInspectionSummary(PocInspectionSummary summary) + { + return $"통과 {summary.PassedCount}건 / 확인 {summary.ReviewCount}건 / 평균 {summary.AverageMatchRate:0.0}%"; + } + private static SolidColorBrush SeverityBrush(string severity) { return severity switch