1
0
Fork 0

feat(P1): 변환 그래프 엔진 + 멀티홉 경로탐색 + PDF 압축

ProviderRegistry를 ConversionGraph로 승격하고 ConversionEngine이 자체 Dijkstra로 멀티홉 경로를 자동 합성하도록 전환. 손실을 LossClass 가중치로 SSOT화.

- ConversionGraph: (node,hop) 라벨링 Dijkstra로 홉 제약 최단경로 (일반 Dijkstra의 hops 오염 false negative 회피)
- ConversionEngine.ExecuteChainAsync: 멀티홉 체이닝, 중간 다중출력 거부, 마지막 홉 Status 존중
- PdfToolProvider: pdf->pdf 구조 압축 (PDFsharp, Save 원자화)
- PairsFromMatrix 자기쌍 제외 (png->png 재인코딩 회귀 방지), PDF->이미지 Rasterize 표시
- xUnit 테스트 프로젝트 신설 + 그래프/회귀 14개 통과
- 적대적 병렬 리뷰(27 agents)로 확정된 13건 중 정확성 핵심 수정

SSOT: P1 done
This commit is contained in:
Yun Chan 2026-06-01 12:48:58 +09:00
parent 232f453e15
commit e3ef59e009
16 changed files with 641 additions and 30 deletions

View file

@ -85,11 +85,15 @@ public sealed class ConversionEngine
var output = ConversionPair.Normalize(outputExtension);
var inputExt = ConversionPair.Normalize(Path.GetExtension(sourcePath));
if (string.Equals(inputExt, output, StringComparison.OrdinalIgnoreCase))
return ConvertResult.Skip(sourcePath, "입력과 출력 형식이 동일해 변환이 필요하지 않습니다.");
// 그래프 경로 탐색: 직접 엣지가 있으면 1홉, 없으면 손실 가중치 기반 멀티홉을 자동 합성.
var maxHops = options.AllowMultiHop ? Math.Max(1, options.MaxHops) : 1;
var path = _registry.Graph.FindBestPath(inputExt, output, maxHops, !options.AvoidLossy);
if (!_registry.TryGet(sourcePath, output, out var provider) || provider is null)
if (path is null || path.Count == 0)
{
if (string.Equals(inputExt, output, StringComparison.OrdinalIgnoreCase))
return ConvertResult.Skip(sourcePath, "입력과 출력 형식이 동일해 변환이 필요하지 않습니다.");
var available = _registry.OutputsForFile(sourcePath);
var hint = available.Count > 0
? $" 가능한 출력: {string.Join(", ", available)}"
@ -97,30 +101,117 @@ public sealed class ConversionEngine
return ConvertResult.Fail(sourcePath, $"{inputExt} → {output} 변환을 지원하지 않습니다.{hint}");
}
var availability = await provider.CheckAvailabilityAsync(cancellationToken).ConfigureAwait(false);
if (!availability.IsReady)
return await ExecuteChainAsync(sourcePath, output, path, options, progress, cancellationToken)
.ConfigureAwait(false);
}
/// <summary>
/// 변환 경로(1홉 또는 멀티홉)를 순차 실행한다. 중간 산출물은 임시 작업폴더에 체이닝하고
/// 마지막 홉만 실제 출력 폴더에 쓴다. 각 홉 시작 전 Provider 가용성을 점검한다.
/// </summary>
private async Task<ConvertResult> ExecuteChainAsync(
string sourcePath,
string finalOutputExtension,
IReadOnlyList<ConversionGraph.Edge> path,
ConvertOptions options,
IProgress<double>? progress,
CancellationToken cancellationToken)
{
foreach (var edge in path)
{
var missing = availability.MissingDependencies?.Select(d => d.Name) ?? Array.Empty<string>();
var detail = availability.Reason ?? "필수 의존성이 준비되지 않았습니다.";
if (missing.Any()) detail += $" (필요: {string.Join(", ", missing)})";
return ConvertResult.Fail(sourcePath, detail);
var availability = await edge.Provider.CheckAvailabilityAsync(cancellationToken).ConfigureAwait(false);
if (!availability.IsReady)
{
var missing = availability.MissingDependencies?.Select(d => d.Name) ?? Array.Empty<string>();
var detail = availability.Reason ?? "필수 의존성이 준비되지 않았습니다.";
if (missing.Any()) detail += $" (필요: {string.Join(", ", missing)})";
return ConvertResult.Fail(sourcePath, detail);
}
}
var outputDir = ResolveOutputDirectory(sourcePath, output, options);
Directory.CreateDirectory(outputDir);
// 단일 홉 — 기존 동작과 동일 (출력 폴더 해결 후 직접 위임)
if (path.Count == 1)
{
var outputDir = ResolveOutputDirectory(sourcePath, finalOutputExtension, options);
Directory.CreateDirectory(outputDir);
try
{
return await path[0].Provider
.ConvertAsync(sourcePath, outputDir, finalOutputExtension, options, progress, cancellationToken)
.ConfigureAwait(false);
}
catch (OperationCanceledException) { throw; }
catch (Exception ex) { return ConvertResult.Fail(sourcePath, ex.Message, ex); }
}
// 멀티홉 — 중간 산출물은 임시 폴더, 마지막만 실제 출력
var workRoot = Path.Combine(Path.GetTempPath(), "e2e_" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(workRoot);
try
{
return await provider.ConvertAsync(sourcePath, outputDir, output, options, progress, cancellationToken)
.ConfigureAwait(false);
var current = sourcePath;
for (var h = 0; h < path.Count; h++)
{
cancellationToken.ThrowIfCancellationRequested();
var edge = path[h];
var isLast = h == path.Count - 1;
var hopExt = edge.To;
var outDir = isLast
? ResolveOutputDirectory(sourcePath, finalOutputExtension, options)
: Path.Combine(workRoot, "h" + h.ToString());
Directory.CreateDirectory(outDir);
var hopIndex = h;
var hopProgress = new Progress<double>(p =>
progress?.Report((hopIndex + Math.Clamp(p, 0, 1)) / path.Count));
ConvertResult result;
try
{
result = await edge.Provider
.ConvertAsync(current, outDir, hopExt, options, hopProgress, cancellationToken)
.ConfigureAwait(false);
}
catch (OperationCanceledException) { throw; }
catch (Exception ex)
{
return ConvertResult.Fail(sourcePath,
$"{h + 1}/{path.Count}단계 ({edge.From} → {edge.To}) 실패: {ex.Message}", ex);
}
if (isLast)
{
// 마지막 홉은 상태를 그대로 존중 (정상 Skip을 Fail로 둔갑시키지 않음)
return result.Status switch
{
ConvertStatus.Success => ConvertResult.Ok(sourcePath, result.OutputPaths),
ConvertStatus.Skipped => ConvertResult.Skip(sourcePath, result.Message ?? "건너뛰었습니다."),
_ => ConvertResult.Fail(sourcePath,
$"{h + 1}/{path.Count}단계 ({edge.From} → {edge.To}) 실패: {result.Message ?? " ."}", result.Error),
};
}
// 중간 홉은 단일 산출물이어야 다음 홉으로 체이닝할 수 있다.
if (result.Status != ConvertStatus.Success || result.OutputPaths.Count == 0)
{
return ConvertResult.Fail(sourcePath,
$"{h + 1}/{path.Count}단계 ({edge.From} → {edge.To}) 실패: {result.Message ?? " ."}");
}
if (result.OutputPaths.Count > 1)
{
// 다중 산출물(예: PDF→페이지별 이미지)을 중간 홉으로 두면 나머지가 유실된다 — 명시적 거부.
return ConvertResult.Fail(sourcePath,
$"{h + 1}/{path.Count}단계 ({edge.From} → {edge.To})가 여러 파일을 생성해 자동 합성된 다음 단계로 이어갈 수 없습니다. 직접 변환 경로를 사용하거나 단계를 나눠 실행하세요.");
}
current = result.OutputPaths[0]; // 다음 홉의 입력
}
return ConvertResult.Fail(sourcePath, "변환 체인 실행에 실패했습니다.");
}
catch (OperationCanceledException)
finally
{
throw;
}
catch (Exception ex)
{
return ConvertResult.Fail(sourcePath, ex.Message, ex);
try { Directory.Delete(workRoot, true); } catch { /* 임시 정리 실패 무시 */ }
}
}