Show field-level inspection details

This commit is contained in:
2026-06-09 14:16:30 +09:00
parent 3a9fb2dd5c
commit 0337977bff
6 changed files with 198 additions and 44 deletions

View File

@@ -1,10 +1,32 @@
namespace TornadoAce_CJOnStyle.Domain
{
public sealed record InspectionFieldResult(
string CueSheetField,
string SceneObjectName,
string ExpectedText,
string ActualText,
double MatchRate,
string Severity,
string Decision);
public sealed record InspectionResult(
string CueSheetId,
string ProgramTitle,
string SceneName,
double MatchRate,
string Severity,
string Decision);
string Decision,
IReadOnlyList<InspectionFieldResult> FieldResults)
{
public InspectionResult(
string cueSheetId,
string programTitle,
string sceneName,
double matchRate,
string severity,
string decision)
: this(cueSheetId, programTitle, sceneName, matchRate, severity, decision, [])
{
}
}
}

View File

@@ -44,18 +44,28 @@ namespace TornadoAce_CJOnStyle.Services
private InspectionResult Inspect(CueSheetRecord cueSheet, SceneDataSnapshot snapshot, SceneTemplateDefinition template)
{
var expectedObjects = BuildVisibleTextObjects(cueSheet, template);
var fieldScores = expectedObjects
.Select(expected =>
var fieldResults = template.Mappings
.Select(mapping =>
{
snapshot.VisibleTextObjects.TryGetValue(expected.Key, out var actualValue);
var score = CalculateSimilarity(expected.Value, actualValue ?? string.Empty);
return new FieldInspectionScore(expected.Key, score);
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 = 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 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"
@@ -70,7 +80,7 @@ namespace TornadoAce_CJOnStyle.Services
_ => "1차 통과"
};
return new InspectionResult(cueSheet.CueSheetId, cueSheet.ProgramTitle, snapshot.SceneName, matchRate, severity, decision);
return new InspectionResult(cueSheet.CueSheetId, cueSheet.ProgramTitle, snapshot.SceneName, matchRate, severity, decision, fieldResults);
}
private SceneDataSnapshot GenerateExtractedScene(CueSheetRecord cueSheet, SceneTemplateDefinition template)
@@ -135,6 +145,26 @@ namespace TornadoAce_CJOnStyle.Services
|| 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);
@@ -186,8 +216,6 @@ namespace TornadoAce_CJOnStyle.Services
return previousRow[target.Length];
}
private sealed record FieldInspectionScore(string SceneObjectName, double Score);
}
public sealed record CueSheetSelection(CueSheetRecord Record, int Position, int TotalCount);

View File

@@ -45,6 +45,7 @@ namespace TornadoAce_CJOnStyle.ViewModels
LoadEndpoints();
LoadRequirementCoverage();
LoadInspectionQueue(initialInspectionSummary.Results);
LoadInspectionDetails(initialInspectionSummary.Results);
}
private string buildPhase = string.Empty;
@@ -128,6 +129,8 @@ namespace TornadoAce_CJOnStyle.ViewModels
public ObservableCollection<InspectionQueueItemViewModel> InspectionQueue { get; } = [];
public ObservableCollection<InspectionDetailItemViewModel> InspectionDetails { get; } = [];
public string LoginOperatorName
{
get => loginOperatorName;
@@ -209,6 +212,7 @@ namespace TornadoAce_CJOnStyle.ViewModels
StatusCards[2].Metric = $"{summary.AverageMatchRate:0.0}%";
StatusCards[2].Detail = $"확인 {summary.ReviewCount}건";
LoadInspectionQueue(summary.Results);
LoadInspectionDetails(summary.Results);
}
private void LoadStatusCards()
@@ -251,7 +255,7 @@ namespace TornadoAce_CJOnStyle.ViewModels
RequirementCoverage.Add(new PocRequirementViewModel("RFP-03", "Data Field Mapping", "PoC", "Scene 스냅샷 생성", Brush(0xFF37D67A)));
RequirementCoverage.Add(new PocRequirementViewModel("RFP-04", "Tornado Scene API", "PoC", "Text/Image 추출 샘플", Brush(0xFF37D67A)));
RequirementCoverage.Add(new PocRequirementViewModel("RFP-05", "관리자 매핑 기능", "초안", "매핑표 UI", Brush(0xFFB48CFF)));
RequirementCoverage.Add(new PocRequirementViewModel("RFP-06", "1·2단계 자동 검수", "PoC", "Diff + LLM 후보", Brush(0xFF45D6D1)));
RequirementCoverage.Add(new PocRequirementViewModel("RFP-06", "1·2단계 자동 검수", "PoC", "필드 상세 Diff + LLM 후보", Brush(0xFF45D6D1)));
}
private void LoadInspectionQueue(IEnumerable<InspectionResult> results)
@@ -270,6 +274,31 @@ namespace TornadoAce_CJOnStyle.ViewModels
}
}
private void LoadInspectionDetails(IEnumerable<InspectionResult> results)
{
InspectionDetails.Clear();
var detailItems = results
.SelectMany(result => result.FieldResults.Select(field => new { Result = result, Field = field }))
.Where(item => item.Field.MatchRate < 99.5)
.OrderBy(item => item.Field.MatchRate)
.ThenBy(item => item.Result.CueSheetId)
.Take(5);
foreach (var item in detailItems)
{
InspectionDetails.Add(new InspectionDetailItemViewModel(
item.Result.CueSheetId,
item.Field.SceneObjectName,
item.Field.ExpectedText,
item.Field.ActualText,
$"{item.Field.MatchRate:0.0}%",
item.Field.Severity,
item.Field.Decision,
SeverityBrush(item.Field.Severity)));
}
}
private void ApplySceneSnapshot(SceneDataSnapshot scene)
{
SceneSnapshotLabel = $"{scene.SceneName} / {scene.VisibleTextObjects.Count} text objects";

View File

@@ -74,4 +74,14 @@ namespace TornadoAce_CJOnStyle.ViewModels
string Severity,
string Decision,
SolidColorBrush SeverityBrush);
public sealed record InspectionDetailItemViewModel(
string CueSheetId,
string SceneObject,
string ExpectedText,
string ActualText,
string MatchRate,
string Severity,
string Decision,
SolidColorBrush SeverityBrush);
}

View File

@@ -529,38 +529,103 @@
BorderBrush="{StaticResource PanelStrokeBrush}"
BorderThickness="1"
CornerRadius="8">
<StackPanel Spacing="14">
<TextBlock Style="{StaticResource SectionTitleTextStyle}" Text="Scene 데이터 API 샘플" />
<Border
Padding="14"
Background="#101419"
BorderBrush="{StaticResource PanelStrokeBrush}"
BorderThickness="1"
CornerRadius="8">
<StackPanel Spacing="8">
<ScrollViewer VerticalScrollBarVisibility="Auto" VerticalScrollMode="Auto">
<StackPanel Spacing="14">
<TextBlock Style="{StaticResource SectionTitleTextStyle}" Text="Scene 데이터 API 샘플" />
<Border
Padding="14"
Background="#101419"
BorderBrush="{StaticResource PanelStrokeBrush}"
BorderThickness="1"
CornerRadius="8">
<StackPanel Spacing="8">
<TextBlock
Style="{StaticResource LabelTextStyle}"
Text="{x:Bind ViewModel.SceneSnapshotLabel, Mode=OneWay}" />
<TextBlock
FontFamily="Cascadia Mono"
FontSize="13"
Foreground="{StaticResource AccentCyanBrush}"
Text="{x:Bind ViewModel.SceneSnapshotText, Mode=OneWay}"
TextWrapping="WrapWholeWords" />
</StackPanel>
</Border>
<StackPanel Spacing="6">
<TextBlock Style="{StaticResource LabelTextStyle}" Text="LLM 검수 정책" />
<TextBlock
Style="{StaticResource LabelTextStyle}"
Text="{x:Bind ViewModel.SceneSnapshotLabel, Mode=OneWay}" />
FontFamily="Bahnschrift SemiBold"
FontSize="18"
Foreground="{StaticResource TextPrimaryBrush}"
Text="{x:Bind ViewModel.LlmPolicyText, Mode=OneWay}" />
<TextBlock
FontFamily="Cascadia Mono"
FontSize="13"
Foreground="{StaticResource AccentCyanBrush}"
Text="{x:Bind ViewModel.SceneSnapshotText, Mode=OneWay}"
TextWrapping="WrapWholeWords" />
Style="{StaticResource BodyTextStyle}"
Text="{x:Bind ViewModel.LlmPolicyDetail, Mode=OneWay}" />
</StackPanel>
</Border>
<StackPanel Spacing="6">
<TextBlock Style="{StaticResource LabelTextStyle}" Text="LLM 검수 정책" />
<TextBlock
FontFamily="Bahnschrift SemiBold"
FontSize="18"
Foreground="{StaticResource TextPrimaryBrush}"
Text="{x:Bind ViewModel.LlmPolicyText, Mode=OneWay}" />
<TextBlock
Style="{StaticResource BodyTextStyle}"
Text="{x:Bind ViewModel.LlmPolicyDetail, Mode=OneWay}" />
<Border
Height="1"
Background="{StaticResource PanelStrokeBrush}" />
<TextBlock Style="{StaticResource SectionTitleTextStyle}" Text="검수 상세" />
<ItemsControl ItemsSource="{x:Bind ViewModel.InspectionDetails, Mode=OneWay}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:InspectionDetailItemViewModel">
<Border
Margin="0,0,0,10"
Padding="10"
Background="{StaticResource PanelAltBrush}"
CornerRadius="8">
<StackPanel Spacing="7">
<Grid ColumnSpacing="8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel Spacing="2">
<TextBlock
FontFamily="Cascadia Mono"
FontSize="12"
Foreground="{StaticResource TextMutedBrush}"
Text="{x:Bind CueSheetId}" />
<TextBlock
FontFamily="Bahnschrift SemiBold"
Foreground="{StaticResource TextPrimaryBrush}"
Text="{x:Bind SceneObject}" />
</StackPanel>
<Border
Grid.Column="1"
Padding="8,3"
Background="{x:Bind SeverityBrush}"
CornerRadius="8">
<TextBlock
FontFamily="Bahnschrift SemiBold"
FontSize="12"
Foreground="#071013"
Text="{x:Bind Severity}" />
</Border>
</Grid>
<TextBlock
Style="{StaticResource LabelTextStyle}"
Text="{x:Bind MatchRate}" />
<TextBlock
Style="{StaticResource BodyTextStyle}"
Text="{x:Bind ExpectedText}"
TextTrimming="CharacterEllipsis" />
<TextBlock
Style="{StaticResource BodyTextStyle}"
Foreground="{StaticResource AccentCyanBrush}"
Text="{x:Bind ActualText}"
TextTrimming="CharacterEllipsis" />
<TextBlock
Style="{StaticResource LabelTextStyle}"
Text="{x:Bind Decision}" />
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</StackPanel>
</ScrollViewer>
</Border>
</Grid>
</Grid>

View File

@@ -18,8 +18,8 @@
| RFP-03 | CG 포맷 제작 자동화, Data Field Mapping | 큐시트 필드와 Scene 객체 매핑 구현 | `SceneTemplateDefinition`, `PocAutomationEngine.GenerateScene` |
| RFP-04 | Tornado Scene API | Scene visible Text/Image 스냅샷 샘플 구현 | `SceneDataSnapshot.ToPreviewJson` |
| RFP-05 | 큐시트 데이터와 Tornado Scene 연동 관리자 기능 | 매핑표 UI 초안 구현 | `FieldMappings` 화면 |
| RFP-06 | 자동 검수 1단계 Text Diffing | 문자열 정규화/유사도 기반 비교 구현 | `PocAutomationEngine.InspectWorkspace` |
| RFP-07 | 자동 검수 2단계 LLM 중요도 피드백 | LLM 검토 후보 분류와 정책 문구 구현 | 검수 결과 `Severity`, `Decision`, LLM 정책 패널 |
| RFP-06 | 자동 검수 1단계 Text Diffing | 필드별 기대값/실제값/일치율 비교 구현 | `PocAutomationEngine.InspectWorkspace`, `InspectionFieldResult` |
| RFP-07 | 자동 검수 2단계 LLM 중요도 피드백 | LLM 검토 후보 분류와 제작자 상세 UI 구현 | 검수 결과 `Severity`, `Decision`, `InspectionDetails` |
| RFP-08 | 제작자가 결과를 볼 수 있는 GUI | WinUI 3 운영 콘솔 초안 구현 | `MainPage.xaml` |
## 초기 PoC 완료 기준
@@ -35,5 +35,5 @@
- 큐시트/ERP Mock 데이터를 JSON 파일로 분리하여 실제 API 응답 구조와 맞춘다.
- Tornado2/Tornado3 어댑터를 동일 인터페이스로 유지하고, 버전별 구현체를 분리한다.
- Scene 내부 비노출 객체를 구분할 메타데이터 규칙을 정의한다.
- 1차 검수 실패 사유를 필드 단위로 화면에 표시한다.
- 1차 검수 실패 사유를 필드 단위로 누적하고 LLM 요청 후보로 전달한다.
- LLM 요청/응답 스키마와 감사 로그 보관 정책을 별도 모델로 둔다.