diff --git a/Controllers/TransformationExecutionController.cs b/Controllers/TransformationExecutionController.cs index 93b8704..2c45f8e 100644 --- a/Controllers/TransformationExecutionController.cs +++ b/Controllers/TransformationExecutionController.cs @@ -174,6 +174,15 @@ public async Task ExecuteTransformation([FromBody] Transformation /// de infra), o fallback automático de IA é disparado em background (loop gerar→validar→ /// corrigir via Ollama), sujeito a cooldown de 4h por LayoutGuid; ver /// para acompanhar o resultado. + /// + /// Issue LayoutParserReact #86 (diagnóstico estruturado): a resposta traz, de forma + /// ADITIVA (não quebra clientes existentes), + /// — um por pathway avaliado (sysmiddle/tcl-xsl/ + /// ai-fallback), com status/code/message — e + /// , que permite ao suporte + /// cruzar a resposta HTTP com o log estruturado completo (não sanitizado) desta chamada. Toda + /// Message nesse array já passou por + /// — nunca contém caminho físico de disco ou detalhe interno cru. /// // Issue #32: dispara processos externos (runner x86) e é operação privilegiada — era // restrita ao papel "admin". Issue #93: reabre para qualquer usuário autenticado (o @@ -250,8 +259,9 @@ public async Task ExecuteTransformationCandidates([FromBody] Tran // failureKinds: classificação interna (§2 do design-fallback-ia-automatico) coletada na // ORIGEM de cada pathway — nunca inferida depois por regex sobre warning já sanitizado. var failureKinds = new ConcurrentBag(); - var sysmiddleTask = ExecuteSysmiddleCandidatesAsync(request, layoutRecord, isXmlInput, warnings, failureKinds, candidatesCts.Token); - var tclXslTask = ExecuteTclXslCandidatesAsync(request, isXmlInput, warnings, failureKinds); + var pathwayDiagnostics = new ConcurrentBag(); + var sysmiddleTask = ExecuteSysmiddleCandidatesAsync(request, layoutRecord, isXmlInput, warnings, failureKinds, pathwayDiagnostics, candidatesCts.Token); + var tclXslTask = ExecuteTclXslCandidatesAsync(request, isXmlInput, warnings, failureKinds, pathwayDiagnostics); var allTask = Task.WhenAll(sysmiddleTask, tclXslTask); var winner = await Task.WhenAny(allTask, Task.Delay(TimeSpan.FromSeconds(overallTimeoutSeconds))); @@ -285,7 +295,7 @@ public async Task ExecuteTransformationCandidates([FromBody] Tran // falhou por infra (Estado B) — aí a correção é operacional, não de transformação, e a // IA nunca deveria tentar "recriar" um mapper que já existe e está correto. if (candidates.Count == 0) - TryEnqueueAiFallback(request, layoutRecord, isXmlInput, failureKinds, warnings, CurrentUserId); + TryEnqueueAiFallback(request, layoutRecord, isXmlInput, failureKinds, warnings, pathwayDiagnostics, CurrentUserId); string? recommendedId = null; if (candidates.Count > 0) @@ -299,7 +309,11 @@ public async Task ExecuteTransformationCandidates([FromBody] Tran Success = true, Candidates = candidates, RecommendedCandidateId = recommendedId, - Warnings = warnings + Warnings = warnings, + // pathwayDiagnostics (Issue #86): populado na origem por cada pathway (sysmiddle, + // tcl-xsl, ai-fallback) — ver docs/architecture/diagnostico-issue-86-*.md §4. + PathwayDiagnostics = pathwayDiagnostics.ToList(), + CorrelationId = Services.Logging.CorrelationContext.CurrentId }); } @@ -315,22 +329,46 @@ public async Task ExecuteTransformationCandidates([FromBody] Tran /// private async Task> ExecuteSysmiddleCandidatesAsync( TransformationRequest request, LayoutRecord layoutRecord, bool isXmlInput, List warnings, - ConcurrentBag failureKinds, CancellationToken cancellationToken) + ConcurrentBag failureKinds, ConcurrentBag pathwayDiagnostics, + CancellationToken cancellationToken) { var result = new List(); // Sysmiddle/low-code espera texto posicional (TXT), não XML — não é uma falha do // pathway, é entrada fora de escopo (a IA não deveria disparar por causa disso). if (isXmlInput) + { + _logger.LogInformation( + "PathwayDiagnostic {CorrelationId}: pathway={Pathway} status={Status} code={Code} layout={LayoutName} motivo=entrada XML fora do escopo do pathway sysmiddle", + Services.Logging.CorrelationContext.CurrentId, "sysmiddle", "not_applicable", "not_applicable", request.LayoutName); + pathwayDiagnostics.Add(new Models.Transformation.PathwayDiagnostic + { + Pathway = "sysmiddle", + Status = "not_applicable", + Code = "not_applicable", + Message = "Entrada XML — pathway sysmiddle espera texto posicional (TXT)" + }); return result; + } try { var resolvedLayoutGuid = LowCodeLayoutGuidResolver.Resolve(request.LayoutGuid, layoutRecord.LayoutGuid); if (resolvedLayoutGuid == null) { - warnings.Add($"Layout {request.LayoutName} sem LayoutGuid válido no request ou no catálogo — pathway sysmiddle não aplicável"); + var msg = $"Layout {request.LayoutName} sem LayoutGuid válido no request ou no catálogo — pathway sysmiddle não aplicável"; + warnings.Add(msg); failureKinds.Add(FailureKind.NotApplicable); + _logger.LogInformation( + "PathwayDiagnostic {CorrelationId}: pathway={Pathway} status={Status} code={Code} layout={LayoutName} fonte=request.LayoutGuid/catalogo (nenhum resolvível)", + Services.Logging.CorrelationContext.CurrentId, "sysmiddle", "not_applicable", "not_applicable", request.LayoutName); + pathwayDiagnostics.Add(new Models.Transformation.PathwayDiagnostic + { + Pathway = "sysmiddle", + Status = "not_applicable", + Code = "not_applicable", + Message = msg + }); return result; } @@ -348,13 +386,26 @@ private async Task> ExecuteSysmiddleCandidatesAsyn if (!autoResult.Applicable) { - warnings.Add($"Nenhum mapeador low-code encontrado para o layout {request.LayoutName} (pathway sysmiddle)"); + var msgNoMapper = $"Nenhum mapeador low-code encontrado para o layout {request.LayoutName} (pathway sysmiddle)"; + warnings.Add(msgNoMapper); // Estado A (§2 do design-fallback-ia-automatico): não existe mapper cadastrado // para este layout — gap real de cobertura, elegível ao fallback de IA. failureKinds.Add(FailureKind.NotApplicable); + _logger.LogInformation( + "PathwayDiagnostic {CorrelationId}: pathway={Pathway} status={Status} code={Code} layout={LayoutName} layoutGuid={LayoutGuid} fonte=catalogo (consulta a mapeadores low-code sem resultado)", + Services.Logging.CorrelationContext.CurrentId, "sysmiddle", "not_applicable", "no_mapper", request.LayoutName, resolvedLayoutGuid); + pathwayDiagnostics.Add(new Models.Transformation.PathwayDiagnostic + { + Pathway = "sysmiddle", + Status = "not_applicable", + Code = "no_mapper", + Message = msgNoMapper + }); return result; } + var anyCandidateFailed = false; + string lastCandidateFailureMessage = null; foreach (var c in autoResult.Candidates) { if (c.Success && !string.IsNullOrEmpty(c.OutputXml)) @@ -372,19 +423,61 @@ private async Task> ExecuteSysmiddleCandidatesAsyn // vira warning (ver tabela de decisão do contrato). Estado B (§2 do design): // o mapper EXISTE (Applicable==true) mas a execução falhou — é infra/config // (runner, timeout, .exe ausente), não gap de cobertura. Nunca dispara IA. - warnings.Add($"Candidato {c.MapperGuid} (pathway sysmiddle) falhou: {c.ErrorMessage ?? "erro desconhecido"}"); + var sanitizedCandidateError = LowCodeErrorSanitizer.ForWire(c.ErrorMessage ?? "erro desconhecido"); + anyCandidateFailed = true; + lastCandidateFailureMessage = sanitizedCandidateError; + warnings.Add($"Candidato {c.MapperGuid} (pathway sysmiddle) falhou: {sanitizedCandidateError}"); failureKinds.Add(FailureKind.ExecutionInfraError); } } + + if (result.Count > 0) + { + _logger.LogInformation( + "PathwayDiagnostic {CorrelationId}: pathway={Pathway} status={Status} layout={LayoutName} layoutGuid={LayoutGuid} candidatos={CandidateCount} fonte=mapeadores low-code do catalogo", + Services.Logging.CorrelationContext.CurrentId, "sysmiddle", "candidate_generated", request.LayoutName, resolvedLayoutGuid, result.Count); + pathwayDiagnostics.Add(new Models.Transformation.PathwayDiagnostic + { + Pathway = "sysmiddle", + Status = "candidate_generated", + Code = null, + Message = $"{result.Count} candidato(s) sysmiddle gerado(s)" + }); + } + else if (anyCandidateFailed) + { + // autoResult.Applicable == true (mapper existe) mas TODOS os candidatos + // falharam na execução — infra/runner, não gap de cobertura (§4.3 "runner_unavailable"). + _logger.LogWarning( + "PathwayDiagnostic {CorrelationId}: pathway={Pathway} status={Status} code={Code} layout={LayoutName} layoutGuid={LayoutGuid} fonte=execução do runner (mapper existe, execução falhou)", + Services.Logging.CorrelationContext.CurrentId, "sysmiddle", "failed", "runner_unavailable", request.LayoutName, resolvedLayoutGuid); + pathwayDiagnostics.Add(new Models.Transformation.PathwayDiagnostic + { + Pathway = "sysmiddle", + Status = "failed", + Code = "runner_unavailable", + Message = lastCandidateFailureMessage ?? "Todos os candidatos sysmiddle falharam na execução" + }); + } } catch (Exception ex) { - _logger.LogWarning(ex, "Falha estrutural no pathway sysmiddle ao gerar candidatos para layout {LayoutName}", request.LayoutName); + _logger.LogWarning(ex, + "Falha estrutural no pathway sysmiddle ao gerar candidatos para layout {LayoutName}. PathwayDiagnostic {CorrelationId}: pathway={Pathway} status={Status} code={Code}", + request.LayoutName, Services.Logging.CorrelationContext.CurrentId, "sysmiddle", "failed", "execution_error"); // Saneado: exceção de I/O deste pathway carrega caminho de disco do servidor e este // warning sai no payload 200 (mesmo defeito do §3.1 da spec, outro ponto de saída). - warnings.Add($"Pathway sysmiddle falhou: {LowCodeErrorSanitizer.ForWire(ex)}"); + var sanitizedEx = LowCodeErrorSanitizer.ForWire(ex); + warnings.Add($"Pathway sysmiddle falhou: {sanitizedEx}"); // Falha estrutural (exceção) é sempre infra, não "não modelado" — nunca dispara IA. failureKinds.Add(FailureKind.ExecutionInfraError); + pathwayDiagnostics.Add(new Models.Transformation.PathwayDiagnostic + { + Pathway = "sysmiddle", + Status = "failed", + Code = "execution_error", + Message = sanitizedEx + }); } return result; @@ -442,34 +535,71 @@ private void TryEnqueueAiCandidate( /// private void TryEnqueueAiFallback( TransformationRequest request, LayoutRecord layoutRecord, bool isXmlInput, - ConcurrentBag failureKinds, List warnings, string userId) + ConcurrentBag failureKinds, List warnings, + ConcurrentBag pathwayDiagnostics, string userId) { try { if (failureKinds.Any(k => k == FailureKind.ExecutionInfraError)) { // Estado B: já existe o warning de infra específico emitido pelo pathway que - // falhou — nada a acrescentar aqui, só não disparar a IA (§2 do desenho). + // falhou (e já virou pathwayDiagnostics próprio de sysmiddle/tcl-xsl) — nada a + // acrescentar aqui, só não disparar a IA (§2 do desenho). Não emite um 3º + // diagnóstico "ai-fallback: not_applicable" para não duplicar sinal — o front já + // tem os itens failed de quem realmente quebrou. return; } var resolvedLayoutGuidText = LowCodeLayoutGuidResolver.Resolve(request.LayoutGuid, layoutRecord.LayoutGuid); if (resolvedLayoutGuidText == null || !Guid.TryParse(resolvedLayoutGuidText, out var resolvedLayoutGuid)) { - warnings.Add($"Layout {request.LayoutName} sem LayoutGuid válido — fallback de IA não aplicável"); + var msg = $"Layout {request.LayoutName} sem LayoutGuid válido — fallback de IA não aplicável"; + warnings.Add(msg); + _logger.LogInformation( + "PathwayDiagnostic {CorrelationId}: pathway={Pathway} status={Status} code={Code} layout={LayoutName} fonte=request.LayoutGuid/catalogo (nenhum resolvível)", + Services.Logging.CorrelationContext.CurrentId, "ai-fallback", "not_applicable", "not_applicable", request.LayoutName); + pathwayDiagnostics.Add(new Models.Transformation.PathwayDiagnostic + { + Pathway = "ai-fallback", + Status = "not_applicable", + Code = "not_applicable", + Message = msg + }); return; } if (_aiFallbackGate.IsInCooldown(resolvedLayoutGuid, out var retryAt)) { - warnings.Add($"Pathway IA fallback suprimido para este layout até {retryAt:HH:mm} (já tentado sem sucesso)"); + var msg = $"Pathway IA fallback suprimido para este layout até {retryAt:HH:mm} (já tentado sem sucesso)"; + warnings.Add(msg); + _logger.LogInformation( + "PathwayDiagnostic {CorrelationId}: pathway={Pathway} status={Status} code={Code} layout={LayoutName} layoutGuid={LayoutGuid} fonte=IAiFallbackSuppressionGate (cooldown ativo até {RetryAt})", + Services.Logging.CorrelationContext.CurrentId, "ai-fallback", "not_applicable", "not_applicable", request.LayoutName, resolvedLayoutGuid, retryAt); + pathwayDiagnostics.Add(new Models.Transformation.PathwayDiagnostic + { + Pathway = "ai-fallback", + Status = "not_applicable", + Code = "not_applicable", + Message = msg + }); return; } var ticket = LowCodeTransformationStore.BuildTicketFromContent(request.InputContent, resolvedLayoutGuidText); if (ticket == null) { - warnings.Add($"Layout {request.LayoutName}: não foi possível compor o ticket do fallback de IA"); + var msg = $"Layout {request.LayoutName}: não foi possível compor o ticket do fallback de IA"; + warnings.Add(msg); + _logger.LogWarning( + "PathwayDiagnostic {CorrelationId}: pathway={Pathway} status={Status} code={Code} layout={LayoutName} layoutGuid={LayoutGuid} fonte=LowCodeTransformationStore.BuildTicketFromContent (retornou null)", + Services.Logging.CorrelationContext.CurrentId, "ai-fallback", "failed", "configuration_error", request.LayoutName, resolvedLayoutGuid); + pathwayDiagnostics.Add(new Models.Transformation.PathwayDiagnostic + { + Pathway = "ai-fallback", + Status = "failed", + Code = "configuration_error", + Message = msg + }); return; } @@ -486,11 +616,35 @@ private void TryEnqueueAiFallback( groundTruthXml: null, CancellationToken.None); - warnings.Add($"Nenhum candidato de transformação encontrado — fallback automático de IA enfileirado (ticket {ticket}), consulte GET execute-candidates/{ticket}/ia-status"); + var enqueuedMsg = $"Nenhum candidato de transformação encontrado — fallback automático de IA enfileirado (ticket {ticket}), consulte GET execute-candidates/{ticket}/ia-status"; + warnings.Add(enqueuedMsg); + // "candidate_generated" no sentido de que o pathway produziu um item consultável + // (ticket assíncrono) — não um XML pronto, mas o front tem o que fazer com ele + // (§4.2 do desenho: "inclui o ticket assíncrono do fallback de IA, que 'gera' no + // sentido de estar em processamento"). + _logger.LogInformation( + "PathwayDiagnostic {CorrelationId}: pathway={Pathway} status={Status} layout={LayoutName} layoutGuid={LayoutGuid} ticket={Ticket} fonte=IAiTransformationCandidateService.EnqueueAsync (sem gabarito)", + Services.Logging.CorrelationContext.CurrentId, "ai-fallback", "candidate_generated", request.LayoutName, resolvedLayoutGuid, ticket); + pathwayDiagnostics.Add(new Models.Transformation.PathwayDiagnostic + { + Pathway = "ai-fallback", + Status = "candidate_generated", + Code = null, + Message = enqueuedMsg + }); } catch (Exception ex) { - _logger.LogWarning(ex, "Falha ao disparar o fallback automático de IA para layout {LayoutName}", request.LayoutName); + _logger.LogWarning(ex, + "Falha ao disparar o fallback automático de IA para layout {LayoutName}. PathwayDiagnostic {CorrelationId}: pathway={Pathway} status={Status} code={Code}", + request.LayoutName, Services.Logging.CorrelationContext.CurrentId, "ai-fallback", "failed", "execution_error"); + pathwayDiagnostics.Add(new Models.Transformation.PathwayDiagnostic + { + Pathway = "ai-fallback", + Status = "failed", + Code = "execution_error", + Message = LowCodeErrorSanitizer.ForWire(ex) + }); } } @@ -526,7 +680,8 @@ public async Task GetAiCandidateStatus(string ticket, Cancellatio /// noção de múltiplos TCL/XSL candidatos para o mesmo layout). /// private async Task> ExecuteTclXslCandidatesAsync( - TransformationRequest request, bool isXmlInput, List warnings, ConcurrentBag failureKinds) + TransformationRequest request, bool isXmlInput, List warnings, ConcurrentBag failureKinds, + ConcurrentBag pathwayDiagnostics) { var result = new List(); @@ -545,9 +700,32 @@ private async Task> ExecuteTclXslCandidatesAsync( if (!pipelineResult.Success || string.IsNullOrEmpty(pipelineResult.TransformedXml)) { - warnings.Add($"Candidato tcl-xsl falhou: {string.Join("; ", pipelineResult.Errors)}"); + // Saneado (§5 do diagnóstico-issue-86): pipelineResult.Errors pode carregar + // caminho de disco cru (IOException/XmlException internos do pipeline). + var sanitizedTclXslError = LowCodeErrorSanitizer.ForWire(string.Join("; ", pipelineResult.Errors)); + warnings.Add($"Candidato tcl-xsl falhou: {sanitizedTclXslError}"); // "Sem heurística aplicável" para este layout — Estado A (§2 do design). failureKinds.Add(FailureKind.NotApplicable); + + // Issue #86 §2.4: distingue "arquivo MAP não encontrado" de "arquivo XSL não + // encontrado" pelo ErrorCode populado na origem (TransformationPipelineService), + // não por regex sobre a mensagem já sanitizada. + var code = pipelineResult.ErrorCode switch + { + "map_not_found" => "map_not_found", + "xsl_not_found" => "xsl_not_found", + _ => "map_not_found" // fallback conservador: maioria dos casos "não aplicável" hoje é ausência de MAP + }; + _logger.LogWarning( + "PathwayDiagnostic {CorrelationId}: pathway={Pathway} status={Status} code={Code} layout={LayoutName} fonte=TransformationPipelineService.ErrorCode={ErrorCode}", + Services.Logging.CorrelationContext.CurrentId, "tcl-xsl", "failed", code, request.LayoutName, pipelineResult.ErrorCode); + pathwayDiagnostics.Add(new Models.Transformation.PathwayDiagnostic + { + Pathway = "tcl-xsl", + Status = "failed", + Code = code, + Message = sanitizedTclXslError + }); return result; } @@ -568,7 +746,8 @@ private async Task> ExecuteTclXslCandidatesAsync( // Falha de validação não invalida o candidato em si (o XML transformado existe) — // só fica sem o campo Validation preenchido. _logger.LogWarning(ex, "Falha ao validar candidato tcl-xsl para layout {LayoutName}", request.LayoutName); - warnings.Add($"Validação do candidato tcl-xsl falhou: {ex.Message}"); + // Saneado (§5 do diagnóstico-issue-86): mesmo padrão do sysmiddle (linha ~385). + warnings.Add($"Validação do candidato tcl-xsl falhou: {LowCodeErrorSanitizer.ForWire(ex)}"); } } @@ -580,13 +759,36 @@ private async Task> ExecuteTclXslCandidatesAsync( SegmentMappings = pipelineResult.SegmentMappings?.ToDictionary(kv => kv.Key.ToString(), kv => kv.Value), Validation = validation }); + + _logger.LogInformation( + "PathwayDiagnostic {CorrelationId}: pathway={Pathway} status={Status} layout={LayoutName} tclPath={TclPath} xslPath={XslPath} fonte=TransformationPipelineService", + Services.Logging.CorrelationContext.CurrentId, "tcl-xsl", "candidate_generated", request.LayoutName, + System.IO.Path.GetFileName(pipelineResult.TclPath), System.IO.Path.GetFileName(pipelineResult.XslPath)); + pathwayDiagnostics.Add(new Models.Transformation.PathwayDiagnostic + { + Pathway = "tcl-xsl", + Status = "candidate_generated", + Code = null, + Message = "Candidato tcl-xsl gerado com sucesso" + }); } catch (Exception ex) { - _logger.LogWarning(ex, "Falha estrutural no pathway tcl-xsl ao gerar candidato para layout {LayoutName}", request.LayoutName); - warnings.Add($"Pathway tcl-xsl falhou: {ex.Message}"); + _logger.LogWarning(ex, + "Falha estrutural no pathway tcl-xsl ao gerar candidato para layout {LayoutName}. PathwayDiagnostic {CorrelationId}: pathway={Pathway} status={Status} code={Code}", + request.LayoutName, Services.Logging.CorrelationContext.CurrentId, "tcl-xsl", "failed", "execution_error"); + // Saneado (§5 do diagnóstico-issue-86): mesmo padrão do sysmiddle (linha ~385). + var sanitizedTclXslEx = LowCodeErrorSanitizer.ForWire(ex); + warnings.Add($"Pathway tcl-xsl falhou: {sanitizedTclXslEx}"); // Exceção estrutural é infra, não "não modelado" — nunca dispara IA. failureKinds.Add(FailureKind.ExecutionInfraError); + pathwayDiagnostics.Add(new Models.Transformation.PathwayDiagnostic + { + Pathway = "tcl-xsl", + Status = "failed", + Code = "execution_error", + Message = sanitizedTclXslEx + }); } return result; diff --git a/Models/Transformation/PathwayDiagnostic.cs b/Models/Transformation/PathwayDiagnostic.cs new file mode 100644 index 0000000..69e27d2 --- /dev/null +++ b/Models/Transformation/PathwayDiagnostic.cs @@ -0,0 +1,32 @@ +namespace LayoutParserApi.Models.Transformation +{ + /// + /// Diagnóstico estruturado por pathway de POST /api/transformation-execution/execute-candidates + /// (Issue LayoutParserReact #86). Campo ADITIVO — não substitui , + /// que continua populado exatamente como hoje por compatibilidade. + /// + /// Ver desenho completo em + /// docs/architecture/diagnostico-issue-86-diagnostico-estruturado-execute-candidates.md §4. + /// Este arquivo só define a estrutura; a população dos valores por pathway (sysmiddle/tcl-xsl/ + /// ai-fallback) é feita por quem já monta warnings/failureKinds hoje. + /// + public class PathwayDiagnostic + { + /// "sysmiddle" | "tcl-xsl" | "ai-fallback". + public string Pathway { get; set; } = ""; + + /// "candidate_generated" | "not_applicable" | "failed" (§4.2 do desenho). + /// String, não enum exposto — permite adicionar valores sem quebrar o contrato. + public string Status { get; set; } = ""; + + /// Taxonomia estável: "no_mapper" | "map_not_found" | "xsl_not_found" | + /// "configuration_error" | "runner_unavailable" | "execution_error" | "not_applicable" + /// (§4.3 do desenho). String, não enum exposto, pelo mesmo motivo de . + public string Code { get; set; } = ""; + + /// Mensagem legível para o front. SEMPRE passada por + /// antes de + /// chegar aqui — nunca caminho de disco/detalhe interno cru (§5 do desenho). + public string Message { get; set; } = ""; + } +} diff --git a/Models/Transformation/TransformationCandidate.cs b/Models/Transformation/TransformationCandidate.cs index 28d86b5..3388592 100644 --- a/Models/Transformation/TransformationCandidate.cs +++ b/Models/Transformation/TransformationCandidate.cs @@ -33,5 +33,15 @@ public class TransformationExecutionCandidatesResponse public List Candidates { get; set; } = new(); public string? RecommendedCandidateId { get; set; } public List Warnings { get; set; } = new(); + + /// Diagnóstico estruturado por pathway (Issue LayoutParserReact #86) — ADITIVO, + /// não substitui . Vazio hoje: a população dos valores por pathway + /// (sysmiddle/tcl-xsl/ai-fallback) é feita em cima desta estrutura, ver + /// . + public List PathwayDiagnostics { get; set; } = new(); + + /// CorrelationId da request (), + /// permite ao suporte cruzar com o log estruturado completo (não sanitizado) desta chamada. + public string? CorrelationId { get; set; } } } diff --git a/README.md b/README.md index 9ee3dbc..45d8383 100644 --- a/README.md +++ b/README.md @@ -325,6 +325,48 @@ Without a `groundTruthXml` (State A, "generate from scratch"), the convergence c | `failed` | Yes — **new in this contract** | Candidates exist, but **none** succeeded — structural failure of the set. Previously this came back as `completed` with every candidate `success=false`, forcing the front to scan the array to infer failure. | | `not_applicable` / `error` | Yes (only in `/api/parse/upload`'s synchronous response) | `not_applicable`: pathway not eligible (no mapper, non-positional type, empty input). `error`: structural failure processing transformations (e.g. database down) — does not fail the main parse. | +### Diagnóstico estruturado de `execute-candidates` (Issue LayoutParserReact #86) / Structured diagnostics for `execute-candidates` + +**🇧🇷** `POST /api/transformationexecution/execute-candidates` ganhou dois campos **aditivos** na resposta (não quebram clientes existentes que ignoram campos desconhecidos): [`pathwayDiagnostics`](Models/Transformation/PathwayDiagnostic.cs) e `correlationId`. Design completo: [`docs/architecture/diagnostico-issue-86-diagnostico-estruturado-execute-candidates.md`](docs/architecture/diagnostico-issue-86-diagnostico-estruturado-execute-candidates.md). + +```jsonc +{ + "success": true, + "candidates": [], + "recommendedCandidateId": null, + "warnings": ["..."], + "pathwayDiagnostics": [ + { "pathway": "sysmiddle", "status": "not_applicable", "code": "no_mapper", "message": "..." }, + { "pathway": "tcl-xsl", "status": "failed", "code": "map_not_found", "message": "..." } + ], + "correlationId": "..." +} +``` + +**Semântica principal:** `candidates: []` nunca fica sem causa quando a API sabe o motivo — cada pathway avaliado (`sysmiddle`, `tcl-xsl`, e `ai-fallback` quando o fallback automático de IA é disparado) entra em `pathwayDiagnostics` com um veredito, mesmo quando não produz candidato. `warnings` continua populado exatamente como antes, por compatibilidade — `pathwayDiagnostics` é estruturado, não substitui. + +| Campo | Valores | Significado | +|-------|---------|-------------| +| `pathway` | `sysmiddle` \| `tcl-xsl` \| `ai-fallback` | Qual dos pathways gerou este diagnóstico. | +| `status` | `candidate_generated` \| `not_applicable` \| `failed` | `candidate_generated`: o pathway produziu ao menos um candidato. `not_applicable`: o pathway não é elegível para este layout/entrada (não é falha). `failed`: o pathway era elegível mas não conseguiu produzir candidato. | +| `code` | `no_mapper` \| `map_not_found` \| `xsl_not_found` \| `configuration_error` \| `runner_unavailable` \| `timeout` \| `not_applicable` \| `execution_error` | Taxonomia estável (string, não enum — permite adicionar valores sem quebrar o contrato). | +| `message` | texto livre | Mensagem legível para exibição no front. | + +**Regra de sanitização:** toda `message` em `pathwayDiagnostics` passa por [`LowCodeErrorSanitizer`](Services/Transformation/LowCode/LowCodeErrorSanitizer.cs) antes de chegar ao payload HTTP — **nunca** contém caminho físico de disco nem detalhe interno cru. O detalhe completo (não sanitizado) só existe no log estruturado, correlacionável via `correlationId`. + +**🇺🇸** `POST /api/transformationexecution/execute-candidates` gained two **additive** response fields (safe for existing clients that ignore unknown fields): [`pathwayDiagnostics`](Models/Transformation/PathwayDiagnostic.cs) and `correlationId`. Full design: [`docs/architecture/diagnostico-issue-86-diagnostico-estruturado-execute-candidates.md`](docs/architecture/diagnostico-issue-86-diagnostico-estruturado-execute-candidates.md). + +**Core semantics:** `candidates: []` is never left without a cause when the API knows the reason — every pathway evaluated (`sysmiddle`, `tcl-xsl`, and `ai-fallback` when the automatic AI fallback fires) gets an entry in `pathwayDiagnostics` with a verdict, even when it produces no candidate. `warnings` remains populated exactly as before for backward compatibility — `pathwayDiagnostics` is structured, it doesn't replace it. + +| Field | Values | Meaning | +|-------|--------|---------| +| `pathway` | `sysmiddle` \| `tcl-xsl` \| `ai-fallback` | Which pathway produced this diagnostic. | +| `status` | `candidate_generated` \| `not_applicable` \| `failed` | `candidate_generated`: the pathway produced at least one candidate. `not_applicable`: the pathway isn't eligible for this layout/input (not a failure). `failed`: the pathway was eligible but couldn't produce a candidate. | +| `code` | `no_mapper` \| `map_not_found` \| `xsl_not_found` \| `configuration_error` \| `runner_unavailable` \| `timeout` \| `not_applicable` \| `execution_error` | Stable taxonomy (string, not an exposed enum — new values can be added without breaking the contract). | +| `message` | free text | Human-readable message for front-end display. | + +**Sanitization rule:** every `message` in `pathwayDiagnostics` goes through [`LowCodeErrorSanitizer`](Services/Transformation/LowCode/LowCodeErrorSanitizer.cs) before reaching the HTTP payload — it **never** contains a physical disk path or raw internal detail. The full (unsanitized) detail only exists in the structured log, correlatable via `correlationId`. + --- ## 8. Configuração / Configuration diff --git a/Services/XmlAnalysis/Models/TransformationPipelineResult.cs b/Services/XmlAnalysis/Models/TransformationPipelineResult.cs index 4b739d4..a87baa7 100644 --- a/Services/XmlAnalysis/Models/TransformationPipelineResult.cs +++ b/Services/XmlAnalysis/Models/TransformationPipelineResult.cs @@ -11,6 +11,15 @@ public class TransformationPipelineResult public string XslPath { get; set; } public List Errors { get; set; } = new(); public List Warnings { get; set; } = new(); + + /// + /// Código estável da causa de falha (Issue LayoutParserReact #86, pathwayDiagnostics). + /// "map_not_found" | "xsl_not_found" | null (sucesso ou erro interno não classificado — + /// nesse caso o chamador cai no "execution_error" genérico). Populado no ponto de origem + /// (), nunca inferido depois por regex sobre + /// — mesma disciplina já usada para FailureKind no controller. + /// + public string ErrorCode { get; set; } public Dictionary StepResults { get; set; } = new(); public Dictionary SegmentMappings { get; set; } = new(); } diff --git a/Services/XmlAnalysis/TransformationPipelineService.cs b/Services/XmlAnalysis/TransformationPipelineService.cs index cbdac2b..9219ec2 100644 --- a/Services/XmlAnalysis/TransformationPipelineService.cs +++ b/Services/XmlAnalysis/TransformationPipelineService.cs @@ -110,6 +110,7 @@ public async Task TransformXmlToXmlAsync(string xm if (string.IsNullOrEmpty(xslPath) || !File.Exists(xslPath)) { result.Success = false; + result.ErrorCode = "xsl_not_found"; result.Errors.Add($"Arquivo XSL não encontrado para transformação {sourceDocumentType} → {targetDocumentType}"); return result; } @@ -151,6 +152,7 @@ private async Task TransformTxtToIntermediateXmlAsync(string txtContent, var mapContent = await LoadMappingFileAsync(layoutName); if (mapContent == null) { + result.ErrorCode = "map_not_found"; result.Errors.Add($"Arquivo MAP não encontrado para layout: {layoutName}"); return null; } @@ -309,6 +311,7 @@ private async Task TransformIntermediateToFinalXmlAsync(string intermedi var xslPath = FindXslFile("Intermediate", targetDocumentType, layoutName); if (string.IsNullOrEmpty(xslPath) || !File.Exists(xslPath)) { + result.ErrorCode = "xsl_not_found"; result.Errors.Add($"Arquivo XSL não encontrado para transformação Intermediate → {targetDocumentType}"); return null; } diff --git a/security-code-scan-baseline.json b/security-code-scan-baseline.json index dfcd449..fcc1197 100644 --- a/security-code-scan-baseline.json +++ b/security-code-scan-baseline.json @@ -58,7 +58,7 @@ { "code": "SCS0018", "file": "Services/Transformation/TransformationValidatorService.cs", "line": 232 }, { "code": "SCS0018", "file": "Services/Validation/DocumentMLValidationService.cs", "line": 178 }, { "code": "SCS0018", "file": "Services/Validation/DocumentMLValidationService.cs", "line": 206 }, - { "code": "SCS0018", "file": "Services/XmlAnalysis/TransformationPipelineService.cs", "line": 390 }, + { "code": "SCS0018", "file": "Services/XmlAnalysis/TransformationPipelineService.cs", "line": 393 }, { "code": "SCS0018", "file": "Services/XmlAnalysis/XsdValidationService.cs", "line": 221 }, { "code": "SCS0018", "file": "Services/XmlAnalysis/XsdValidationService.cs", "line": 235 }, { "code": "SCS0018", "file": "Services/XmlAnalysis/XsdValidationService.cs", "line": 340 } diff --git a/tests/LayoutParserApi.Tests/Controllers/TransformationExecutionControllerPathwayDiagnosticsTests.cs b/tests/LayoutParserApi.Tests/Controllers/TransformationExecutionControllerPathwayDiagnosticsTests.cs new file mode 100644 index 0000000..338496e --- /dev/null +++ b/tests/LayoutParserApi.Tests/Controllers/TransformationExecutionControllerPathwayDiagnosticsTests.cs @@ -0,0 +1,266 @@ +using LayoutParserApi.Controllers; +using LayoutParserApi.Models; +using LayoutParserApi.Models.Database; +using LayoutParserApi.Models.Transformation; +using LayoutParserApi.Services.Database; +using LayoutParserApi.Services.Interfaces; +using LayoutParserApi.Services.Transformation.Ai; +using LayoutParserApi.Services.Transformation.LowCode; +using LayoutParserApi.Services.XmlAnalysis; + +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; + +namespace LayoutParserApi.Tests.Controllers +{ + /// + /// Issue LayoutParserReact #86: POST execute-candidates passa a devolver + /// pathwayDiagnostics[] populado (não mais sempre vazio) para os dois pathways síncronos + /// (sysmiddle, tcl-xsl). Reproduz, com documento/layout SINTÉTICOS (nunca dado real de cliente), + /// os dois cenários relatados na issue: "nenhum mapeador low-code" (sysmiddle) e "arquivo MAP não + /// encontrado" (tcl-xsl) — e confirma que cada pathway termina em exatamente 1 diagnóstico, nunca + /// silencioso. + /// + /// Reaproveita o padrão de TransformationPipelineServiceMapFileTests (fixture mínima + /// de pastas TCL/XSL vazias) e de LowCodeAutoTransformationCacheTests (fake de + /// devolvendo lista vazia — sem tocar runner/SQL real). + /// + public class TransformationExecutionControllerPathwayDiagnosticsTests + { + private const string LayoutName = "LAY_SINTETICO_TESTE_ISSUE86"; + private static readonly Guid LayoutGuid = Guid.Parse("00000000-0000-0000-0000-0000000000e8"); + + private sealed class FakeCurrentUser : ICurrentUser + { + public string? Name { get; set; } = "issue86-test-user"; + public IReadOnlyList Roles { get; set; } = Array.Empty(); + public bool IsAuthenticated => Name != null; + public bool IsInRole(string role) => Roles.Contains(role, StringComparer.OrdinalIgnoreCase); + } + + private sealed class FakeLayoutDatabaseService : ILayoutDatabaseService + { + public Task SearchLayoutsAsync(LayoutSearchRequest request) => + Task.FromResult(new LayoutSearchResponse + { + Success = true, + Layouts = new List { new() { Name = LayoutName, LayoutGuid = LayoutGuid } } + }); + + public Task GetLayoutByIdAsync(int id) => Task.FromResult(null); + } + + /// Mesmo papel do MapperDbFalso de LowCodeAutoTransformationCacheTests, + /// mas sempre devolvendo lista vazia — força autoResult.Applicable == false (Estado A, + /// "no_mapper") sem nunca tocar o runner x86 nem SQL real. + private sealed class MapperDbVazio : MapperDatabaseService + { + public MapperDbVazio(IConfiguration config) : base(NullLogger.Instance, null!, config) { } + + public override Task> GetRankedMapperCandidatesForLayoutGuidAsync( + string layoutGuid, int projectId, IReadOnlyCollection allowedPackageGuids) + => Task.FromResult(new List()); + } + + private sealed class SpyAiCandidateService : IAiTransformationCandidateService + { + public int EnqueueCount { get; private set; } + public Task EnqueueAsync(string userId, string ticket, string layoutName, Guid layoutGuid, string mapperGuid, + string inputContent, string? groundTruthXml, CancellationToken cancellationToken) + { + EnqueueCount++; + return Task.CompletedTask; + } + + public Task GetStatusAsync(string userId, string ticket, CancellationToken cancellationToken) => + Task.FromResult(new AiCandidateStatus { Status = AiCandidateStatus.StatusNotFound }); + } + + private sealed class SpyAiFallbackSuppressionGate : IAiFallbackSuppressionGate + { + public bool IsInCooldown(Guid layoutGuid, out DateTimeOffset retryAt) { retryAt = default; return false; } + public void RegisterFailure(Guid layoutGuid, TimeSpan cooldown) { } + public void ClearCooldown(Guid layoutGuid) { } + } + + /// + /// Constrói o controller real, com sysmiddle () + /// apontando para um (sem mapper cadastrado) e tcl-xsl + /// () apontando para uma pasta TCL temporária + /// vazia (sem .tcl para o layout) — reproduz os dois sintomas originais da issue #86 + /// (candidates: [] + as duas mensagens de texto) com um payload 100% sintético. + /// + private static (TransformationExecutionController Controller, SpyAiCandidateService AiSpy, string TclDir) BuildController() + { + var raiz = Path.Combine(Path.GetTempPath(), "lp-tests", "execute-candidates-diag", Guid.NewGuid().ToString("N")); + var tclDir = Path.Combine(raiz, "tcl"); + var xslDir = Path.Combine(raiz, "xsl"); + Directory.CreateDirectory(tclDir); + Directory.CreateDirectory(xslDir); + + var lowCodeConfig = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["ML:LowCodeTransformationsPath"] = Path.Combine(raiz, "lowcode-store"), + ["Logging:File:Directory"] = Path.Combine(raiz, "runner-logs") + }) + .Build(); + + var lowCodeOptions = Options.Create(new LowCodeRunnerOptions + { + RunnerPath = "runner-inexistente.exe", + SysmiddleDir = Path.GetTempPath(), + GlobalFolder = Path.GetTempPath() + }); + + var store = new LowCodeTransformationStore( + NullLogger.Instance, lowCodeConfig, lowCodeOptions, redis: null); + + var mapperDb = new MapperDbVazio(lowCodeConfig); + var services = new ServiceCollection(); + services.AddScoped(_ => mapperDb); + + // O runner (LowCodeTransformationService) nunca é chamado quando não há mapper — ver + // LowCodeAutoTransformationService.TransformAndPersistAsync (checa ranked.Count == 0 ANTES + // de tocar o runner). null! é seguro aqui pelo mesmo motivo documentado em + // TransformationExecutionControllerUserIsolationTests.BuildController. + var lowCodeAuto = new LowCodeAutoTransformationService( + NullLogger.Instance, + services.BuildServiceProvider().GetRequiredService(), + null!, + store, + lowCodeOptions); + + var pipelineConfig = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["TransformationPipeline:TclPath"] = tclDir, + ["TransformationPipeline:XslPath"] = xslDir, + }) + .Build(); + var pipelineService = new TransformationPipelineService( + NullLogger.Instance, pipelineConfig); + + var aiSpy = new SpyAiCandidateService(); + + var controller = new TransformationExecutionController( + NullLogger.Instance, + pipelineService: pipelineService, + validatorService: null!, + learningService: null!, + autoGenerator: null!, + lowCode: null!, + lowCodeAuto: lowCodeAuto, + layoutDb: new FakeLayoutDatabaseService(), + lowCodeOptions: lowCodeOptions, + aiCandidateService: aiSpy, + aiFallbackGate: new SpyAiFallbackSuppressionGate(), + currentUser: new FakeCurrentUser()); + + return (controller, aiSpy, tclDir); + } + + [Fact] + public async Task Sem_mapper_e_sem_tcl_pathwayDiagnostics_reporta_no_mapper_e_map_not_found() + { + var (controller, aiSpy, _) = BuildController(); + + var request = new TransformationRequest + { + InputContent = "000001DADOS POSICIONAIS SINTETICOS DE TESTE ISSUE86", + LayoutName = LayoutName, + LayoutGuid = LayoutGuid.ToString() + }; + + var actionResult = await controller.ExecuteTransformationCandidates(request); + + var ok = Assert.IsType(actionResult); + var response = Assert.IsType(ok.Value); + + Assert.Empty(response.Candidates); + // CorrelationId pode ser null fora de um pipeline HTTP real (sem middleware de correlação + // no teste) — o contrato exige a PROPRIEDADE presente no shape (response.CorrelationId + // compila e existe), não um valor não-nulo neste cenário sem HttpContext. + + // 3 diagnósticos: sysmiddle (no_mapper) + tcl-xsl (map_not_found) + ai-fallback + // (candidate_generated, ver asserção mais abaixo) — nenhum pathway fica silencioso. + Assert.Equal(3, response.PathwayDiagnostics.Count); + + var sysmiddle = Assert.Single(response.PathwayDiagnostics, d => d.Pathway == "sysmiddle"); + Assert.Equal("not_applicable", sysmiddle.Status); + Assert.Equal("no_mapper", sysmiddle.Code); + Assert.False(string.IsNullOrWhiteSpace(sysmiddle.Message)); + + var tclXsl = Assert.Single(response.PathwayDiagnostics, d => d.Pathway == "tcl-xsl"); + Assert.Equal("failed", tclXsl.Status); + Assert.Equal("map_not_found", tclXsl.Code); + Assert.False(string.IsNullOrWhiteSpace(tclXsl.Message)); + + // Fallback de IA (Estado A: nenhum FailureKind.ExecutionInfraError, já que ambos os + // pathways síncronos reportaram not_applicable/failed com códigos de "não encontrado", não + // de infra) deve ter disparado — e ganha seu próprio 3º diagnóstico. Aqui o contrato do + // desenho (§4.3) classifica map_not_found/xsl_not_found como FailureKind.NotApplicable no + // controller, não ExecutionInfraError — então o fallback de IA continua elegível. + Assert.Equal(1, aiSpy.EnqueueCount); + var aiFallback = Assert.Single(response.PathwayDiagnostics, d => d.Pathway == "ai-fallback"); + Assert.Equal("candidate_generated", aiFallback.Status); + } + + [Fact] + public async Task Sem_mapper_e_sem_tcl_nenhuma_mensagem_vaza_caminho_de_disco() + { + var (controller, _, tclDir) = BuildController(); + + var request = new TransformationRequest + { + InputContent = "000001OUTRO DOCUMENTO SINTETICO DE TESTE", + LayoutName = LayoutName, + LayoutGuid = LayoutGuid.ToString() + }; + + var actionResult = await controller.ExecuteTransformationCandidates(request); + var ok = Assert.IsType(actionResult); + var response = Assert.IsType(ok.Value); + + foreach (var diag in response.PathwayDiagnostics) + { + Assert.DoesNotContain(@"C:\", diag.Message ?? "", StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain(tclDir, diag.Message ?? "", StringComparison.OrdinalIgnoreCase); + } + foreach (var warning in response.Warnings) + { + Assert.DoesNotContain(@"C:\", warning, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain(tclDir, warning, StringComparison.OrdinalIgnoreCase); + } + } + + [Fact] + public async Task Xsl_ausente_apos_tcl_resolvido_reporta_xsl_not_found() + { + var (controller, _, tclDir) = BuildController(); + + // Cria o .tcl (fixture mínima já usada em TransformationPipelineServiceMapFileTests) sem + // criar nenhum .xsl correspondente — força o caminho "MAP resolvido, XSL não encontrado". + var mapXml = ""; + await File.WriteAllTextAsync(Path.Combine(tclDir, $"{LayoutName}.tcl"), mapXml); + + var request = new TransformationRequest + { + InputContent = "20260827SINTETICO", + LayoutName = LayoutName, + LayoutGuid = LayoutGuid.ToString() + }; + + var actionResult = await controller.ExecuteTransformationCandidates(request); + var ok = Assert.IsType(actionResult); + var response = Assert.IsType(ok.Value); + + var tclXsl = Assert.Single(response.PathwayDiagnostics, d => d.Pathway == "tcl-xsl"); + Assert.Equal("failed", tclXsl.Status); + Assert.Equal("xsl_not_found", tclXsl.Code); + } + } +} diff --git a/tests/LayoutParserApi.Tests/Controllers/TransformationExecutionControllerUserIsolationTests.cs b/tests/LayoutParserApi.Tests/Controllers/TransformationExecutionControllerUserIsolationTests.cs index 590041f..54fb420 100644 --- a/tests/LayoutParserApi.Tests/Controllers/TransformationExecutionControllerUserIsolationTests.cs +++ b/tests/LayoutParserApi.Tests/Controllers/TransformationExecutionControllerUserIsolationTests.cs @@ -190,16 +190,18 @@ public async Task TryEnqueueAiCandidate_propaga_CurrentUserId_para_EnqueueAsync( // TryEnqueueAiFallback é privado — mesma técnica de reflection do teste acima, pelo mesmo // motivo (exercitar o pathway sysmiddle real fugiria do escopo de um teste unitário). - private static void InvokeTryEnqueueAiFallback( + private static List InvokeTryEnqueueAiFallback( TransformationExecutionController controller, TransformationRequest request, LayoutRecord layoutRecord, bool isXmlInput, IEnumerable failureKinds, List warnings, string userId) { var bag = new System.Collections.Concurrent.ConcurrentBag(failureKinds); + var diagnostics = new System.Collections.Concurrent.ConcurrentBag(); var method = typeof(TransformationExecutionController) .GetMethod("TryEnqueueAiFallback", BindingFlags.NonPublic | BindingFlags.Instance) ?? throw new InvalidOperationException("Método TryEnqueueAiFallback não encontrado — o controller mudou de forma incompatível com este teste."); - method.Invoke(controller, new object?[] { request, layoutRecord, isXmlInput, bag, warnings, userId }); + method.Invoke(controller, new object?[] { request, layoutRecord, isXmlInput, bag, warnings, diagnostics, userId }); + return diagnostics.ToList(); } [Fact] @@ -219,13 +221,16 @@ public void TryEnqueueAiFallback_EstadoA_nao_modelado_dispara_o_fallback() var warnings = new List(); // Estado A: nenhum pathway falhou por infra — só "não aplicável"/"sem heurística". - InvokeTryEnqueueAiFallback( + var diagnostics = InvokeTryEnqueueAiFallback( controller, request, layoutRecord, isXmlInput: false, failureKinds: new[] { FailureKind.NotApplicable, FailureKind.NotApplicable }, warnings, "dave"); Assert.Equal("dave", spy.LastEnqueueUserId); Assert.Contains(warnings, w => w.Contains("fallback automático de IA enfileirado", StringComparison.OrdinalIgnoreCase)); + var diag = Assert.Single(diagnostics); + Assert.Equal("ai-fallback", diag.Pathway); + Assert.Equal("candidate_generated", diag.Status); } [Fact] @@ -246,14 +251,16 @@ public void TryEnqueueAiFallback_EstadoB_falha_de_infra_NAO_dispara_o_fallback() // Estado B: pelo menos um pathway falhou por infra — mapper existe, IA não deve tentar // recriar algo que já é a fonte de verdade (regressão explícita do caso já diagnosticado - // em diagnostico-mapper-nao-encontrado-producao-2026-08-15.md). - InvokeTryEnqueueAiFallback( + // em diagnostico-mapper-nao-encontrado-producao-2026-08-15.md). Nenhum diagnóstico próprio + // de "ai-fallback" é emitido aqui — o item failed do pathway que quebrou já é o sinal. + var diagnostics = InvokeTryEnqueueAiFallback( controller, request, layoutRecord, isXmlInput: false, failureKinds: new[] { FailureKind.ExecutionInfraError, FailureKind.NotApplicable }, warnings, "erin"); Assert.Null(spy.LastEnqueueUserId); Assert.DoesNotContain(warnings, w => w.Contains("fallback automático de IA enfileirado", StringComparison.OrdinalIgnoreCase)); + Assert.Empty(diagnostics); } // --- TAREFA 3 (regressão geral): os 3 endpoints deixaram de exigir o papel "admin" ---