Wire PoC automation flow
This commit is contained in:
200
TornadoAce_CJOnStyle/Services/PocAutomationEngine.cs
Normal file
200
TornadoAce_CJOnStyle/Services/PocAutomationEngine.cs
Normal file
@@ -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<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 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<InspectionResult> Results,
|
||||
int PassedCount,
|
||||
int ReviewCount,
|
||||
double AverageMatchRate);
|
||||
}
|
||||
@@ -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<InspectionResult> 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
|
||||
|
||||
Reference in New Issue
Block a user