From 42f3c87fbdb2927e892665f852d22b065c78e67b Mon Sep 17 00:00:00 2001 From: Chien Yuan Chang <186005646+chienyuanchang@users.noreply.github.com> Date: Tue, 27 Oct 2026 09:56:19 -0700 Subject: [PATCH 01/22] feat(ai-cu): improve interactive infrastructure generation Add host-native generation prompts, a model multi-select flow, readable summaries, and regression coverage. Prepare 0.1.1-preview and date the initial release. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../CHANGELOG.md | 13 +- .../docs/provisioning.md | 43 +- .../docs/usage.md | 3 +- .../extension.yaml | 2 +- .../internal/cmd/infra.go | 77 ++- .../internal/cmd/infra_host.go | 25 +- .../internal/cmd/infra_playback_test.go | 29 +- .../internal/cmd/infra_prompts.go | 187 +++++++ .../internal/cmd/infra_prompts_test.go | 489 ++++++++++++++++++ .../internal/cmd/infra_test.go | 55 +- .../internal/cmd/installed_host_test.go | 20 + .../version.txt | 2 +- 12 files changed, 900 insertions(+), 45 deletions(-) create mode 100644 cli/azd/extensions/azure.ai.content-understanding/internal/cmd/infra_prompts.go create mode 100644 cli/azd/extensions/azure.ai.content-understanding/internal/cmd/infra_prompts_test.go diff --git a/cli/azd/extensions/azure.ai.content-understanding/CHANGELOG.md b/cli/azd/extensions/azure.ai.content-understanding/CHANGELOG.md index fbd815a8280..1aac3c71bca 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/CHANGELOG.md +++ b/cli/azd/extensions/azure.ai.content-understanding/CHANGELOG.md @@ -1,6 +1,17 @@ # Release History -## 0.1.0-preview (Unreleased) +## 0.1.1-preview (Unreleased) + +- Add interactive environment, region, resource-name prefix, and role-assignment + prompts to infrastructure generation. Explicit inputs take precedence; + `--no-prompt` and `--output json` skip the new wizard. +- Show supported models together with recommended selections, allow multiple + selections and correction of conflicting versions, and require confirmation + before deploying interactively selected models. +- Show a readable infrastructure generation summary by default. Use + `--output json` to retain the structured result. + +## 0.1.0-preview (2026-10-27) - Initial preview of Azure Content Understanding commands under `azd ai cu`, using Azure Developer CLI sign-in and environments. diff --git a/cli/azd/extensions/azure.ai.content-understanding/docs/provisioning.md b/cli/azd/extensions/azure.ai.content-understanding/docs/provisioning.md index 2b698af7fbc..a36b19211b7 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/docs/provisioning.md +++ b/cli/azd/extensions/azure.ai.content-understanding/docs/provisioning.md @@ -49,8 +49,10 @@ data-plane access are separate. | Deploy models to an existing resource | Permission for the subscription-scope deployment and the required model deployment actions on that resource. | | Call Content Understanding | Cognitive Services User on the Foundry resource, or equivalent data-plane permissions. Contributor alone does not grant this access. | -Role assignment is disabled by default. Pass `--assign-roles` to opt in when -creating a resource and when your identity has the required permissions. +Role assignment defaults to No. Interactive generation asks when neither role +flag is supplied for a new resource. Pass `--assign-roles` to opt in when +creating a resource and when your identity has the required permissions, or +`--no-assign-roles` to skip the question. Otherwise, have an administrator grant data-plane access once the resource exists, then rerun `azd up` to complete Content Understanding setup. @@ -75,9 +77,30 @@ cd provision azd up ``` -Generation selects a subscription through azd. Defaults are environment `dev`, -region `eastus2`, and interactive model selection. The model prompt runs during -post-provision setup, after the resource exists. +Generation selects a subscription through azd. For a new resource, its wizard +asks for an environment name, optional resource prefix, supported region, and +whether to assign a data-plane role. Explicit flags skip the corresponding +questions, including `--assign-roles=false` and `--foundry-prefix ""`. +An inherited `AZD_ENVIRONMENT` also skips the environment question. +Defaults are environment `dev` (or the inherited environment), region `eastus2`, +an automatically generated resource name, and no role assignment. +There is no additional new/existing-resource question: use `--foundry-endpoint` +explicitly to target an existing resource. + +The wizard generates state in the output directory; it does not read deployment +settings from another project's selected environment. For existing resources, +generation skips the prefix, region, and role questions. An omitted region uses +the discovered resource region when available. + +Generation prints the directory, environment, files written, reuse status, and +next commands. Scripts that parse the result must pass `--output json`; its +structured result is unchanged. Both `--output json` and `--no-prompt` skip +the new wizard questions and use explicit inputs, environment context, and +defaults. JSON output alone does not disable the existing subscription selector; +use `--no-prompt` for unattended execution. + +Omitting `--models` retains interactive model selection during post-provision +setup, after the resource exists; generation itself never deploys models. The new-resource path creates a resource group, a Microsoft Foundry resource, a Foundry project, and selected model deployments. The azd environment identifies @@ -144,6 +167,16 @@ hook deploys the selected models and configures defaults. CU-supported catalog validates selections during model setup. `--foundry-endpoint` and `--foundry-prefix` cannot be combined. +Interactive model setup shows the live catalog together, including model name, +version, kind, and SKU. Recommended models are marked and preselected when an +unambiguous recommendation is available. Review and submit the selection, then explicitly confirm deployment (default +No), or clear all selections to deploy no models. Declining confirmation +cancels model setup rather than silently accepting recommendations. If recommendations +are ambiguous, the picker explains why nothing is preselected. Selecting +multiple versions of one family keeps the picker open for correction; +cancellation starts no model deployment. It does not roll back resources that +`azd up` already provisioned. + ## Generated post-provision behavior The generated Bash and PowerShell hooks invoke the extension's versioned diff --git a/cli/azd/extensions/azure.ai.content-understanding/docs/usage.md b/cli/azd/extensions/azure.ai.content-understanding/docs/usage.md index d051b28dac2..a3b01ccb540 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/docs/usage.md +++ b/cli/azd/extensions/azure.ai.content-understanding/docs/usage.md @@ -453,7 +453,8 @@ set `NO_COLOR=1` for plain text. ## Usage guidance - Supported output formats are `default`, `json`, and `none`. Analysis defaults - to readable LLM-input text, and `doctor` defaults to readable diagnostics. + to readable LLM-input text, `doctor` defaults to readable diagnostics, and + `infra generate` defaults to a readable project summary. Other commands, analysis summaries, dry runs, and `--usage` wrappers use JSON. Use `--output json` for complete analysis JSON. - `--output none` suppresses successful stdout, not service operations, requested diff --git a/cli/azd/extensions/azure.ai.content-understanding/extension.yaml b/cli/azd/extensions/azure.ai.content-understanding/extension.yaml index 39e8b0c0646..d18b6e331ba 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/extension.yaml +++ b/cli/azd/extensions/azure.ai.content-understanding/extension.yaml @@ -12,4 +12,4 @@ tags: - ai - content-understanding usage: azd ai cu [options] -version: 0.1.0-preview +version: 0.1.1-preview diff --git a/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/infra.go b/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/infra.go index 89d005c833b..cd536b857b9 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/infra.go +++ b/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/infra.go @@ -8,6 +8,8 @@ import ( "fmt" "os" "path/filepath" + "runtime" + "slices" "strings" "azure.ai.content-understanding/internal/config" @@ -24,6 +26,10 @@ func newInfraCommand(extCtx *azdext.ExtensionContext, open infraHostFactory) *co var output, location, api, models, endpoint, prefix string var roles, noRoles, force bool generate := &cobra.Command{Use: "generate", Short: "Generate the canonical azd/Bicep project", Args: cobra.NoArgs} + generate.Long = "Generate an azd/Bicep project without deploying Azure resources.\n" + + "Prompt for omitted environment, region, optional new-resource prefix, and role assignment.\n" + + "Explicit inputs take precedence. --no-prompt and --output json use context/defaults without the wizard.\n" + + "Show a readable summary by default; use --output json for the complete structured result." generate.Example = ` azd ai cu infra generate Interactively generate an azd/Bicep project without deploying resources. @@ -73,7 +79,11 @@ func newInfraCommand(extCtx *azdext.ExtensionContext, open infraHostFactory) *co post.RunE = func(cmd *cobra.Command, _ []string) error { return exterrors.FromError(runInfraPost(cmd, extCtx, open)) } - group.AddCommand(jsonCommand(generate), jsonCommand(modelCommand), jsonCommand(post)) + azdext.RegisterFlagOptions(generate, azdext.FlagOptions{ + Name: "output", AllowedValues: []string{"default", "json", "none"}, + Usage: "Output format: default (human-readable), json, or none", + }) + group.AddCommand(generate, jsonCommand(modelCommand), jsonCommand(post)) return group } @@ -81,6 +91,18 @@ func runInfraGenerate( cmd *cobra.Command, extCtx *azdext.ExtensionContext, open infraHostFactory, output, location, api, models, endpoint, prefix string, roles, force bool, ) error { + for _, name := range []string{"environment", "location", "api-version", "models", "foundry-endpoint", "output-dir"} { + if cmd.Flags().Changed(name) { + value, err := cmd.Flags().GetString(name) + if err != nil { + return err + } + if strings.TrimSpace(value) == "" { + return commandError("empty_infra_argument", "--"+name+" cannot be empty.", + "Omit the flag to use its default or supply a valid value.") + } + } + } environment := extCtx.Environment if environment == "" { environment = "dev" @@ -93,10 +115,14 @@ func runInfraGenerate( if choices, err = choices.Normalize(); err != nil { return err } - if prefix != "" && endpoint != "" { + if cmd.Flags().Changed("foundry-prefix") && cmd.Flags().Changed("foundry-endpoint") { return commandError("conflicting_foundry_resource", "Use an existing Foundry resource or a new prefix, not both.", "Remove --foundry-endpoint or --foundry-prefix.") } + output, err = expandInfraPath(output) + if err != nil { + return err + } ctx := azdext.WithAccessToken(cmd.Context()) host, err := open(cmd, extCtx) if err != nil { @@ -126,15 +152,54 @@ func runInfraGenerate( choices.Location = resolved.Region } } - output, err = expandInfraPath(output) - if err != nil { - return err + if !extCtx.NoPrompt && extCtx.OutputFormat != "json" { + choices, err = host.PromptGeneration(ctx, choices, generationPrompts{ + environment: extCtx.Environment == "", + location: !cmd.Flags().Changed("location") && endpoint == "", + prefix: !cmd.Flags().Changed("foundry-prefix") && endpoint == "", + roles: !cmd.Flags().Changed("assign-roles") && + !cmd.Flags().Changed("no-assign-roles") && endpoint == "", + }) + if err != nil { + return err + } } result, err := infra.Materialize(ctx, output, choices, force) if err != nil { return err } - return writeServiceResult(cmd, extCtx.OutputFormat, result) + if extCtx.OutputFormat == "json" || extCtx.OutputFormat == "none" { + return writeServiceResult(cmd, extCtx.OutputFormat, result) + } + var summary strings.Builder + action := "Generated" + if result.ReusedExistingTemplate { + action = "Updated" + } + fmt.Fprintf(&summary, "%s project: %s\nEnvironment: %s\nRegion: %s\nSubscription: %s (%s)\n", + action, result.OutputDirectory, result.Environment, result.Location, result.SubscriptionName, result.SubscriptionID) + if result.ReusedExistingTemplate { + summary.WriteString("Reused existing templates") + if !slices.Contains(result.Files, "infra/models.json") { + summary.WriteString("; preserved infra/models.json") + } + summary.WriteString(".\n") + } + summary.WriteString("Files written:\n") + for _, path := range result.Files { + fmt.Fprintf(&summary, " %s\n", path) + } + summary.WriteString("\nNo Azure resources were deployed. Next:\n") + for index, step := range result.NextSteps { + if index == 0 && runtime.GOOS == "windows" { + step = "Set-Location -LiteralPath '" + strings.ReplaceAll(result.OutputDirectory, "'", "''") + "'" + } + fmt.Fprintf(&summary, " %s\n", step) + } + if _, err := fmt.Fprint(cmd.OutOrStdout(), summary.String()); err != nil { + return fmt.Errorf("write infrastructure summary: %w", err) + } + return nil } func runInfraModels( diff --git a/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/infra_host.go b/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/infra_host.go index f0f84345d0e..e2f40c7cc4b 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/infra_host.go +++ b/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/infra_host.go @@ -30,6 +30,7 @@ type infraSnapshot struct { type infraHost interface { Subscription(context.Context, bool) (*azdext.Subscription, error) Resolve(context.Context, *azdext.Subscription, string) (armclient.Account, error) + PromptGeneration(context.Context, infra.Choices, generationPrompts) (infra.Choices, error) Snapshot(context.Context, string) (infraSnapshot, error) Capabilities(context.Context, infraSnapshot, infra.PostRequest) (infra.PostCapabilities, infra.ModelCapabilities, error) Close() @@ -267,29 +268,7 @@ func (c *infraCapabilities) PromptModels(ctx context.Context, models []infra.Mod return nil, commandError("model_selection_required", "Model selection requires an interactive decision.", "Set CU_MODEL_SELECTION to none, recommended, or explicit model@version selectors.") } - selected := []infra.Model{} - for _, model := range models { - response, err := c.host.client.Prompt().Confirm(ctx, &azdext.ConfirmRequest{Options: &azdext.ConfirmOptions{ - Message: "Deploy " + model.Selector() + " (" + model.SKU + ")?", DefaultValue: new(false), - }}) - if err != nil { - return nil, err - } - if response == nil || response.Value == nil { - return nil, commandError("invalid_confirmation_response", "The host returned no model deployment decision.", - "Use an explicit --selection value.") - } - if response.GetValue() { - for _, previous := range selected { - if strings.EqualFold(previous.Name, model.Name) { - return nil, commandError("duplicate_model_family", "Only one version of each family can be deployed.", - "Retry with explicit model@version selectors.") - } - } - selected = append(selected, model) - } - } - return selected, ctx.Err() + return c.host.promptModels(ctx, models) } func (c *infraCapabilities) SetupModels(ctx context.Context) error { if c.host.noPrompt && c.request.Selection == "prompt" { diff --git a/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/infra_playback_test.go b/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/infra_playback_test.go index acc69aa7c56..d4172caf6c6 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/infra_playback_test.go +++ b/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/infra_playback_test.go @@ -53,9 +53,26 @@ func (h *infraPlaybackHost) Capabilities( } func TestGeneratedProjectPostprovisionThroughHostAndRESTPlayback(t *testing.T) { + for _, selection := range []string{"recommended", "prompt"} { + t.Run(selection, func(t *testing.T) { + testGeneratedProjectPostprovision(t, selection) + }) + } +} + +func testGeneratedProjectPostprovision(t *testing.T, selection string) { + t.Helper() _ = infraCommandFixture(t) rpc := &infraRPC{section: &azdext.GetConfigSectionResponse{}, writes: map[string]json.RawMessage{}, subscriptions: []*azdext.Subscription{{Id: "sub", Name: "Subscription", TenantId: "tenant", IsDefault: true}}} + rpc.multiSelect = func(request *azdext.MultiSelectRequest) (*azdext.MultiSelectResponse, error) { + assert.Len(t, request.GetOptions().GetChoices(), 2) + return &azdext.MultiSelectResponse{Values: request.GetOptions().GetChoices()}, nil + } + rpc.confirm = func(request *azdext.ConfirmRequest) (*azdext.ConfirmResponse, error) { + assert.False(t, request.GetOptions().GetDefaultValue()) + return &azdext.ConfirmResponse{Value: new(true)}, nil + } _ = infraRPCClient(t, rpc) models := []string{"gpt-5.2", "text-embedding-3-large"} deployed := map[string]bool{} @@ -145,23 +162,27 @@ func TestGeneratedProjectPostprovisionThroughHostAndRESTPlayback(t *testing.T) { return stdout.String(), err } directory := filepath.Join(testutil.TempDir(t), "generated") - _, err := execute("infra", "generate", "-d", directory, "-e", "dev", "--models", "recommended", "--no-prompt") + _, err := execute("infra", "generate", "-d", directory, "-e", "dev", "--models", selection, "--no-prompt") require.NoError(t, err) assert.Empty(t, calls) env, err := localio.Read(filepath.Join(directory, ".azure/dev/.env")) require.NoError(t, err) - assert.Contains(t, string(env), `CU_MODEL_SELECTION="recommended"`) + assert.Contains(t, string(env), `CU_MODEL_SELECTION="`+selection+`"`) rpc.mu.Lock() rpc.values = []*azdext.KeyValue{ {Key: "FOUNDRY_ENDPOINT", Value: "https://foundry.services.ai.azure.com"}, {Key: "FOUNDRY_RESOURCE_NAME", Value: "foundry"}, {Key: "CU_ENDPOINT", Value: "https://foundry.services.ai.azure.com"}, {Key: "AZURE_RESOURCE_GROUP", Value: "group"}, {Key: "AZURE_SUBSCRIPTION_ID", Value: "sub"}, - {Key: "AZURE_TENANT_ID", Value: "tenant"}, {Key: "CU_MODEL_SELECTION", Value: "recommended"}, + {Key: "AZURE_TENANT_ID", Value: "tenant"}, {Key: "CU_MODEL_SELECTION", Value: selection}, } rpc.mu.Unlock() t.Chdir(directory) - stdout, err := execute("infra", "_postprovision-v1", "-e", "dev", "--no-prompt") + postArgs := []string{"infra", "_postprovision-v1", "-e", "dev"} + if selection != "prompt" { + postArgs = append(postArgs, "--no-prompt") + } + stdout, err := execute(postArgs...) require.NoError(t, err) var result infra.PostResult require.NoError(t, json.Unmarshal([]byte(stdout), &result)) diff --git a/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/infra_prompts.go b/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/infra_prompts.go new file mode 100644 index 00000000000..6587aa0fb04 --- /dev/null +++ b/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/infra_prompts.go @@ -0,0 +1,187 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "errors" + "fmt" + "slices" + "strings" + + "azure.ai.content-understanding/internal/cuclient" + "azure.ai.content-understanding/internal/infra" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" +) + +type generationPrompts struct { + environment, location, prefix, roles bool +} + +func invalidInfraPrompt() error { + return commandError("invalid_prompt_response", "The azd host returned an invalid infrastructure selection.", + "Retry through azd or use explicit inputs with --no-prompt.") +} + +func (h *azdInfraHost) PromptGeneration( + ctx context.Context, choices infra.Choices, prompts generationPrompts, +) (infra.Choices, error) { + for _, field := range []struct { + enabled bool + message string + help string + value *string + }{ + {prompts.environment, "azd environment name", + "Generate files only; run azd up afterwards. This name identifies the deployment under .azure/.", + &choices.Environment}, + {prompts.prefix, "Optional Microsoft Foundry resource name prefix (blank for generated name)", + "The globally unique resource name becomes part of its public endpoint. Use 1-20 letters, numbers or hyphens.", + &choices.FoundryPrefix}, + } { + if !field.enabled { + continue + } + help := field.help + for { + if err := ctx.Err(); err != nil { + return choices, err + } + response, err := h.client.Prompt().Prompt(ctx, &azdext.PromptRequest{Options: &azdext.PromptOptions{ + Message: field.message, HelpMessage: help, DefaultValue: *field.value, + Required: field.value == &choices.Environment, + }}) + if err != nil { + return choices, err + } + if response == nil { + return choices, invalidInfraPrompt() + } + *field.value = response.GetValue() + normalized, err := choices.Normalize() + if err != nil { + help = field.help + "\nInvalid input: " + err.Error() + continue + } + choices = normalized + break + } + } + if prompts.location { + options := &azdext.SelectOptions{ + Message: "Select a Content Understanding region", + HelpMessage: "Region for the new Microsoft Foundry resource.", EnableFiltering: new(true), + } + index := int32(0) + for _, region := range infra.Regions { + if region == choices.Location { + options.SelectedIndex = new(index) + } + options.Choices = append(options.Choices, &azdext.SelectChoice{Value: region, Label: region}) + index++ + } + response, err := h.client.Prompt().Select(ctx, &azdext.SelectRequest{Options: options}) + if err != nil { + return choices, err + } + if response == nil || response.Value == nil || response.GetValue() < 0 || + int(response.GetValue()) >= len(infra.Regions) { + return choices, invalidInfraPrompt() + } + choices.Location = infra.Regions[response.GetValue()] + } + if prompts.roles { + response, err := h.client.Prompt().Confirm(ctx, &azdext.ConfirmRequest{Options: &azdext.ConfirmOptions{ + Message: "Assign Cognitive Services User to your user on the new Foundry resource?", + HelpMessage: "Requires permission to create role assignments, such as Owner, User Access Administrator, " + + "or Role Based Access Control Administrator. If skipped, ask an administrator to grant access for Entra ID.", + DefaultValue: new(false), + }}) + if err != nil { + return choices, err + } + if response == nil || response.Value == nil { + return choices, invalidInfraPrompt() + } + choices.AssignRoles = response.GetValue() + } + if err := ctx.Err(); err != nil { + return choices, err + } + return choices.Normalize() +} + +func (h *azdInfraHost) promptModels(ctx context.Context, models []infra.Model) ([]infra.Model, error) { + options := &azdext.MultiSelectOptions{ + Message: "Select live CU-supported models to deploy", + HelpMessage: "Accept or change the recommended selections. Clear all selections to deploy no models. " + + "Select only one version of each model family.", + AllowEmptySelection: new(true), EnableFiltering: new(true), + } + recommended, err := infra.SelectModels(models, "recommended") + if err != nil { + local, ok := errors.AsType[*cuclient.Error](err) + if !ok || (local.Code != "ambiguous_model_version" && local.Code != "no_deployable_models") { + return nil, err + } + options.HelpMessage += "\nNo models preselected: " + local.Message + } + for _, model := range models { + label := fmt.Sprintf("%s | %s | %s", model.Selector(), model.Kind, model.SKU) + isRecommended := slices.Contains(recommended, model) + if isRecommended { + label += " (recommended)" + } + options.Choices = append(options.Choices, &azdext.MultiSelectChoice{ + Value: model.Selector(), Label: label, Selected: isRecommended, + }) + } + for { + if err := ctx.Err(); err != nil { + return nil, err + } + response, err := h.client.Prompt().MultiSelect(ctx, &azdext.MultiSelectRequest{Options: options}) + if err != nil { + return nil, err + } + if response == nil { + return nil, invalidInfraPrompt() + } + selectors := []string{} + for _, choice := range response.GetValues() { + if choice == nil { + return nil, invalidInfraPrompt() + } + selectors = append(selectors, choice.GetValue()) + } + if len(selectors) == 0 { + return []infra.Model{}, ctx.Err() + } + selected, err := infra.SelectModels(models, strings.Join(selectors, ",")) + if err == nil { + // A host without a terminal can accept preselected choices at EOF; require an explicit opt-in to deploy. + confirmation, err := h.client.Prompt().Confirm(ctx, &azdext.ConfirmRequest{Options: &azdext.ConfirmOptions{ + Message: "Deploy these models: " + strings.Join(selectors, ", ") + "?", + HelpMessage: "Model deployment can incur Azure charges. No models will be deployed unless you confirm.", + DefaultValue: new(false), + }}) + if err != nil { + return nil, err + } + if confirmation == nil || confirmation.Value == nil { + return nil, invalidInfraPrompt() + } + if !confirmation.GetValue() { + return nil, context.Canceled + } + return selected, ctx.Err() + } + options.HelpMessage = "Invalid selection: " + err.Error() + + " Select only one version of each family, or clear all selections for no models." + for _, choice := range options.Choices { + choice.Selected = slices.Contains(selectors, choice.Value) + } + } +} diff --git a/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/infra_prompts_test.go b/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/infra_prompts_test.go new file mode 100644 index 00000000000..d270a7e7898 --- /dev/null +++ b/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/infra_prompts_test.go @@ -0,0 +1,489 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "azure.ai.content-understanding/internal/infra" + "azure.ai.content-understanding/internal/testutil" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func TestInfraGenerateContextPrecedence(t *testing.T) { + for _, test := range []struct { + name, inherited string + args []string + prompts generationPrompts + environment string + location string + }{ + {"omitted", "", nil, generationPrompts{true, true, true, true}, "dev", "eastus2"}, + {"inherited", "staging", nil, generationPrompts{false, true, true, true}, "staging", "eastus2"}, + {"explicit", "other", []string{"-e", "target", "-l", "westus3", "--foundry-prefix", "app", + "--assign-roles=false"}, generationPrompts{}, "target", "westus3"}, + {"existing", "", []string{"--foundry-endpoint", "foundry"}, generationPrompts{environment: true}, "dev", "westus3"}, + {"no roles false", "", []string{"--no-assign-roles=false"}, + generationPrompts{true, true, true, false}, "dev", "eastus2"}, + {"empty prefix", "", []string{"--foundry-prefix", ""}, + generationPrompts{true, true, false, true}, "dev", "eastus2"}, + } { + t.Run(test.name, func(t *testing.T) { + host := infraCommandFixture(t) + t.Setenv("AZD_ENVIRONMENT", test.inherited) + host.prompt = func(choices infra.Choices) (infra.Choices, error) { + assert.Equal(t, test.environment, choices.Environment) + assert.Equal(t, test.location, choices.Location) + if test.prompts.environment { + choices.Environment = "chosen" + } + return choices, nil + } + target := filepath.Join(testutil.TempDir(t), "project") + args := append([]string{"infra", "generate", "-d", target, "--models", "none"}, test.args...) + stdout, stderr, err := executeInfra(t, host, args...) + require.NoError(t, err) + assert.Empty(t, stderr) + assert.Equal(t, []generationPrompts{test.prompts}, host.prompts) + environment := test.environment + if test.prompts.environment { + environment = "chosen" + } + assert.Contains(t, stdout, "Environment: "+environment) + // #nosec G304 -- Reads the generated test-owned environment. + body, err := os.ReadFile(filepath.Join(target, ".azure", environment, ".env")) + require.NoError(t, err) + assert.Contains(t, string(body), `AZURE_ENV_NAME="`+environment+`"`) + assert.NotContains(t, host.calls, "snapshot") + }) + } +} + +func TestInfraGenerateExplicitInputsAndNoPrompt(t *testing.T) { + for _, flag := range []string{"--environment", "--location", "--models", "--api-version", + "--foundry-endpoint", "--output-dir"} { + t.Run(flag, func(t *testing.T) { + host := infraCommandFixture(t) + target := filepath.Join(testutil.TempDir(t), "project") + stdout, _, err := executeInfra(t, host, "infra", "generate", "-d", target, flag, "") + require.Error(t, err) + if flag != "--api-version" { + assert.Contains(t, err.Error(), flag) + } + assert.Empty(t, stdout) + assert.Empty(t, host.calls) + assert.NoDirExists(t, target) + }) + } + host := infraCommandFixture(t) + stdout, _, err := executeInfra(t, host, "infra", "generate", + "--foundry-endpoint", "foundry", "--foundry-prefix", "") + require.Error(t, err) + assert.Empty(t, stdout) + assert.Empty(t, host.calls) +} + +func TestInfraGeneratePromptCancellation(t *testing.T) { + host := infraCommandFixture(t) + host.prompt = func(choices infra.Choices) (infra.Choices, error) { return choices, context.Canceled } + target := filepath.Join(testutil.TempDir(t), "project") + stdout, _, err := executeInfra(t, host, "infra", "generate", "-d", target, "--models", "none") + require.ErrorIs(t, err, context.Canceled) + assert.Empty(t, stdout) + assert.NoDirExists(t, target) + assert.Equal(t, "close", host.calls[len(host.calls)-1]) +} + +func TestInfraGenerateOutputModes(t *testing.T) { + for _, format := range []string{"default", "json", "none"} { + t.Run(format, func(t *testing.T) { + host := infraCommandFixture(t) + t.Setenv("AZD_ENVIRONMENT", "context") + target := filepath.Join(testutil.TempDir(t), "project's $name [1]") + args := []string{"infra", "generate", "-d", target, "--models", "none", "--no-prompt", "-o", format} + stdout, stderr, err := executeInfra(t, host, args...) + require.NoError(t, err) + assert.Empty(t, stderr) + assert.Empty(t, host.prompts) + switch format { + case "json": + var result map[string]json.RawMessage + require.NoError(t, json.Unmarshal([]byte(stdout), &result)) + assert.Len(t, result, 16) + for _, key := range []string{"environment", "location", "api_version", "foundry_prefix", + "foundry_endpoint", "foundry_resource_group", "model_selection", "assign_roles", + "force_configuration_setup", "subscriptionId", "subscriptionName", "tenantId", + "outputDirectory", "files", "reusedExistingTemplate", "nextSteps"} { + assert.Contains(t, result, key) + } + var generation infra.Generation + require.NoError(t, json.Unmarshal([]byte(stdout), &generation)) + assert.Equal(t, "context", generation.Environment) + assert.Equal(t, "eastus2", generation.Location) + assert.Equal(t, "none", generation.ModelSelection) + assert.False(t, generation.AssignRoles) + assert.Len(t, generation.Files, 10) + assert.Len(t, generation.NextSteps, 3) + case "none": + assert.Empty(t, stdout) + default: + assert.Contains(t, stdout, "Generated project: "+target) + assert.Contains(t, stdout, "No Azure resources were deployed.") + if runtime.GOOS == "windows" { + assert.Contains(t, stdout, "Set-Location -LiteralPath '"+ + strings.ReplaceAll(target, "'", "''")+"'") + } else { + assert.Contains(t, stdout, `project'"'"'s $name [1]'`) + } + stdout, _, err = executeInfra(t, host, args...) + require.NoError(t, err) + assert.Contains(t, stdout, "Updated project:") + assert.Contains(t, stdout, "preserved infra/models.json") + } + assert.FileExists(t, filepath.Join(target, "azure.yaml")) + }) + } + t.Run("JSON skips new wizard without no-prompt", func(t *testing.T) { + host := infraCommandFixture(t) + _, _, err := executeInfra(t, host, "infra", "generate", "-d", + filepath.Join(testutil.TempDir(t), "project"), "--models", "none", "-o", "json") + require.NoError(t, err) + assert.Empty(t, host.prompts) + }) +} + +func generationPromptFixture(t *testing.T) (*infraRPC, *azdInfraHost, infra.Choices) { + t.Helper() + rpc := &infraRPC{} + rpc.promptText = func(request *azdext.PromptRequest) (*azdext.PromptResponse, error) { + return &azdext.PromptResponse{Value: request.GetOptions().GetDefaultValue()}, nil + } + rpc.selectOption = func(request *azdext.SelectRequest) (*azdext.SelectResponse, error) { + return &azdext.SelectResponse{Value: request.GetOptions().SelectedIndex}, nil + } + rpc.confirm = func(request *azdext.ConfirmRequest) (*azdext.ConfirmResponse, error) { + assert.False(t, request.GetOptions().GetDefaultValue()) + assert.NotContains(t, request.GetOptions().GetHelpMessage(), "API key") + return &azdext.ConfirmResponse{Value: new(false)}, nil + } + return rpc, infraRPCClient(t, rpc), infra.Choices{ + Environment: "dev", Location: "eastus2", APIVersion: "2025-11-01", ModelSelection: "none", + } +} + +func TestInfraGenerationHostPrompts(t *testing.T) { + rpc, host, choices := generationPromptFixture(t) + inputs := []string{"../invalid", "chosen", "invalid prefix", "app"} + rpc.promptText = func(request *azdext.PromptRequest) (*azdext.PromptResponse, error) { + if len(inputs) == 3 || len(inputs) == 1 { + assert.Contains(t, request.GetOptions().GetHelpMessage(), "Invalid input:") + } + value := inputs[0] + inputs = inputs[1:] + return &azdext.PromptResponse{Value: value}, nil + } + rpc.selectOption = func(request *azdext.SelectRequest) (*azdext.SelectResponse, error) { + options := request.GetOptions() + assert.Equal(t, "eastus2", options.Choices[options.GetSelectedIndex()].GetValue()) + assert.Len(t, options.Choices, len(infra.Regions)) + assert.Equal(t, "westus3", options.Choices[11].GetValue()) + return &azdext.SelectResponse{Value: new(int32(11))}, nil + } + result, err := host.PromptGeneration(azdext.WithAccessToken(t.Context()), choices, + generationPrompts{true, true, true, true}) + require.NoError(t, err) + assert.Empty(t, inputs) + assert.Equal(t, "chosen", result.Environment) + assert.Equal(t, "app", result.FoundryPrefix) + assert.Equal(t, "westus3", result.Location) + assert.False(t, result.AssignRoles) +} + +func TestInfraGenerationHostPromptErrors(t *testing.T) { + for _, stage := range []string{"environment", "prefix", "region", "roles"} { + for _, invalid := range []bool{false, true} { + if invalid && (stage == "environment" || stage == "prefix") { + continue + } + t.Run(stage+map[bool]string{false: "/canceled", true: "/invalid"}[invalid], func(t *testing.T) { + rpc, host, choices := generationPromptFixture(t) + prompts := generationPrompts{} + var failure error + if !invalid { + failure = status.Error(codes.Canceled, "canceled") + } + switch stage { + case "environment", "prefix": + prompts.environment, prompts.prefix = stage == "environment", stage == "prefix" + rpc.promptText = func(*azdext.PromptRequest) (*azdext.PromptResponse, error) { + return nil, failure + } + case "region": + prompts.location = true + rpc.selectOption = func(*azdext.SelectRequest) (*azdext.SelectResponse, error) { + if invalid { + return &azdext.SelectResponse{Value: new(int32(-1))}, nil + } + return nil, failure + } + case "roles": + prompts.roles = true + rpc.confirm = func(*azdext.ConfirmRequest) (*azdext.ConfirmResponse, error) { + return nil, failure + } + } + _, err := host.PromptGeneration(azdext.WithAccessToken(t.Context()), choices, prompts) + require.Error(t, err) + rpc.mu.Lock() + assert.Empty(t, rpc.writes) + rpc.mu.Unlock() + }) + } + } +} + +func pickerModels() []infra.Model { + return []infra.Model{ + {Name: "gpt-5.2", Model: "gpt-5.2", Version: "1", Kind: "completion", SKU: "GlobalStandard", Default: true}, + {Name: "gpt-5.2", Model: "gpt-5.2", Version: "2", Kind: "completion", SKU: "GlobalStandard"}, + {Name: "text-embedding-3-large", Model: "text-embedding-3-large", Version: "1", + Kind: "embedding", SKU: "Standard", Default: true}, + } +} + +func TestInfraModelPickerSelections(t *testing.T) { + for _, scenario := range []string{"recommended", "none", "correct conflict", "ambiguous recommendation"} { + t.Run(scenario, func(t *testing.T) { + rpc := &infraRPC{confirm: func(request *azdext.ConfirmRequest) (*azdext.ConfirmResponse, error) { + assert.False(t, request.GetOptions().GetDefaultValue()) + assert.Contains(t, request.GetOptions().GetHelpMessage(), "charges") + return &azdext.ConfirmResponse{Value: new(true)}, nil + }} + models := pickerModels() + if scenario == "ambiguous recommendation" { + models[0].Default = false + } + calls := 0 + rpc.multiSelect = func(request *azdext.MultiSelectRequest) (*azdext.MultiSelectResponse, error) { + calls++ + options := request.GetOptions() + assert.True(t, options.GetAllowEmptySelection()) + assert.Contains(t, options.Choices[0].GetLabel(), "gpt-5.2@1 | completion | GlobalStandard") + if scenario == "ambiguous recommendation" { + assert.Contains(t, options.GetHelpMessage(), "No models preselected:") + for _, choice := range options.Choices { + assert.False(t, choice.GetSelected()) + } + return &azdext.MultiSelectResponse{}, nil + } + if scenario == "none" { + return &azdext.MultiSelectResponse{}, nil + } + if scenario == "correct conflict" { + if calls == 1 { + return &azdext.MultiSelectResponse{Values: options.Choices[:2]}, nil + } + assert.Contains(t, options.GetHelpMessage(), "Invalid selection:") + return &azdext.MultiSelectResponse{Values: []*azdext.MultiSelectChoice{options.Choices[1]}}, nil + } + assert.True(t, options.Choices[0].GetSelected()) + assert.False(t, options.Choices[1].GetSelected()) + assert.True(t, options.Choices[2].GetSelected()) + assert.Contains(t, options.Choices[0].GetLabel(), "(recommended)") + return &azdext.MultiSelectResponse{Values: []*azdext.MultiSelectChoice{ + options.Choices[0], options.Choices[2], + }}, nil + } + host := infraRPCClient(t, rpc) + capabilities := &infraCapabilities{host: host} + selected, err := capabilities.PromptModels(azdext.WithAccessToken(t.Context()), models) + require.NoError(t, err) + rpc.mu.Lock() + if scenario == "none" || scenario == "ambiguous recommendation" { + assert.NotContains(t, rpc.calls, "confirm") + } else { + assert.Contains(t, rpc.calls, "confirm") + } + rpc.mu.Unlock() + switch scenario { + case "recommended": + assert.Equal(t, []infra.Model{models[0], models[2]}, selected) + case "correct conflict": + assert.Equal(t, []infra.Model{models[1]}, selected) + assert.Equal(t, 2, calls) + default: + assert.Empty(t, selected) + } + }) + } +} + +func TestInfraModelPickerCancellation(t *testing.T) { + rpc := &infraRPC{multiSelect: func(*azdext.MultiSelectRequest) (*azdext.MultiSelectResponse, error) { + return nil, status.Error(codes.Canceled, "canceled") + }} + host := infraRPCClient(t, rpc) + capabilities := &infraCapabilities{host: host} + selected, err := capabilities.PromptModels(azdext.WithAccessToken(t.Context()), pickerModels()) + require.Error(t, err) + assert.Equal(t, codes.Canceled, status.Code(err)) + assert.Nil(t, selected) + host.noPrompt = true + _, err = capabilities.PromptModels(t.Context(), pickerModels()) + require.Error(t, err) + assert.Contains(t, err.Error(), "interactive decision") +} + +func TestInfraModelPickerInvalidResponse(t *testing.T) { + calls := 0 + rpc := &infraRPC{multiSelect: func(request *azdext.MultiSelectRequest) (*azdext.MultiSelectResponse, error) { + calls++ + if calls == 1 { + return &azdext.MultiSelectResponse{Values: []*azdext.MultiSelectChoice{{Value: "unknown@1"}}}, nil + } + assert.Contains(t, request.GetOptions().GetHelpMessage(), "Invalid selection:") + return nil, status.Error(codes.Canceled, "canceled") + }} + host := infraRPCClient(t, rpc) + _, err := host.promptModels(azdext.WithAccessToken(t.Context()), pickerModels()) + require.Error(t, err) + assert.Equal(t, codes.Canceled, status.Code(err)) + assert.Equal(t, 2, calls) +} + +func TestInfraGenerateCanceledContext(t *testing.T) { + _, host, choices := generationPromptFixture(t) + ctx, cancel := context.WithCancel(t.Context()) + cancel() + _, err := host.PromptGeneration(ctx, choices, generationPrompts{true, true, true, true}) + assert.True(t, errors.Is(err, context.Canceled)) +} + +type infraPickerCapabilities struct { + *infraCommandCapabilities + host *azdInfraHost +} + +func (c *infraPickerCapabilities) PromptModels(ctx context.Context, models []infra.Model) ([]infra.Model, error) { + return c.host.promptModels(ctx, models) +} + +func TestInfraModelPickerCancellationPreventsMutations(t *testing.T) { + for _, scenario := range []string{ + "picker canceled", "confirmation default", "confirmation canceled", "missing decision", + } { + t.Run(scenario, func(t *testing.T) { + rpc := &infraRPC{ + multiSelect: func(request *azdext.MultiSelectRequest) (*azdext.MultiSelectResponse, error) { + if scenario == "picker canceled" { + return nil, status.Error(codes.Canceled, "canceled") + } + return &azdext.MultiSelectResponse{Values: request.GetOptions().GetChoices()}, nil + }, + confirm: func(request *azdext.ConfirmRequest) (*azdext.ConfirmResponse, error) { + assert.False(t, request.GetOptions().GetDefaultValue()) + switch scenario { + case "confirmation canceled": + return nil, status.Error(codes.Canceled, "canceled") + case "missing decision": + return &azdext.ConfirmResponse{}, nil + default: + return &azdext.ConfirmResponse{Value: request.GetOptions().DefaultValue}, nil + } + }, + } + capabilities := &infraPickerCapabilities{ + infraCommandCapabilities: &infraCommandCapabilities{}, host: infraRPCClient(t, rpc), + } + output := filepath.Join(testutil.TempDir(t), "models.json") + _, err := infra.SetupModels(azdext.WithAccessToken(t.Context()), capabilities, "prompt", output, true, true) + require.Error(t, err) + if scenario == "confirmation default" { + assert.ErrorIs(t, err, context.Canceled) + } + assert.Equal(t, []string{"supported", "catalog"}, capabilities.calls) + assert.NoFileExists(t, output) + }) + } +} + +func TestInfraGenerateOutputFailure(t *testing.T) { + host := infraCommandFixture(t) + root := newRootCommandWithInfra(nil, func(*cobra.Command, *azdext.ExtensionContext) (infraHost, error) { + return host, nil + }) + target := filepath.Join(testutil.TempDir(t), "project") + root.SetOut(failingWriter{}) + root.SetArgs([]string{"infra", "generate", "-d", target, "--models", "none", "--no-prompt"}) + err := root.ExecuteContext(t.Context()) + require.Error(t, err) + assert.FileExists(t, filepath.Join(target, "azure.yaml")) +} + +func TestInfraGenerationThroughHostRPC(t *testing.T) { + for _, stage := range []string{"success", "environment", "prefix", "region", "roles"} { + t.Run(stage, func(t *testing.T) { + _ = infraCommandFixture(t) + rpc, _, _ := generationPromptFixture(t) + rpc.subscriptions = []*azdext.Subscription{{Id: "sub", TenantId: "tenant", Name: "Subscription"}} + rpc.defaultEnvironment = "unrelated" + rpc.promptText = func(request *azdext.PromptRequest) (*azdext.PromptResponse, error) { + current, value := "prefix", "app" + if request.GetOptions().GetRequired() { + current, value = "environment", "chosen" + } + if stage == current { + return nil, status.Error(codes.Canceled, "canceled") + } + return &azdext.PromptResponse{Value: value}, nil + } + rpc.selectOption = func(request *azdext.SelectRequest) (*azdext.SelectResponse, error) { + if stage == "region" { + return nil, status.Error(codes.Canceled, "canceled") + } + return &azdext.SelectResponse{Value: request.GetOptions().SelectedIndex}, nil + } + rpc.confirm = func(*azdext.ConfirmRequest) (*azdext.ConfirmResponse, error) { + if stage == "roles" { + return nil, status.Error(codes.Canceled, "canceled") + } + return &azdext.ConfirmResponse{Value: new(true)}, nil + } + target := filepath.Join(testutil.TempDir(t), "project") + stdout, stderr, err := executeRoot(t, "infra", "generate", "-d", target, "--models", "none") + assert.Empty(t, stderr) + if stage == "success" { + require.NoError(t, err) + assert.Contains(t, stdout, "Environment: chosen") + // #nosec G304 -- Reads a generated test-owned environment. + body, err := os.ReadFile(filepath.Join(target, ".azure", "chosen", ".env")) + require.NoError(t, err) + assert.Contains(t, string(body), `FOUNDRY_RESOURCE_PREFIX="app"`) + assert.Contains(t, string(body), `AZD_ASSIGN_ROLES="true"`) + assert.NoDirExists(t, filepath.Join(target, ".azure", "unrelated")) + } else { + require.Error(t, err) + assert.Empty(t, stdout) + assert.NoDirExists(t, target) + } + rpc.mu.Lock() + assert.Empty(t, rpc.writes) + assert.NotContains(t, rpc.calls, "current") + rpc.mu.Unlock() + }) + } +} diff --git a/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/infra_test.go b/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/infra_test.go index a12fe3b13fe..86e7b3930bd 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/infra_test.go +++ b/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/infra_test.go @@ -35,6 +35,8 @@ type infraCommandHost struct { calls []string selected string request infra.PostRequest + prompts []generationPrompts + prompt func(infra.Choices) (infra.Choices, error) } func infraCommandFixture(t *testing.T) *infraCommandHost { @@ -73,6 +75,15 @@ func (f *infraCommandHost) Capabilities(_ context.Context, _ infraSnapshot, requ return f.capabilities, f.capabilities, nil } func (f *infraCommandHost) Close() { f.calls = append(f.calls, "close") } +func (f *infraCommandHost) PromptGeneration( + _ context.Context, choices infra.Choices, prompts generationPrompts, +) (infra.Choices, error) { + f.prompts = append(f.prompts, prompts) + if f.prompt != nil { + return f.prompt(choices) + } + return choices, nil +} type infraCommandCapabilities struct { calls []string @@ -135,7 +146,7 @@ func TestInfraCommandsGenerationReuseAndNoEndpointDependency(t *testing.T) { t.Setenv("CU_API_KEY", "private-not-read") target := filepath.Join(testutil.TempDir(t), "generated") stdout, stderr, err := executeInfra(t, f, - "infra", "generate", "-d", target, "-e", "new-env", "--models", "none", "--no-prompt") + "infra", "generate", "-d", target, "-e", "new-env", "--models", "none", "--no-prompt", "-o", "json") require.NoError(t, err) assert.Empty(t, stderr) var result infra.Generation @@ -147,7 +158,7 @@ func TestInfraCommandsGenerationReuseAndNoEndpointDependency(t *testing.T) { assert.Len(t, result.Files, 10) f.calls = nil stdout, _, err = executeInfra(t, f, "infra", "generate", "-d", target, "-e", "new-env", "--models", "none", - "--foundry-endpoint", "foundry") + "--foundry-endpoint", "foundry", "-o", "json") require.NoError(t, err) require.NoError(t, json.Unmarshal([]byte(stdout), &result)) assert.True(t, result.ReusedExistingTemplate) @@ -227,7 +238,7 @@ func TestInfraNewResourceRoleAssignmentOptions(t *testing.T) { host := infraCommandFixture(t) target := filepath.Join(testutil.TempDir(t), "project") stdout, stderr, err := executeInfra(t, host, "infra", "generate", "--output-dir", target, - "--models", "none", "--no-prompt", test.flag) + "--models", "none", "--no-prompt", "-o", "json", test.flag) require.NoError(t, err) assert.Empty(t, stderr) var result infra.Generation @@ -340,6 +351,10 @@ type infraRPC struct { tenantResponse *azdext.LookupTenantResponse tenantErr error defaultEnvironment string + promptText func(*azdext.PromptRequest) (*azdext.PromptResponse, error) + selectOption func(*azdext.SelectRequest) (*azdext.SelectResponse, error) + confirm func(*azdext.ConfirmRequest) (*azdext.ConfirmResponse, error) + multiSelect func(*azdext.MultiSelectRequest) (*azdext.MultiSelectResponse, error) } func (h *infraRPC) record(value string) { @@ -436,6 +451,40 @@ type infraUserConfigRPC struct { rpc *infraRPC } +func (h *infraPromptRPC) Prompt(ctx context.Context, request *azdext.PromptRequest) (*azdext.PromptResponse, error) { + h.rpc.record("prompt-text") + if h.rpc.promptText == nil { + return h.UnimplementedPromptServiceServer.Prompt(ctx, request) + } + return h.rpc.promptText(request) +} + +func (h *infraPromptRPC) Select(ctx context.Context, request *azdext.SelectRequest) (*azdext.SelectResponse, error) { + h.rpc.record("select") + if h.rpc.selectOption == nil { + return h.UnimplementedPromptServiceServer.Select(ctx, request) + } + return h.rpc.selectOption(request) +} + +func (h *infraPromptRPC) Confirm(ctx context.Context, request *azdext.ConfirmRequest) (*azdext.ConfirmResponse, error) { + h.rpc.record("confirm") + if h.rpc.confirm == nil { + return h.UnimplementedPromptServiceServer.Confirm(ctx, request) + } + return h.rpc.confirm(request) +} + +func (h *infraPromptRPC) MultiSelect( + ctx context.Context, request *azdext.MultiSelectRequest, +) (*azdext.MultiSelectResponse, error) { + h.rpc.record("multi-select") + if h.rpc.multiSelect == nil { + return h.UnimplementedPromptServiceServer.MultiSelect(ctx, request) + } + return h.rpc.multiSelect(request) +} + func (h *infraUserConfigRPC) Get( _ context.Context, request *azdext.GetUserConfigRequest, ) (*azdext.GetUserConfigResponse, error) { diff --git a/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/installed_host_test.go b/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/installed_host_test.go index af8c294cebd..1ccbf2ce210 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/installed_host_test.go +++ b/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/installed_host_test.go @@ -122,6 +122,26 @@ func TestInstalledHostOffline(t *testing.T) { }) require.Contains(t, string(run(true, "extension", "list", "--installed", "-o", "json")), extensionID) require.Contains(t, string(run(true, "ai", "cu", "--help")), "analyzer") + require.Contains(t, string(run(true, "ai", "cu", "infra", "generate", "--help")), "human-readable") + for _, test := range []struct { + name string + args []string + want string + }{ + {"invalid region", []string{"--location", "invalid"}, "CU-supported"}, + {"empty environment", []string{"--environment", ""}, "--environment cannot be empty"}, + {"empty models", []string{"--models", ""}, "--models cannot be empty"}, + {"empty endpoint", []string{"--foundry-endpoint", ""}, "--foundry-endpoint cannot be empty"}, + {"conflicting prefix", []string{"--foundry-endpoint", "foundry", "--foundry-prefix", ""}, + "Remove --foundry-endpoint or --foundry-prefix"}, + {"conflicting roles", []string{"--assign-roles=false", "--no-assign-roles=false"}, + "Use --assign-roles or --no-assign-roles"}, + } { + target := filepath.Join(work, "not-generated", test.name) + args := append([]string{"ai", "cu", "infra", "generate", "--output-dir", target}, test.args...) + require.Contains(t, string(run(false, args...)), test.want, test.name) + require.NoDirExists(t, target, test.name) + } var actual map[string]string require.NoError(t, json.Unmarshal(run(true, "ai", "cu", "version", "-o", "json"), &actual)) require.Equal(t, map[string]string{"name": extensionID, "version": version}, actual) diff --git a/cli/azd/extensions/azure.ai.content-understanding/version.txt b/cli/azd/extensions/azure.ai.content-understanding/version.txt index 2c31a296e4c..9ff8406fee4 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/version.txt +++ b/cli/azd/extensions/azure.ai.content-understanding/version.txt @@ -1 +1 @@ -0.1.0-preview +0.1.1-preview From 6ece9b2de50897c1221fdd44d348b64305eef4a6 Mon Sep 17 00:00:00 2001 From: Chien Yuan Chang <186005646+chienyuanchang@users.noreply.github.com> Date: Tue, 27 Oct 2026 10:28:34 -0700 Subject: [PATCH 02/22] fix(ai-cu): improve actionable and locatable diagnostics Address P2 analysis and analyzer error guidance and privacy-safe schema source locations. Add regressions for explicit empty selectors and full extension help paths. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../CHANGELOG.md | 4 + .../docs/usage.md | 13 +++ .../internal/analysis/run.go | 30 +++++- .../internal/analysis/run_test.go | 63 ++++++++++++ .../cmd/analyzer_testing_playback_test.go | 4 +- .../internal/cmd/analyzer_validate.go | 11 ++- .../internal/cmd/analyzer_validate_test.go | 64 +++++++++++- .../internal/cmd/help_test.go | 19 ++++ .../internal/cmd/installed_host_test.go | 14 +++ .../internal/cuclient/pipeline.go | 7 +- .../internal/cuclient/pipeline_test.go | 54 ++++++++++ .../internal/cuclient/polling.go | 1 + .../internal/schema/locations.go | 98 +++++++++++++++++++ .../internal/schema/locations_test.go | 42 ++++++++ 14 files changed, 417 insertions(+), 7 deletions(-) create mode 100644 cli/azd/extensions/azure.ai.content-understanding/internal/schema/locations.go create mode 100644 cli/azd/extensions/azure.ai.content-understanding/internal/schema/locations_test.go diff --git a/cli/azd/extensions/azure.ai.content-understanding/CHANGELOG.md b/cli/azd/extensions/azure.ai.content-understanding/CHANGELOG.md index 1aac3c71bca..782cc31d8fa 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/CHANGELOG.md +++ b/cli/azd/extensions/azure.ai.content-understanding/CHANGELOG.md @@ -10,6 +10,10 @@ before deploying interactively selected models. - Show a readable infrastructure generation summary by default. Use `--output json` to retain the structured result. +- Include safe service codes and per-input analysis failure reasons, with + actionable guidance for missing analyzers and conflicting analyzer IDs. +- Locate offline schema validation errors by line and column without exposing + authored field names or rejected values. ## 0.1.0-preview (2026-10-27) diff --git a/cli/azd/extensions/azure.ai.content-understanding/docs/usage.md b/cli/azd/extensions/azure.ai.content-understanding/docs/usage.md index a3b01ccb540..f36cd687a31 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/docs/usage.md +++ b/cli/azd/extensions/azure.ai.content-understanding/docs/usage.md @@ -173,6 +173,14 @@ writes. Analysis dry runs still require resolved endpoint and analyzer configuration. Select inputs using one mode: positional inputs, `--file`, `--source`, or `--url`. +Analysis failures include safe reasons and guidance for the first failed inputs +in discovery order, identified by input number without echoing file paths or URLs. +Long diagnostics are shortened; use `--report-file` on the original run to retain +all per-input outcomes. Diagnosing a failure does not issue another analysis request. +Missing-resource errors suggest checking the endpoint and available analyzers; +creation conflicts suggest inspecting the existing analyzer and choosing an unused ID. +Service response bodies and authored content remain hidden. + - Batch concurrency defaults to 4; `--concurrency` accepts 1-32. - More than 50 selected inputs require confirmation or explicit `--yes`. - Existing results fail by default. Use `--on-existing skip` to keep them or @@ -222,6 +230,11 @@ azd ai cu analyzer validate \ ``` Validation is offline and does not require an azd project, endpoint, or sign-in. +Errors in valid JSON identify the property by its one-based source line and column, +without displaying authored field names or rejected values. Columns count Unicode +characters; tabs count as one character. Missing properties point to their +containing object when available. Ambiguous dotted paths list all matching source +locations. Successful validation output retains its existing structured paths. Create a new analyzer from the reviewed definition: diff --git a/cli/azd/extensions/azure.ai.content-understanding/internal/analysis/run.go b/cli/azd/extensions/azure.ai.content-understanding/internal/analysis/run.go index 0089340dd0f..8ea7168372b 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/internal/analysis/run.go +++ b/cli/azd/extensions/azure.ai.content-understanding/internal/analysis/run.go @@ -10,6 +10,7 @@ import ( "errors" "fmt" "os" + "strings" "sync" "sync/atomic" @@ -160,11 +161,30 @@ func Execute(ctx context.Context, client cuclient.Client, plan Plan, options Run } } if summary.Counts.Failed != 0 { + var details strings.Builder + shown := 0 + for index, record := range result.Records { + if record.Status != "failed" { + continue + } + diagnostic := fmt.Sprintf("\nInput %d: %s", index+1, record.Error) + if details.Len()+len(diagnostic) > 1400 && shown != 0 { + fmt.Fprintf(&details, "\n%d additional failed input(s).", summary.Counts.Failed-shown) + break + } + if len(diagnostic) > 1400 { + runes := []rune(diagnostic) + diagnostic = string(runes[:min(len(runes), 300)]) + "..." + } + details.WriteString(diagnostic) + shown++ + } return result, &cuclient.Error{ Kind: cuclient.ErrorService, Code: "analysis_failed", Message: fmt.Sprintf( - "%d analysis input(s) failed; successful result files were retained.", summary.Counts.Failed), - Suggestion: "Use --report-file to inspect per-input outcomes.", + "%d analysis input(s) failed; successful result files were retained.%s", summary.Counts.Failed, details.String()), + Suggestion: "Correct the reported input or analyzer problem before retrying. " + + "Use --report-file on future runs to save all per-input outcomes.", } } return result, nil @@ -300,7 +320,11 @@ func safeDiagnostic(err error) string { return "Analysis timed out." } if domain, ok := errors.AsType[*cuclient.Error](err); ok { - return exterrors.Redact(domain.Message) + message := domain.Message + if domain.Suggestion != "" { + message += " " + domain.Suggestion + } + return exterrors.Redact(message) } return "Analysis failed; check the input, authentication, and service availability." } diff --git a/cli/azd/extensions/azure.ai.content-understanding/internal/analysis/run_test.go b/cli/azd/extensions/azure.ai.content-understanding/internal/analysis/run_test.go index 5fa0882117f..d0fe73ca89d 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/internal/analysis/run_test.go +++ b/cli/azd/extensions/azure.ai.content-understanding/internal/analysis/run_test.go @@ -8,13 +8,75 @@ import ( "encoding/json" "fmt" "io" + "strings" "testing" "azure.ai.content-understanding/internal/cuclient" + "azure.ai.content-understanding/internal/exterrors" "github.com/stretchr/testify/require" ) +func TestExecutionFailureDiagnosticsAreActionableAndBounded(t *testing.T) { + for _, count := range []int{1, 12} { + t.Run(fmt.Sprint(count), func(t *testing.T) { + urls := make([]string, count) + for index := range urls { + urls[index] = fmt.Sprintf("https://example.com/private-%d.pdf?sig=private-signature", index) + } + inputs, err := Discover(t.Context(), InputOptions{URLs: urls}) + require.NoError(t, err) + plan, err := PlanOutputs(inputs, OutputOptions{Existing: "error", Directory: t.TempDir()}) + require.NoError(t, err) + calls := 0 + client := fakeAnalysisClient{analyze: func( + context.Context, cuclient.AnalyzeRequest, + ) (cuclient.AnalyzeResponse, error) { + calls++ + return cuclient.AnalyzeResponse{}, &cuclient.Error{ + Kind: cuclient.ErrorNotFound, Code: "NotFound", StatusCode: 404, + Message: "The Content Understanding request failed (HTTP 404; code: NotFound).", + Suggestion: "Verify the analyzer ID. Use azd ai cu analyzer list. " + + "https://user:private-password@example.com/input?sig=private-secret#private-fragment", + } + }} + options := runOptions() + options.Concurrency = 1 + result, err := Execute(t.Context(), client, plan, options) + require.Error(t, err) + diagnostic := exterrors.FromError(err).Error() + require.Contains(t, diagnostic, "Input 1:") + require.Contains(t, diagnostic, "HTTP 404; code: NotFound") + require.Contains(t, diagnostic, "azd ai cu analyzer list") + require.NotContains(t, diagnostic, "private") + require.NotContains(t, diagnostic, "Diagnostic omitted") + require.Equal(t, count, calls, "diagnostics must not issue another analysis request") + require.Len(t, result.Records, count) + if count > 1 { + require.Contains(t, diagnostic, "additional failed input(s)") + } + }) + } +} + +func TestExecutionLongFailureStillShowsFirstReason(t *testing.T) { + inputs := discoverFiles(t, "a.pdf") + plan, err := PlanOutputs(inputs, OutputOptions{Existing: "error"}) + require.NoError(t, err) + client := fakeAnalysisClient{analyze: func( + context.Context, cuclient.AnalyzeRequest, + ) (cuclient.AnalyzeResponse, error) { + return cuclient.AnalyzeResponse{}, &cuclient.Error{ + Kind: cuclient.ErrorService, Message: "Invalid document. " + strings.Repeat("x", 1800), + } + }} + _, err = Execute(t.Context(), client, plan, runOptions()) + require.Error(t, err) + diagnostic := exterrors.FromError(err).Error() + require.Contains(t, diagnostic, "Input 1: Invalid document.") + require.NotContains(t, diagnostic, "Diagnostic omitted") +} + func TestExecutionInputModeAndUsageContracts(t *testing.T) { for _, mode := range []cuclient.AnalysisMode{cuclient.AnalysisModeAsync, cuclient.AnalysisModeInline} { for _, remote := range []bool{false, true} { @@ -27,6 +89,7 @@ func TestExecutionInputModeAndUsageContracts(t *testing.T) { inputs, err = Discover(t.Context(), InputOptions{URLs: []string{url}}) require.NoError(t, err) } + plan, err := PlanOutputs(inputs, OutputOptions{Existing: "error"}) require.NoError(t, err) calls := 0 diff --git a/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/analyzer_testing_playback_test.go b/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/analyzer_testing_playback_test.go index 66f3f2fd088..e6c4f0779f8 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/analyzer_testing_playback_test.go +++ b/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/analyzer_testing_playback_test.go @@ -114,7 +114,9 @@ func TestRecordedAnalyzerFieldReport(t *testing.T) { }, "samples": []any{map[string]any{ "input": "sample_invoice.pdf", "status": "error", - "error": "The analysis operation failed.", "fields": map[string]any{}, + "error": "The analysis operation failed. Service code: InvalidRequest. " + + "Check the analyzer configuration, input, and dependencies before retrying.", + "fields": map[string]any{}, }}, }) require.NoError(t, err) diff --git a/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/analyzer_validate.go b/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/analyzer_validate.go index 7a2de0f317b..409813e4672 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/analyzer_validate.go +++ b/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/analyzer_validate.go @@ -4,6 +4,7 @@ package cmd import ( + "encoding/json" "os" "path/filepath" "strings" @@ -72,10 +73,18 @@ func newAnalyzerValidateCommand(extCtx *azdext.ExtensionContext) *cobra.Command findings = append(findings, result.Warnings...) } if len(findings) != 0 { + var locations map[string][]string + if json.Valid(data) { + locations, err = schema.SourceLocations(data) + if err != nil { + return exterrors.FromError(commandError("schema_location_failed", + "The schema source locations could not be determined.", "Check the schema JSON and retry.")) + } + } var message strings.Builder message.WriteString("Schema validation failed:") for _, finding := range findings { - message.WriteString("\n" + finding.DiagnosticPath() + ": " + finding.Message) + message.WriteString("\n" + finding.DiagnosticLocation(locations) + ": " + finding.Message) } return exterrors.FromError(commandError("invalid_analyzer_schema", message.String(), "Correct the reported constraints, or omit --strict to allow authoring warnings.")) diff --git a/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/analyzer_validate_test.go b/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/analyzer_validate_test.go index 505c2477c38..6649b163373 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/analyzer_validate_test.go +++ b/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/analyzer_validate_test.go @@ -73,7 +73,7 @@ func TestAnalyzerValidateStrictSpecAndVersion(t *testing.T) { assert.Contains(t, stdout, `"warnings": []`) assert.Contains(t, stdout, `"strict": true`) _, _, err = f.execute(t.Context(), "analyzer", "validate", file) - require.ErrorContains(t, err, "apiVersion") + require.ErrorContains(t, err, "Line 2, column 3") assert.Zero(t, f.opens) } @@ -131,3 +131,65 @@ func TestAnalyzerValidateCancellationWriterAndMetadata(t *testing.T) { assert.True(t, command.Launch.NoAzdEnvironment) assert.Zero(t, f.opens) } + +func TestAnalyzerValidateLocatesPrivateFieldErrors(t *testing.T) { + f := newDefaultsFixture(t) + file := validationFile(t, `{ + "baseAnalyzerId": "prebuilt-document", + "models": {"completion": "gpt-4.1"}, + "fieldSchema": {"fields": { + "private.field": { + "type": "currency", + "method": "extarct", + "description": "private authored description" + } + }} +}`) + for _, spec := range []bool{false, true} { + args := []string{"analyzer", "validate", file, "--strict"} + if spec { + args = append(args, "--spec") + } + stdout, stderr, err := f.execute(t.Context(), args...) + require.Error(t, err) + require.Contains(t, err.Error(), "Line 6, column 7") + require.Contains(t, err.Error(), "Line 7, column 7") + if spec { + require.Contains(t, err.Error(), "service contract (enum)") + } + for _, private := range []string{"private", "currency", "extarct", file} { + require.NotContains(t, err.Error(), private) + } + require.Empty(t, stdout) + require.Empty(t, stderr) + } + require.Zero(t, f.opens) + require.Empty(t, f.client.calls) +} + +func TestExplicitEmptyServiceSelectorsDoNotUseFallbacks(t *testing.T) { + t.Setenv("CU_ENDPOINT", "https://configured.services.ai.azure.com") + t.Setenv("CU_API_VERSION", cuclient.DefaultAPIVersion) + file := validationFile(t, `{}`) + for _, command := range [][]string{ + {"analyzer", "list"}, + {"analyzer", "show", "prebuilt-layout"}, + {"analyzer", "create", "test", "--schema", file}, + {"analyzer", "delete", "test", "--yes"}, + {"doctor"}, + {"analyze", "--url", "https://example.com/input.pdf"}, + } { + for _, flag := range []string{"--endpoint", "--api-version"} { + t.Run(command[0]+"/"+flag, func(t *testing.T) { + f := newDefaultsFixture(t) + args := append(append([]string{}, command...), flag, "") + stdout, stderr, err := f.execute(t.Context(), args...) + require.ErrorContains(t, err, "must not be empty") + require.Empty(t, stdout) + require.Empty(t, stderr) + require.Zero(t, f.opens) + require.Empty(t, f.client.calls) + }) + } + } +} diff --git a/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/help_test.go b/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/help_test.go index 31be58563e5..bfb79601efb 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/help_test.go +++ b/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/help_test.go @@ -36,6 +36,25 @@ func renderHelp(t *testing.T, cmd *cobra.Command) string { return stdout.String() } +func TestHelpUsesFullExtensionNamespace(t *testing.T) { + setHelpColor(t, false) + root := NewRootCommand() + for _, path := range [][]string{nil, {"analyze"}, {"analyzer"}, {"analyzer", "validate"}} { + command, _, err := root.Find(path) + require.NoError(t, err) + text := renderHelp(t, command) + expected := "azd ai cu" + if len(path) != 0 { + expected += " " + strings.Join(path, " ") + } + require.Contains(t, text, " "+expected) + require.NotContains(t, text, "\n cu ") + if command.HasAvailableSubCommands() { + require.Contains(t, text, "Use "+expected+" [command] --help") + } + } +} + func TestHelpPreservesSDKFlagOptions(t *testing.T) { setHelpColor(t, false) root, _ := azdext.NewExtensionRootCommand(azdext.ExtensionCommandOptions{Name: "cu"}) diff --git a/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/installed_host_test.go b/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/installed_host_test.go index 1ccbf2ce210..0604b2c000b 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/installed_host_test.go +++ b/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/installed_host_test.go @@ -156,6 +156,13 @@ func TestInstalledHostOffline(t *testing.T) { require.Equal(t, map[string]string{ "CU_ENDPOINT": "https://resource.services.ai.azure.com", "CU_API_VERSION": "2027-01-01-preview", }, userDefaults) + for _, flag := range []string{"--endpoint", "--api-version"} { + output := run(false, "ai", "cu", "analyzer", "list", flag, "") + require.Contains(t, string(output), "must not be empty") + } + var unchanged map[string]string + require.NoError(t, json.Unmarshal(run(true, "ai", "cu", "config", "list", "-o", "json"), &unchanged)) + require.Equal(t, userDefaults, unchanged) run(true, "ai", "cu", "config", "unset", "CU_ENDPOINT") run(false, "ai", "cu", "config", "get", "CU_ENDPOINT") @@ -171,6 +178,13 @@ func TestInstalledHostOffline(t *testing.T) { require.NoError(t, json.Unmarshal(output, &result)) require.True(t, result.OK) require.True(t, result.Strict) + invalidDefinition := "{\n \"baseAnalyzerId\":\"prebuilt-document\",\n" + + " \"fieldSchema\":{\"fields\":{\"private-field\":{\n \"type\":\"currency\"\n }}}\n}" + require.NoError(t, os.WriteFile(schema, []byte(invalidDefinition), 0o600)) + diagnostic := string(run(false, "ai", "cu", "analyzer", "validate", "--schema", schema, "--spec")) + require.Contains(t, diagnostic, "Line 4, column 5") + require.NotContains(t, diagnostic, "private-field") + require.NotContains(t, diagnostic, "currency") require.NoError(t, os.WriteFile(schema, []byte("{invalid"), 0o600)) require.Contains(t, string(run(false, "ai", "cu", "analyzer", "validate", "--schema", schema)), "ERROR") run(true, "extension", "uninstall", extensionID) diff --git a/cli/azd/extensions/azure.ai.content-understanding/internal/cuclient/pipeline.go b/cli/azd/extensions/azure.ai.content-understanding/internal/cuclient/pipeline.go index d4b644b8155..d2e3a6a379d 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/internal/cuclient/pipeline.go +++ b/cli/azd/extensions/azure.ai.content-understanding/internal/cuclient/pipeline.go @@ -75,8 +75,13 @@ func serviceResponseError(statusCode int, code, retryAfterHeader string) error { suggestion = "Check the azd account, selected tenant, and access to the CU resource." case http.StatusNotFound: kind = ErrorNotFound + suggestion = "Verify the requested resource or analyzer exists and check the endpoint. " + + "Use azd ai cu analyzer list to inspect available analyzers." case http.StatusConflict: kind = ErrorConflict + suggestion = "For an analyzer creation conflict, inspect the existing definition with " + + "azd ai cu analyzer show and choose an unused analyzer ID. " + + "Do not retry an unchanged conflicting request." case http.StatusRequestEntityTooLarge: kind = ErrorLimit case http.StatusTooManyRequests: @@ -85,7 +90,7 @@ func serviceResponseError(statusCode int, code, retryAfterHeader string) error { } return &Error{ Kind: kind, Code: code, StatusCode: statusCode, - Message: fmt.Sprintf("The Content Understanding request failed (HTTP %d).", statusCode), + Message: fmt.Sprintf("The Content Understanding request failed (HTTP %d; code: %s).", statusCode, code), Suggestion: suggestion, RetryAfter: retryAfter(retryAfterHeader, time.Now()), } } diff --git a/cli/azd/extensions/azure.ai.content-understanding/internal/cuclient/pipeline_test.go b/cli/azd/extensions/azure.ai.content-understanding/internal/cuclient/pipeline_test.go index 39b02df8e14..3f773f7cb27 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/internal/cuclient/pipeline_test.go +++ b/cli/azd/extensions/azure.ai.content-understanding/internal/cuclient/pipeline_test.go @@ -7,6 +7,7 @@ import ( "bytes" "context" "errors" + "fmt" "io" "net/http" "net/http/httptest" @@ -402,8 +403,61 @@ func TestBoundedErrorBodyAndRedaction(t *testing.T) { assert.True(t, body.closed) assert.LessOrEqual(t, body.read, maxErrorBodyBytes+1) } + } +func TestActionableServiceFailureDiagnostics(t *testing.T) { + for _, test := range []struct { + status int + code, hint string + }{ + {http.StatusNotFound, "NotFound", "azd ai cu analyzer list"}, + {http.StatusConflict, "Conflict", "choose an unused analyzer ID"}, + {http.StatusBadRequest, "private invalid code", "Check the service configuration"}, + } { + t.Run(test.code, func(t *testing.T) { + calls := 0 + client := newRESTTransport(t, DefaultAPIVersion, func(*http.Request) (*http.Response, error) { + calls++ + return response(test.status, fmt.Sprintf( + `{"error":{"code":%q,"message":"private response body"}}`, test.code)), nil + }) + var err error + if test.status == http.StatusConflict { + _, err = client.CreateAnalyzer(t.Context(), CreateAnalyzerRequest{ + AnalyzerID: "existing", Definition: []byte(`{"baseAnalyzerId":"prebuilt-document"}`), + }) + } else { + err = client.DeleteAnalyzer(t.Context(), "missing") + } + var domain *Error + require.ErrorAs(t, err, &domain) + require.Contains(t, domain.Suggestion, test.hint) + require.Contains(t, domain.Message, fmt.Sprintf("HTTP %d; code:", test.status)) + require.NotContains(t, domain.Message, "private") + if test.status == http.StatusBadRequest { + require.Contains(t, domain.Message, "http_400") + } else { + require.Contains(t, domain.Message, test.code) + } + require.Equal(t, 1, calls) + }) + } +} + +func TestOperationFailureDiagnosticsDoNotExposeServiceMessages(t *testing.T) { + for _, code := range []string{"InvalidRequest", "invalid private code"} { + domain := operationError("analysis", "Failed", &wireDiagnostic{ + Code: code, Message: "private authored content", + }) + require.NotContains(t, domain.Message, "private") + if code == "InvalidRequest" { + require.Contains(t, domain.Message, "Service code: InvalidRequest") + } else { + require.NotContains(t, domain.Message, "Service code:") + } + } +} func TestResponseErrorCategories(t *testing.T) { for _, test := range []struct { status int diff --git a/cli/azd/extensions/azure.ai.content-understanding/internal/cuclient/polling.go b/cli/azd/extensions/azure.ai.content-understanding/internal/cuclient/polling.go index ab7588b9f63..caf04b9bb53 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/internal/cuclient/polling.go +++ b/cli/azd/extensions/azure.ai.content-understanding/internal/cuclient/polling.go @@ -56,6 +56,7 @@ func operationError(operation, status string, problem *wireDiagnostic) *Error { if problem != nil { if serviceCodePattern.MatchString(problem.Code) { result.Code = problem.Code + result.Message += fmt.Sprintf(" Service code: %s.", problem.Code) } result.Details = diagnostics(problem.Details, true) } diff --git a/cli/azd/extensions/azure.ai.content-understanding/internal/schema/locations.go b/cli/azd/extensions/azure.ai.content-understanding/internal/schema/locations.go new file mode 100644 index 00000000000..84fbd988cad --- /dev/null +++ b/cli/azd/extensions/azure.ai.content-understanding/internal/schema/locations.go @@ -0,0 +1,98 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package schema + +import ( + "bytes" + "encoding/json" + "fmt" + "strconv" + "strings" + "unicode/utf8" +) + +// SourceLocations indexes property positions without putting authored names or values in diagnostics. +// Multiple locations are retained because dotted authoring paths can be ambiguous. +func SourceLocations(data []byte) (map[string][]string, error) { + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.UseNumber() + locations := map[string][]string{} + cursor, line, column := int64(0), 1, 1 + position := func(offset int64) string { + for cursor < offset { + value, size := utf8.DecodeRune(data[cursor:offset]) + if value == '\n' { + line, column = line+1, 1 + } else { + column++ + } + cursor += int64(size) + } + return fmt.Sprintf("Line %d, column %d", line, column) + } + nextOffset := func() int64 { + offset := decoder.InputOffset() + for offset < int64(len(data)) && strings.ContainsRune(" \t\r\n,:", rune(data[offset])) { + offset++ + } + return offset + } + var visit func(string, int64) error + visit = func(path string, offset int64) error { + locations[path] = append(locations[path], position(offset)) + token, err := decoder.Token() + if err != nil { + return err + } + switch token { + case json.Delim('{'): + for decoder.More() { + offset := nextOffset() + key, err := decoder.Token() + if err != nil { + return err + } + name, ok := key.(string) + if !ok { + return fmt.Errorf("invalid schema property token") + } + child := name + if path != "$" { + child = path + "." + name + } + if err := visit(child, offset); err != nil { + return err + } + } + _, err = decoder.Token() + case json.Delim('['): + for index := 0; decoder.More(); index++ { + if err := visit(path+"."+strconv.Itoa(index), nextOffset()); err != nil { + return err + } + } + _, err = decoder.Token() + } + return err + } + if err := visit("$", nextOffset()); err != nil { + return nil, err + } + return locations, nil +} + +// DiagnosticLocation falls back to the containing object for missing properties. +func (f Finding) DiagnosticLocation(locations map[string][]string) string { + for path := f.Path; path != ""; { + if positions := locations[path]; len(positions) != 0 { + return strings.Join(positions, "; ") + } + index := strings.LastIndexByte(path, '.') + if index < 0 { + break + } + path = path[:index] + } + return f.DiagnosticPath() +} diff --git a/cli/azd/extensions/azure.ai.content-understanding/internal/schema/locations_test.go b/cli/azd/extensions/azure.ai.content-understanding/internal/schema/locations_test.go new file mode 100644 index 00000000000..cb000553d1d --- /dev/null +++ b/cli/azd/extensions/azure.ai.content-understanding/internal/schema/locations_test.go @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package schema + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestSourceLocations(t *testing.T) { + const body = "{\r\n \"private.é\": {\"type\": \"currency\"},\r\n" + + " \"private\": {\"é\": {\"type\": \"extarct\"}},\r\n" + + " \"array\": [{\"type\": \"bad\"}],\r\n" + + " \"escaped\\\"name\": {\"type\": 123456789012345678901234567890}\r\n}" + locations, err := SourceLocations([]byte(body)) + require.NoError(t, err) + for _, test := range []struct { + path, want string + }{ + {"$", "Line 1, column 1"}, + {"private.é.type", "Line 2, column 17; Line 3, column 21"}, + {"private.é.items", "Line 2, column 3; Line 3, column 15"}, + {"array.0.type", "Line 4, column 14"}, + {`escaped"name.type`, "Line 5, column 21"}, + {"baseAnalyzerId", "baseAnalyzerId"}, + } { + t.Run(test.path, func(t *testing.T) { + label := (Finding{Path: test.path}).DiagnosticLocation(locations) + require.Equal(t, test.want, label) + require.NotContains(t, label, "private") + require.NotContains(t, label, "currency") + require.NotContains(t, label, "extarct") + }) + } +} + +func TestSourceLocationsInvalidJSON(t *testing.T) { + _, err := SourceLocations([]byte(`{"private":`)) + require.Error(t, err) +} From 69e7fb753f8381b1c8cff78c0ab93c6d67bfdaaa Mon Sep 17 00:00:00 2001 From: Chien Yuan Chang <186005646+chienyuanchang@users.noreply.github.com> Date: Tue, 27 Oct 2026 11:09:30 -0700 Subject: [PATCH 03/22] fix(ai-cu): wrap analysis failure diagnostic for lint Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../azure.ai.content-understanding/internal/analysis/run.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cli/azd/extensions/azure.ai.content-understanding/internal/analysis/run.go b/cli/azd/extensions/azure.ai.content-understanding/internal/analysis/run.go index 8ea7168372b..06019fe0a30 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/internal/analysis/run.go +++ b/cli/azd/extensions/azure.ai.content-understanding/internal/analysis/run.go @@ -182,7 +182,8 @@ func Execute(ctx context.Context, client cuclient.Client, plan Plan, options Run return result, &cuclient.Error{ Kind: cuclient.ErrorService, Code: "analysis_failed", Message: fmt.Sprintf( - "%d analysis input(s) failed; successful result files were retained.%s", summary.Counts.Failed, details.String()), + "%d analysis input(s) failed; successful result files were retained.%s", + summary.Counts.Failed, details.String()), Suggestion: "Correct the reported input or analyzer problem before retrying. " + "Use --report-file on future runs to save all per-input outcomes.", } From 9469f0f2c9e282b5636bf6b9e5223773dc8488b7 Mon Sep 17 00:00:00 2001 From: Chien Yuan Chang <186005646+chienyuanchang@users.noreply.github.com> Date: Tue, 27 Oct 2026 11:54:08 -0700 Subject: [PATCH 04/22] test(ai-cu): canonicalize analysis diagnostic output fixture Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../internal/analysis/run_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cli/azd/extensions/azure.ai.content-understanding/internal/analysis/run_test.go b/cli/azd/extensions/azure.ai.content-understanding/internal/analysis/run_test.go index d0fe73ca89d..75a7f20c75c 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/internal/analysis/run_test.go +++ b/cli/azd/extensions/azure.ai.content-understanding/internal/analysis/run_test.go @@ -13,6 +13,7 @@ import ( "azure.ai.content-understanding/internal/cuclient" "azure.ai.content-understanding/internal/exterrors" + "azure.ai.content-understanding/internal/testutil" "github.com/stretchr/testify/require" ) @@ -26,7 +27,7 @@ func TestExecutionFailureDiagnosticsAreActionableAndBounded(t *testing.T) { } inputs, err := Discover(t.Context(), InputOptions{URLs: urls}) require.NoError(t, err) - plan, err := PlanOutputs(inputs, OutputOptions{Existing: "error", Directory: t.TempDir()}) + plan, err := PlanOutputs(inputs, OutputOptions{Existing: "error", Directory: testutil.TempDir(t)}) require.NoError(t, err) calls := 0 client := fakeAnalysisClient{analyze: func( From a83678fc794cb53f89db751335519a27f59072b9 Mon Sep 17 00:00:00 2001 From: Chien Yuan Chang <186005646+chienyuanchang@users.noreply.github.com> Date: Wed, 28 Oct 2026 11:59:11 -0700 Subject: [PATCH 05/22] fix(ai-cu): scope prompt retries and locate selected schema aliases Retry only validation errors for the prompted infrastructure field. Resolve authoring diagnostics against the selected alias without remapping literal spec findings or changing structured finding output. Add regression coverage and document source-location behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../azure.ai.content-understanding/README.md | 5 ++ .../internal/cmd/analyzer_validate_test.go | 29 ++++++ .../internal/cmd/infra_prompts.go | 9 +- .../internal/cmd/infra_prompts_test.go | 39 ++++++++ .../internal/schema/diagnostics_test.go | 10 +-- .../internal/schema/locations.go | 31 ++++++- .../internal/schema/locations_test.go | 88 +++++++++++++++++++ .../internal/schema/spec.go | 2 +- .../internal/schema/validate.go | 17 ++-- 9 files changed, 215 insertions(+), 15 deletions(-) diff --git a/cli/azd/extensions/azure.ai.content-understanding/README.md b/cli/azd/extensions/azure.ai.content-understanding/README.md index b49cb4a63c9..2df14e6efc4 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/README.md +++ b/cli/azd/extensions/azure.ai.content-understanding/README.md @@ -249,6 +249,11 @@ The test summarizes field population and confidence, not extraction accuracy against labeled ground truth. See [custom analyzer guidance](https://github.com/Azure/azure-dev/blob/main/cli/azd/extensions/azure.ai.content-understanding/docs/usage.md#create-a-custom-analyzer) for validation rules and test options. +Validation errors identify source line/column locations. Authoring checks locate +the alias they actually use (for example, `field_schema` when `fieldSchema` is +empty); `--spec` findings retain locations in the original service-contract body. +Structured finding paths remain unchanged. + ## Command overview | Command | Purpose | diff --git a/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/analyzer_validate_test.go b/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/analyzer_validate_test.go index 6649b163373..84ee75f9039 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/analyzer_validate_test.go +++ b/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/analyzer_validate_test.go @@ -167,6 +167,35 @@ func TestAnalyzerValidateLocatesPrivateFieldErrors(t *testing.T) { require.Empty(t, f.client.calls) } +func TestAnalyzerValidateLocatesEmptyPrimaryAliasErrors(t *testing.T) { + f := newDefaultsFixture(t) + file := validationFile(t, `{ + "baseAnalyzerId": "prebuilt-document", + "models": {"completion": "gpt-4.1"}, + "fieldSchema": {}, + "field_schema": {"fields": { + "private.field": { + "type": "currency" + } + }} +}`) + for _, spec := range []bool{false, true} { + args := []string{"analyzer", "validate", file, "--strict"} + if spec { + args = append(args, "--spec") + } + stdout, stderr, err := f.execute(t.Context(), args...) + require.ErrorContains(t, err, "Line 7, column 7") + for _, private := range []string{"private", "currency", file} { + assert.NotContains(t, err.Error(), private) + } + assert.Empty(t, stdout) + assert.Empty(t, stderr) + } + assert.Zero(t, f.opens) + assert.Empty(t, f.client.calls) +} + func TestExplicitEmptyServiceSelectorsDoNotUseFallbacks(t *testing.T) { t.Setenv("CU_ENDPOINT", "https://configured.services.ai.azure.com") t.Setenv("CU_API_VERSION", cuclient.DefaultAPIVersion) diff --git a/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/infra_prompts.go b/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/infra_prompts.go index 6587aa0fb04..ec2ae778b72 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/infra_prompts.go +++ b/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/infra_prompts.go @@ -33,13 +33,14 @@ func (h *azdInfraHost) PromptGeneration( message string help string value *string + code string }{ {prompts.environment, "azd environment name", "Generate files only; run azd up afterwards. This name identifies the deployment under .azure/.", - &choices.Environment}, + &choices.Environment, "invalid_environment"}, {prompts.prefix, "Optional Microsoft Foundry resource name prefix (blank for generated name)", "The globally unique resource name becomes part of its public endpoint. Use 1-20 letters, numbers or hyphens.", - &choices.FoundryPrefix}, + &choices.FoundryPrefix, "invalid_foundry_prefix"}, } { if !field.enabled { continue @@ -62,6 +63,10 @@ func (h *azdInfraHost) PromptGeneration( *field.value = response.GetValue() normalized, err := choices.Normalize() if err != nil { + local, ok := errors.AsType[*cuclient.Error](err) + if !ok || local.Kind != cuclient.ErrorValidation || local.Code != field.code { + return choices, err + } help = field.help + "\nInvalid input: " + err.Error() continue } diff --git a/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/infra_prompts_test.go b/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/infra_prompts_test.go index d270a7e7898..85ddc1f1fe3 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/infra_prompts_test.go +++ b/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/infra_prompts_test.go @@ -13,6 +13,7 @@ import ( "strings" "testing" + "azure.ai.content-understanding/internal/cuclient" "azure.ai.content-understanding/internal/infra" "azure.ai.content-understanding/internal/testutil" @@ -213,6 +214,44 @@ func TestInfraGenerationHostPrompts(t *testing.T) { assert.False(t, result.AssignRoles) } +func TestInfraGenerationHostDoesNotRetryOtherFields(t *testing.T) { + for _, test := range []struct { + name, environment, location, prefix, code string + prompts generationPrompts + }{ + {"environment with invalid location", "dev", "francecentral", "", "invalid_location", + generationPrompts{environment: true}}, + {"prefix with invalid location", "dev", "francecentral", "", "invalid_location", + generationPrompts{prefix: true}}, + {"environment with invalid prefix", "dev", "eastus2", "invalid prefix", "invalid_foundry_prefix", + generationPrompts{environment: true}}, + {"prefix with invalid environment", "../invalid", "eastus2", "", "invalid_environment", + generationPrompts{prefix: true}}, + } { + t.Run(test.name, func(t *testing.T) { + rpc, host, choices := generationPromptFixture(t) + choices.Environment, choices.Location, choices.FoundryPrefix = test.environment, test.location, test.prefix + calls := 0 + rpc.promptText = func(request *azdext.PromptRequest) (*azdext.PromptResponse, error) { + calls++ + if calls > 1 { + return nil, status.Error(codes.FailedPrecondition, "unexpected repeated prompt") + } + return &azdext.PromptResponse{Value: request.GetOptions().GetDefaultValue()}, nil + } + _, err := host.PromptGeneration(azdext.WithAccessToken(t.Context()), choices, test.prompts) + require.Error(t, err) + local, ok := errors.AsType[*cuclient.Error](err) + require.True(t, ok, "expected the unrelated validation error, got %v", err) + assert.Equal(t, test.code, local.Code) + assert.Equal(t, 1, calls) + rpc.mu.Lock() + assert.Empty(t, rpc.writes) + rpc.mu.Unlock() + }) + } +} + func TestInfraGenerationHostPromptErrors(t *testing.T) { for _, stage := range []string{"environment", "prefix", "region", "roles"} { for _, invalid := range []bool{false, true} { diff --git a/cli/azd/extensions/azure.ai.content-understanding/internal/schema/diagnostics_test.go b/cli/azd/extensions/azure.ai.content-understanding/internal/schema/diagnostics_test.go index a6be9d53b29..9194e4959b1 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/internal/schema/diagnostics_test.go +++ b/cli/azd/extensions/azure.ai.content-understanding/internal/schema/diagnostics_test.go @@ -20,13 +20,13 @@ func TestValidationOrderedDiagnosticsAndSafePaths(t *testing.T) { }` expected := ValidationResult{ Errors: []Finding{ - {"config.enableSegment", "The value must be a boolean."}, - {"config.contentCategories", "Content categories must be a nonempty object."}, - {"fieldSchema.fields.a-private.$ref", "References are unsupported; inline the field definition."}, - {"fieldSchema.fields.z-private.items", "Array fields require an items object."}, + {Path: "config.enableSegment", Message: "The value must be a boolean."}, + {Path: "config.contentCategories", Message: "Content categories must be a nonempty object."}, + {Path: "fieldSchema.fields.a-private.$ref", Message: "References are unsupported; inline the field definition."}, + {Path: "fieldSchema.fields.z-private.items", Message: "Array fields require an items object."}, }, Warnings: []Finding{ - {"models.completion", "Field extraction needs a completion model, for example gpt-5.2."}, + {Path: "models.completion", Message: "Field extraction needs a completion model, for example gpt-5.2."}, }, } for _, version := range []string{"2025-11-01", "2026-06-01-preview"} { diff --git a/cli/azd/extensions/azure.ai.content-understanding/internal/schema/locations.go b/cli/azd/extensions/azure.ai.content-understanding/internal/schema/locations.go index 84fbd988cad..97bd30f639a 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/internal/schema/locations.go +++ b/cli/azd/extensions/azure.ai.content-understanding/internal/schema/locations.go @@ -82,9 +82,36 @@ func SourceLocations(data []byte) (map[string][]string, error) { return locations, nil } -// DiagnosticLocation falls back to the containing object for missing properties. +func (r *ValidationResult) resolveAuthoringLocations(root map[string]json.RawMessage) { + for _, names := range [][2]string{ + {"analyzerId", "analyzer_id"}, + {"baseAnalyzerId", "base_analyzer_id"}, + {"fieldSchema", "field_schema"}, + } { + primary := names[0] + name := authoringAliasName(root, primary, names[1]) + if name == primary { + continue + } + for _, findings := range [][]Finding{r.Errors, r.Warnings} { + for i := range findings { + path := findings[i].Path + if path == primary || strings.HasPrefix(path, primary+".") { + findings[i].sourcePath = name + strings.TrimPrefix(path, primary) + } + } + } + } +} + +// DiagnosticLocation uses the selected authoring alias or literal spec path, +// falling back to the containing object for missing properties. func (f Finding) DiagnosticLocation(locations map[string][]string) string { - for path := f.Path; path != ""; { + path := f.Path + if f.sourcePath != "" { + path = f.sourcePath + } + for path != "" { if positions := locations[path]; len(positions) != 0 { return strings.Join(positions, "; ") } diff --git a/cli/azd/extensions/azure.ai.content-understanding/internal/schema/locations_test.go b/cli/azd/extensions/azure.ai.content-understanding/internal/schema/locations_test.go index cb000553d1d..7febfa30cfc 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/internal/schema/locations_test.go +++ b/cli/azd/extensions/azure.ai.content-understanding/internal/schema/locations_test.go @@ -4,8 +4,12 @@ package schema import ( + "encoding/json" + "fmt" "testing" + "azure.ai.content-understanding/internal/cuclient" + "github.com/stretchr/testify/require" ) @@ -40,3 +44,87 @@ func TestSourceLocationsInvalidJSON(t *testing.T) { _, err := SourceLocations([]byte(`{"private":`)) require.Error(t, err) } + +func TestAuthoringDiagnosticLocationUsesSelectedAlias(t *testing.T) { + for _, primary := range []string{"{}", "[]", "null", "false", "0", `""`} { + t.Run(primary, func(t *testing.T) { + body := fmt.Appendf(nil, "{\n \"fieldSchema\": %s,\n \"field_schema\": {\n"+ + " \"fields\": {\n \"private\": {\n \"type\": \"currency\"\n }\n }\n }\n}", primary) + result, err := Validate(body, cuclient.DefaultAPIVersion, false) + require.NoError(t, err) + locations, err := SourceLocations(body) + require.NoError(t, err) + require.Len(t, result.Errors, 1) + finding := result.Errors[0] + require.Equal(t, "fieldSchema.fields.private.type", finding.Path) + require.Equal(t, "Line 6, column 9", finding.DiagnosticLocation(locations)) + encoded, err := json.Marshal(finding) + require.NoError(t, err) + require.JSONEq(t, `{"path":"fieldSchema.fields.private.type", + "msg":"Specify a field type: array, boolean, date, integer, json, number, object, string, time."}`, + string(encoded)) + }) + } +} + +func TestDiagnosticLocationKeepsSpecAndAuthoringSourcesSeparate(t *testing.T) { + const body = "{\n \"fieldSchema\": false,\n \"field_schema\": []\n}" + result, err := Validate([]byte(body), cuclient.DefaultAPIVersion, true) + require.NoError(t, err) + locations, err := SourceLocations([]byte(body)) + require.NoError(t, err) + authoring, spec := false, false + for _, finding := range result.Errors { + if finding.Path != "fieldSchema" { + continue + } + if finding.Message == "The field schema must be an object." { + authoring = true + require.Equal(t, "Line 3, column 3", finding.DiagnosticLocation(locations)) + } else { + spec = true + require.Contains(t, finding.Message, "service contract") + require.Equal(t, "Line 2, column 3", finding.DiagnosticLocation(locations)) + } + } + require.True(t, authoring, "expected the authoring finding for the selected alias") + require.True(t, spec, "expected the literal service-contract finding for the primary") +} + +func TestAuthoringDiagnosticLocationPreservesAliasPrecedence(t *testing.T) { + for _, test := range []struct { + primary, secondary, value, location string + }{ + {"analyzerId", "analyzer_id", `""`, "Line 3, column 3"}, + {"baseAnalyzerId", "base_analyzer_id", `""`, "Line 3, column 3"}, + {"analyzerId", "analyzer_id", `"bad/id"`, "Line 2, column 3"}, + {"baseAnalyzerId", "base_analyzer_id", `"bad/id"`, "Line 2, column 3"}, + } { + t.Run(test.primary+"/"+test.value, func(t *testing.T) { + body := fmt.Appendf(nil, "{\n %q: %s,\n %q: 123\n}", test.primary, test.value, test.secondary) + result, err := Validate(body, cuclient.DefaultAPIVersion, false) + require.NoError(t, err) + locations, err := SourceLocations(body) + require.NoError(t, err) + require.Len(t, result.Errors, 1) + require.Equal(t, test.primary, result.Errors[0].Path) + require.Equal(t, test.location, result.Errors[0].DiagnosticLocation(locations)) + }) + } +} + +func TestAuthoringWarningLocationUsesSelectedAlias(t *testing.T) { + const body = "{\n \"fieldSchema\": {},\n \"field_schema\": {\n \"description\": \"A complete description.\"\n }\n}" + result, err := Validate([]byte(body), cuclient.DefaultAPIVersion, false) + require.NoError(t, err) + locations, err := SourceLocations([]byte(body)) + require.NoError(t, err) + found := false + for _, finding := range result.Warnings { + if finding.Path == "fieldSchema.fields" { + found = true + require.Equal(t, "Line 3, column 3", finding.DiagnosticLocation(locations)) + } + } + require.True(t, found, "expected an authoring warning for missing fields") +} diff --git a/cli/azd/extensions/azure.ai.content-understanding/internal/schema/spec.go b/cli/azd/extensions/azure.ai.content-understanding/internal/schema/spec.go index c6f078b5025..a769ba54693 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/internal/schema/spec.go +++ b/cli/azd/extensions/azure.ai.content-understanding/internal/schema/spec.go @@ -83,7 +83,7 @@ func validateContract(data []byte, document any, version string) ([]Finding, err } // Validator messages can echo customer values. Report the constraint, // not the rejected instance or the validator's complete error string. - findings = append(findings, Finding{path, fmt.Sprintf( + findings = append(findings, Finding{Path: path, Message: fmt.Sprintf( "Violates the CU %s service contract (%s).", version, strings.Join(failure.ErrorKind.KeywordPath(), "/"), )}) } diff --git a/cli/azd/extensions/azure.ai.content-understanding/internal/schema/validate.go b/cli/azd/extensions/azure.ai.content-understanding/internal/schema/validate.go index ba2544ffa4a..f6304699762 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/internal/schema/validate.go +++ b/cli/azd/extensions/azure.ai.content-understanding/internal/schema/validate.go @@ -17,6 +17,8 @@ import ( type Finding struct { Path string `json:"path"` Message string `json:"msg"` + + sourcePath string } // DiagnosticPath keeps contract-defined locations, never customer-authored property names. @@ -50,6 +52,7 @@ func Validate(data []byte, version string, spec bool) (ValidationResult, error) return result, nil } result.root(root, version) + result.resolveAuthoringLocations(root) if spec { findings, err := validateSpec(data, version) if err != nil { @@ -62,11 +65,11 @@ func Validate(data []byte, version string, spec bool) (ValidationResult, error) } func (r *ValidationResult) fail(path, message string) { - r.Errors = append(r.Errors, Finding{path, message}) + r.Errors = append(r.Errors, Finding{Path: path, Message: message}) } func (r *ValidationResult) warn(path, message string) { - r.Warnings = append(r.Warnings, Finding{path, message}) + r.Warnings = append(r.Warnings, Finding{Path: path, Message: message}) } func (r *ValidationResult) root(root map[string]json.RawMessage, version string) { @@ -249,10 +252,14 @@ func (r *ValidationResult) depth(path string, depth int) { // primary container/value falls back to the alias. Spec validation still checks // the original, unmodified body and rejects values outside the service contract. func authoringAlias(root map[string]json.RawMessage, primary, secondary string) json.RawMessage { + return root[authoringAliasName(root, primary, secondary)] +} + +func authoringAliasName(root map[string]json.RawMessage, primary, secondary string) string { raw := root[primary] var value any if json.Unmarshal(raw, &value) != nil { - return raw + return primary } empty := value == nil switch value := value.(type) { @@ -268,7 +275,7 @@ func authoringAlias(root map[string]json.RawMessage, primary, secondary string) empty = len(value) == 0 } if empty { - return root[secondary] + return secondary } - return raw + return primary } From 3cce1984388b9a608216c6a2d510d82410eee6be Mon Sep 17 00:00:00 2001 From: Chien Yuan Chang <186005646+chienyuanchang@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:14:42 -0700 Subject: [PATCH 06/22] feat(ai-cu): add opt-in terminal Markdown previews Render Markdown with Glamour and supported HTML tables with azd tables or cards. Preserve raw output by default, protect preview metadata and sensitive URLs, and cover command validation and multilingual rendering. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../azure.ai.content-understanding/README.md | 7 + .../cspell.yaml | 5 + .../docs/usage.md | 56 ++++ .../azure.ai.content-understanding/go.mod | 10 +- .../internal/cmd/analyze.go | 49 +++- .../internal/cmd/analyze_render_test.go | 169 +++++++++++ .../internal/preview/metadata.go | 110 ++++++++ .../internal/preview/metadata_test.go | 93 ++++++ .../internal/preview/multilingual_test.go | 101 +++++++ .../internal/preview/render.go | 265 ++++++++++++++++++ .../internal/preview/render_test.go | 174 ++++++++++++ .../internal/preview/safety.go | 85 ++++++ 12 files changed, 1118 insertions(+), 6 deletions(-) create mode 100644 cli/azd/extensions/azure.ai.content-understanding/internal/cmd/analyze_render_test.go create mode 100644 cli/azd/extensions/azure.ai.content-understanding/internal/preview/metadata.go create mode 100644 cli/azd/extensions/azure.ai.content-understanding/internal/preview/metadata_test.go create mode 100644 cli/azd/extensions/azure.ai.content-understanding/internal/preview/multilingual_test.go create mode 100644 cli/azd/extensions/azure.ai.content-understanding/internal/preview/render.go create mode 100644 cli/azd/extensions/azure.ai.content-understanding/internal/preview/render_test.go create mode 100644 cli/azd/extensions/azure.ai.content-understanding/internal/preview/safety.go diff --git a/cli/azd/extensions/azure.ai.content-understanding/README.md b/cli/azd/extensions/azure.ai.content-understanding/README.md index 2df14e6efc4..a6a1fde9a6c 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/README.md +++ b/cli/azd/extensions/azure.ai.content-understanding/README.md @@ -175,6 +175,13 @@ Analysis displays YAML front matter and Markdown in the console by default. azd ai cu analyze invoice.pdf --output-file invoice.result.md ``` +Markdown and HTML tables remain unrendered by default, including when stdout is +redirected. Opt in to a human-readable preview with `azd ai cu analyze invoice.pdf +--render`. Standard Markdown uses Glamour; supported simple HTML tables use the +existing azd table/cards layout. The flag only applies to one streamed LLM-input +result and cannot be combined with saved-output options or JSON. See +[terminal preview behavior and limitations](docs/usage.md#optional-terminal-preview). + For the complete JSON response, including service warnings and usage when present, use `--output json`. Display it in the console or save it to a file: diff --git a/cli/azd/extensions/azure.ai.content-understanding/cspell.yaml b/cli/azd/extensions/azure.ai.content-understanding/cspell.yaml index f1ff3bf117d..ffa674795d9 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/cspell.yaml +++ b/cli/azd/extensions/azure.ai.content-understanding/cspell.yaml @@ -3,6 +3,11 @@ words: - contentunderstanding - noninteractive overrides: + - filename: "internal/preview/{render,metadata}.go" + words: + - goldmark + - rowspan + - yuin - filename: "internal/**/*.go" words: - exterrors diff --git a/cli/azd/extensions/azure.ai.content-understanding/docs/usage.md b/cli/azd/extensions/azure.ai.content-understanding/docs/usage.md index f36cd687a31..65389044839 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/docs/usage.md +++ b/cli/azd/extensions/azure.ai.content-understanding/docs/usage.md @@ -118,6 +118,62 @@ azd ai cu analyze \ --output-file invoice.result.md ``` +### Optional terminal preview + +The default stdout retains the original Markdown, including HTML tables. This +also applies to redirects and pipes, so GUI viewers and other consumers can do +their own rendering: + +```bash +azd ai cu analyze invoice.pdf > invoice.result.md +``` + +Opt in to a human-readable preview with `--render`; use `--render=false` or omit +the flag to retain original text: + +```bash +azd ai cu analyze invoice.pdf --render +``` + +The preview uses the existing Glamour renderer for Markdown, including standard +bold/italic syntax. Each LLM-input YAML front matter section is displayed as a +separate Metadata code block, not interpreted as document headings or tables. +The existing azd table/cards formatter handles supported HTML +tables. It renders all columns without truncation and selects labeled cards when +the full table exceeds the terminal width. Individual long values can still +overflow. This reuses existing dependencies; the HTML extraction adapter is +extension code. + +Supported tables have one explicit header row, unique nonempty headings, and +rectangular nonempty plain-text data cells. Whitespace is normalized and captions +are retained. Merged cells, rich cell markup, empty cells, duplicate headings, and +unsupported structures show a notice and the table source instead. Markdown +markers inside plain-text HTML cells remain literal. Native Markdown tables stay +with Glamour and can shorten long links. The pinned azd cards implementation has +a known label-alignment limitation with mixed Unicode headings; values are not +lost. + +Offline preview probes cover Traditional Chinese, Japanese, Korean, French, +German, Hindi, Arabic, and emoji/combining-character sequences. They check text +retention, not service language support or visual font/shaping/RTL correctness. +The Hindi table probe exposes another pinned-formatter limitation: its rune-width +padding disagrees with ANSI grapheme-width measurements, so columns can lose alignment. +Neither that issue nor Unicode card-label alignment is fixed by the adapter. + +Preview output removes terminal controls and URL credentials, queries, and fragments, +including in fallback source. It is not a byte-for-byte result representation. +Colors are used only for terminal writers when color is enabled; explicit +`--render` with a pipe, redirected file, or other writer produces an uncolored +preview at width 80. The preview does not fetch links or images. + +`--render` requires a single streamed LLM-input result. It cannot be combined with +`--output json`, `--output none`, `--llm-input=false`, `--usage`, `--dry-run`, +`--output-file`, or `--output-dir`; these conflicts are rejected before +authentication. Saved result files, reports, and the default stdout format are +unchanged. To save original Markdown/HTML, omit `--render`. + +### Complete JSON response + For the complete JSON response envelope, including the result, service warnings, and usage when present, use `--output json`. These examples display the JSON in the console and save it to a file, respectively: diff --git a/cli/azd/extensions/azure.ai.content-understanding/go.mod b/cli/azd/extensions/azure.ai.content-understanding/go.mod index 1c06ba51fed..0493700ea40 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/go.mod +++ b/cli/azd/extensions/azure.ai.content-understanding/go.mod @@ -43,9 +43,9 @@ require ( github.com/buger/jsonparser v1.6.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect - github.com/charmbracelet/glamour v1.0.0 // indirect + github.com/charmbracelet/glamour v1.0.0 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect - github.com/charmbracelet/x/ansi v0.11.9 // indirect + github.com/charmbracelet/x/ansi v0.11.9 github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/exp/slice v0.1.0 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect @@ -86,7 +86,7 @@ require ( github.com/theckman/yacspin v0.13.12 // indirect github.com/xo/terminfo v1.2.0 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect - github.com/yuin/goldmark v1.8.6 // indirect + github.com/yuin/goldmark v1.8.6 github.com/yuin/goldmark-emoji v1.0.6 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/otel v1.47.0 // indirect @@ -97,10 +97,10 @@ require ( go.uber.org/multierr v1.11.0 // indirect golang.org/x/crypto v0.58.0 // indirect golang.org/x/exp v0.0.0-20261014143641-f0251eb4aa3e // indirect - golang.org/x/net v0.61.0 // indirect + golang.org/x/net v0.61.0 golang.org/x/sync v0.24.0 // indirect golang.org/x/sys v0.49.0 - golang.org/x/term v0.47.0 // indirect + golang.org/x/term v0.47.0 golang.org/x/text v0.43.0 // indirect golang.org/x/time v0.16.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20261012220534-4341edf3f941 // indirect diff --git a/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/analyze.go b/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/analyze.go index 6737a62aba2..4cb52a53e9a 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/analyze.go +++ b/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/analyze.go @@ -11,9 +11,12 @@ import ( "azure.ai.content-understanding/internal/config" "azure.ai.content-understanding/internal/cuclient" "azure.ai.content-understanding/internal/exterrors" + "azure.ai.content-understanding/internal/preview" "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/fatih/color" "github.com/spf13/cobra" + "golang.org/x/term" ) type analyzeOptions struct { @@ -26,6 +29,7 @@ type analyzeOptions struct { usage bool dryRun bool yes bool + render bool } func newAnalyzeCommand(extCtx *azdext.ExtensionContext, open sessionFactory) *cobra.Command { @@ -35,6 +39,8 @@ func newAnalyzeCommand(extCtx *azdext.ExtensionContext, open sessionFactory) *co Long: "Analyze local files, directories, or HTTPS URLs using Microsoft Entra authentication.\n\n" + "Uses prebuilt-layout when no analyzer is specified or configured.\n" + "Results use YAML front matter and Markdown by default. Batches save source-relative result files.\n" + + "Use --render for an opt-in human-readable preview of one streamed LLM-input result.\n" + + "Without --render, stdout retains the original Markdown and HTML tables, including when redirected.\n" + "Use --output json for complete JSON, or --dry-run to inspect the plan without service calls.", Example: ` azd ai cu analyze invoice.pdf Analyze one file using the configured analyzer, or prebuilt-layout if unset. @@ -52,7 +58,10 @@ func newAnalyzeCommand(extCtx *azdext.ExtensionContext, open sessionFactory) *co Analyze matching PDF files recursively and preserve their relative paths. azd ai cu analyze invoice.pdf --llm-input --output-file invoice.md - Save YAML front matter and Markdown for generative AI model input.`, + Save YAML front matter and Markdown for generative AI model input. + + azd ai cu analyze invoice.pdf --render + Preview Markdown with supported HTML tables; wide tables use labeled cards.`, RunE: func(cmd *cobra.Command, args []string) error { return exterrors.FromError(runAnalyzeCommand(cmd, extCtx, open, options, args)) }, @@ -73,6 +82,8 @@ func newAnalyzeCommand(extCtx *azdext.ExtensionContext, open sessionFactory) *co flags.BoolVar(&options.usage, "usage", false, "Include usage metadata in streamed results or per-input summaries") flags.BoolVar(&options.output.LLMInput, "llm-input", true, "Render YAML front matter and Markdown; set false for complete JSON; true conflicts with --output json") + flags.BoolVar(&options.render, "render", false, + "Preview one streamed LLM-input result using Markdown and table/cards layout (default: original text)") flags.StringVar(&options.output.File, "output-file", "", "Save one result to this file") flags.StringVarP(&options.output.Directory, "output-dir", "d", "", "Save source-relative result files in this directory") flags.StringVar(&options.output.Existing, "on-existing", "", @@ -103,6 +114,25 @@ func runAnalyzeCommand( } options.output.LLMInput = false } + if options.render { + for _, conflict := range []struct { + set bool + name string + }{ + {extCtx.OutputFormat != "default", "--output " + extCtx.OutputFormat}, + {!options.output.LLMInput, "--llm-input=false"}, + {options.usage, "--usage"}, + {options.dryRun, "--dry-run"}, + {cmd.Flags().Changed("output-file"), "--output-file"}, + {cmd.Flags().Changed("output-dir"), "--output-dir"}, + } { + if conflict.set { + return commandError("conflicting_analyze_options", + "--render cannot be combined with "+conflict.name+".", + "Omit --render to keep original text, saved results, or structured output.") + } + } + } if options.dryRun && options.yes { return commandError("dry_run_confirmation", "--dry-run cannot be combined with --yes.", "Remove --yes when planning.") @@ -140,6 +170,11 @@ func runAnalyzeCommand( if err != nil { return err } + if options.render && len(plan.Outputs) != 1 { + return commandError("conflicting_analyze_options", + "--render requires one input and cannot preview a batch.", + "Select one input, or omit --render to save batch results.") + } if err := ctx.Err(); err != nil { return err } @@ -193,6 +228,18 @@ func runAnalyzeCommand( if err := ctx.Err(); err != nil { return err } + if options.render { + width, useColor := 80, false + if file, ok := cmd.OutOrStdout().(*os.File); ok && azdext.IsInteractiveTerminal(file) { + var err error + width, _, err = term.GetSize(int(file.Fd())) //nolint:gosec // File descriptor fits in int. + if err != nil { + return fmt.Errorf("read preview terminal size: %w", err) + } + useColor = !color.NoColor + } + return preview.Render(ctx, cmd.OutOrStdout(), text, width, useColor) + } if _, err := fmt.Fprintln(cmd.OutOrStdout(), text); err != nil { return fmt.Errorf("write CU result: %w", err) } diff --git a/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/analyze_render_test.go b/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/analyze_render_test.go new file mode 100644 index 00000000000..17b359f5101 --- /dev/null +++ b/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/analyze_render_test.go @@ -0,0 +1,169 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "azure.ai.content-understanding/internal/analysis" + "azure.ai.content-understanding/internal/cuclient" + "azure.ai.content-understanding/internal/testutil" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const previewMarkdown = "# Summary\n\n**Important** and *styled*.\n\n" + + "\n\n\n
NameValue
Example42
\n" + +func previewResponse(t *testing.T, fixture *analyzeFixture) json.RawMessage { + t.Helper() + body, err := json.Marshal(map[string]any{ + "status": "Succeeded", + "result": map[string]any{"contents": []any{map[string]any{"kind": "document", "markdown": previewMarkdown}}}, + }) + require.NoError(t, err) + fixture.client.call = func(context.Context, cuclient.AnalyzeRequest) (cuclient.AnalyzeResponse, error) { + return cuclient.AnalyzeResponse{JSON: body}, nil + } + return body +} + +func TestAnalyzeRenderingIsOptIn(t *testing.T) { + for _, flags := range [][]string{nil, {"--render=false"}, {"--render"}} { + t.Run(strings.Join(flags, " "), func(t *testing.T) { + fixture := newAnalyzeFixture(t) + body := previewResponse(t, fixture) + stdout, stderr, err := fixture.execute(t.Context(), append([]string{"https://example.com/doc.pdf"}, flags...)...) + require.NoError(t, err) + assert.Empty(t, stderr) + assert.NotContains(t, stdout, "\x1b", "injected/redirected output must not contain ANSI") + assert.EqualValues(t, 1, fixture.client.calls.Load()) + if len(flags) != 0 && flags[0] == "--render" { + assert.Contains(t, stdout, "Preview only") + assert.Contains(t, stdout, "Important") + assert.Contains(t, stdout, "Example") + assert.NotContains(t, stdout, "") + assert.Contains(t, stdout, "**Important**", "uncolored previews retain ASCII emphasis markers") + } else { + var envelope struct{ Result json.RawMessage } + require.NoError(t, json.Unmarshal(body, &envelope)) + expected, err := analysis.RenderLLM(envelope.Result) + require.NoError(t, err) + assert.Equal(t, expected+"\n", stdout) + assert.Contains(t, stdout, "
") + } + }) + } +} + +func TestAnalyzeRenderRejectsConflictsBeforeAuthentication(t *testing.T) { + for _, flags := range [][]string{ + {"--output", "json"}, {"--output", "none"}, {"--llm-input=false"}, {"--usage"}, {"--dry-run"}, + {"--output-file", "result.md"}, {"--output-dir", "results"}, {"--output-file="}, {"--output-dir="}, + } { + t.Run(strings.Join(flags, " "), func(t *testing.T) { + fixture := newAnalyzeFixture(t) + t.Chdir(testutil.TempDir(t)) + args := append([]string{"https://example.com/doc.pdf", "--render"}, flags...) + stdout, stderr, err := fixture.execute(t.Context(), args...) + require.ErrorContains(t, err, "--render") + assert.Empty(t, stdout) + assert.Empty(t, stderr) + assert.Zero(t, fixture.opens) + assert.Zero(t, fixture.client.calls.Load()) + entries, err := os.ReadDir(".") + require.NoError(t, err) + assert.Empty(t, entries) + }) + } + fixture := newAnalyzeFixture(t) + first, second := schemaFile(t, "first"), schemaFile(t, "second") + _, _, err := fixture.execute(t.Context(), first, second, "--render") + require.ErrorContains(t, err, "batch") + assert.Zero(t, fixture.opens) + assert.Zero(t, fixture.client.calls.Load()) +} + +func TestAnalyzeOriginalFilesAndJSONRemainUnchanged(t *testing.T) { + for _, format := range []string{"default", "json"} { + fixture := newAnalyzeFixture(t) + body := previewResponse(t, fixture) + path := filepath.Join(testutil.TempDir(t), "result.md") + _, stderr, err := fixture.execute(t.Context(), "https://example.com/doc.pdf", + "--render=false", "--output-file", path, "--output", format) + require.NoError(t, err) + assert.Empty(t, stderr) + saved, err := os.ReadFile(path) // #nosec G304 -- Fixed filename inside the test's private temporary directory. + require.NoError(t, err) + if format == "json" { + assert.JSONEq(t, string(body), string(saved)) + } else { + var envelope struct{ Result json.RawMessage } + require.NoError(t, json.Unmarshal(body, &envelope)) + expected, err := analysis.RenderLLM(envelope.Result) + require.NoError(t, err) + assert.Equal(t, expected, string(saved)) + } + assert.NotContains(t, string(saved), "Preview only") + } +} + +func TestAnalyzeRedirectedStdout(t *testing.T) { + for _, render := range []bool{false, true} { + fixture := newAnalyzeFixture(t) + body := previewResponse(t, fixture) + file, err := os.CreateTemp(testutil.TempDir(t), "stdout-*.md") + require.NoError(t, err) + root := fixture.root() + var stderr bytes.Buffer + root.SetOut(file) + root.SetErr(&stderr) + flags := []string{"analyze", "https://example.com/doc.pdf"} + if render { + flags = append(flags, "--render") + } + root.SetArgs(flags) + runErr := root.ExecuteContext(t.Context()) + require.NoError(t, file.Close()) + require.NoError(t, runErr) + result, err := os.ReadFile(file.Name()) + require.NoError(t, err) + assert.Empty(t, stderr.String()) + assert.NotContains(t, string(result), "\x1b") + if render { + assert.Contains(t, string(result), "Preview only") + assert.NotContains(t, string(result), "
") + } else { + var envelope struct{ Result json.RawMessage } + require.NoError(t, json.Unmarshal(body, &envelope)) + expected, err := analysis.RenderLLM(envelope.Result) + require.NoError(t, err) + assert.Equal(t, expected+"\n", string(result)) + } + } +} + +func TestAnalyzeRenderedPreviewKeepsReportContract(t *testing.T) { + fixture := newAnalyzeFixture(t) + previewResponse(t, fixture) + path := filepath.Join(testutil.TempDir(t), "report.json") + stdout, stderr, err := fixture.execute(t.Context(), "https://example.com/doc.pdf", "--render", "--report-file", path) + require.NoError(t, err) + assert.Empty(t, stderr) + assert.Contains(t, stdout, "Preview only") + report, err := os.ReadFile(path) // #nosec G304 -- Fixed filename inside the test's private temporary directory. + require.NoError(t, err) + var parsed map[string]any + require.NoError(t, json.Unmarshal(report, &parsed)) + assert.Equal(t, "cu-cli/analyze-report/v1", parsed["schema"]) + assert.Equal(t, "llm-input", parsed["result_view"]) + assert.NotContains(t, string(report), "Preview only") +} diff --git a/cli/azd/extensions/azure.ai.content-understanding/internal/preview/metadata.go b/cli/azd/extensions/azure.ai.content-understanding/internal/preview/metadata.go new file mode 100644 index 00000000000..391aa4567f3 --- /dev/null +++ b/cli/azd/extensions/azure.ai.content-understanding/internal/preview/metadata.go @@ -0,0 +1,110 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package preview + +import ( + "fmt" + "regexp" + "strings" + + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/text" + "go.yaml.in/yaml/v3" +) + +var ( + metadataStart = regexp.MustCompile(`(?:\A|\n\n\*{5}\n\n)---\n`) + metadataEnd = regexp.MustCompile(`(?m)^---(?:\n|$)`) +) + +// formatMetadata recognizes the RenderLLM content boundaries, not arbitrary +// horizontal rules. Masking candidate YAML keeps its opaque values out of the +// Markdown parser while retaining fences surrounding literal examples. +func formatMetadata(source string) (string, error) { + type block struct { + start, end int + yaml string + } + var blocks []block + masked := []byte(source) + for _, match := range metadataStart.FindAllStringIndex(source, -1) { + start := match[1] - len("---\n") + if len(blocks) != 0 && start < blocks[len(blocks)-1].end { + continue + } + body := match[1] + closing := metadataEnd.FindStringIndex(source[body:]) + if closing == nil { + continue + } + end := body + closing[1] + content := source[body : body+closing[0]] + var node yaml.Node + if err := yaml.Unmarshal([]byte(content), &node); err != nil { + continue + } + if len(node.Content) != 1 || node.Content[0].Kind != yaml.MappingNode || + len(node.Content[0].Content) < 2 || node.Content[0].Content[0].Value != "mimeType" { + continue + } + blocks = append(blocks, block{start: start, end: end, yaml: content}) + for index := start; index < end; index++ { + if masked[index] != '\n' { + masked[index] = ' ' + } + } + } + if len(blocks) == 0 { + return source, nil + } + document := goldmark.New().Parser().Parse(text.NewReader(masked)) + var protected []text.Segment + err := ast.Walk(document, func(node ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + switch node.(type) { + case *ast.FencedCodeBlock, *ast.CodeBlock, *ast.HTMLBlock: + if lines := node.Lines(); lines.Len() != 0 { + protected = append(protected, text.NewSegment(lines.At(0).Start, lines.At(lines.Len()-1).Stop)) + } + return ast.WalkSkipChildren, nil + default: + return ast.WalkContinue, nil + } + }) + if err != nil { + return "", fmt.Errorf("locate preview metadata: %w", err) + } + var result strings.Builder + cursor := 0 + for _, block := range blocks { + literal := false + for _, segment := range protected { + if block.start >= segment.Start && block.start < segment.Stop { + literal = true + break + } + } + if literal { + continue + } + result.WriteString(source[cursor:block.start]) + longest, run := 0, 0 + for _, value := range []byte(block.yaml) { + if value == '`' { + run++ + longest = max(longest, run) + } else { + run = 0 + } + } + fence := strings.Repeat("`", max(3, longest+1)) + result.WriteString("**Metadata**\n\n" + fence + "yaml\n" + block.yaml + fence + "\n\n") + cursor = block.end + } + result.WriteString(source[cursor:]) + return result.String(), nil +} diff --git a/cli/azd/extensions/azure.ai.content-understanding/internal/preview/metadata_test.go b/cli/azd/extensions/azure.ai.content-understanding/internal/preview/metadata_test.go new file mode 100644 index 00000000000..276d1c006df --- /dev/null +++ b/cli/azd/extensions/azure.ai.content-understanding/internal/preview/metadata_test.go @@ -0,0 +1,93 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package preview + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "azure.ai.content-understanding/internal/analysis" + + "github.com/charmbracelet/x/ansi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMetadataUsesDedicatedYAMLBlock(t *testing.T) { + source := "---\nmimeType: application/pdf\npages: 1\n---\n# Title\n\nBody.\n" + formatted, err := formatMetadata(source) + require.NoError(t, err) + assert.Equal(t, "**Metadata**\n\n```yaml\nmimeType: application/pdf\npages: 1\n```\n\n# Title\n\nBody.\n", formatted) + for _, color := range []bool{false, true} { + var output bytes.Buffer + require.NoError(t, Render(t.Context(), &output, source, 80, color)) + plain := ansi.Strip(output.String()) + assert.Contains(t, plain, "Metadata") + assert.Contains(t, plain, "mimeType: application/pdf") + assert.NotContains(t, plain, "## mimeType") + assert.Contains(t, plain, "Title") + assert.Contains(t, plain, "Body.") + } +} + +func TestMetadataLeavesMarkdownAndLiteralExamplesAlone(t *testing.T) { + for _, source := range []string{ + "# Title\n\n---\n\nA horizontal rule.\n", + "---\nA setext heading\n---\n", + "---\ntitle: Not LLM metadata\n---\n", + "---\nmimeType: [invalid YAML\n---\n", + "---\nmimeType: missing closing delimiter\n", + "# Examples\n\n```markdown\nExample:\n\n*****\n\n---\nmimeType: text/plain\n---\n```\n", + "# Examples\n\n~~~markdown\nExample:\n\n*****\n\n---\nmimeType: text/plain\n---\n~~~\n", + "\n", + } { + formatted, err := formatMetadata(source) + require.NoError(t, err) + assert.Equal(t, source, formatted) + } +} + +func TestMetadataAcrossProjectedContentsAndOpaqueValues(t *testing.T) { + const literalExample = "```markdown\nExample:\n\n*****\n\n---\nmimeType: text/example\n---\n```\n" + const opaque = "A literal fence:\n````\n
Not a document table
\n````" + const bodyTable = "\n\n" + + "\n
NameCount
Document body value42
\n" + payload, err := json.Marshal(map[string]any{ + "contents": []any{ + map[string]any{ + "kind": "document", "mimeType": "application/pdf", + "metadata": map[string]any{"note": opaque}, + "markdown": "# First document\n\n" + literalExample + "\n" + bodyTable, + }, + map[string]any{ + "kind": "audioVisual", "mimeType": "audio/wav", "markdown": "# Second content\n\nTranscript.", + }, + }, + }) + require.NoError(t, err) + original, err := analysis.RenderLLM(payload) + require.NoError(t, err) + formatted, err := formatMetadata(original) + require.NoError(t, err) + assert.Equal(t, 2, strings.Count(formatted, "**Metadata**")) + assert.Contains(t, formatted, "`````yaml", "opaque backticks cannot close the metadata code block") + assert.Contains(t, formatted, literalExample, "document code examples must stay literal") + for _, color := range []bool{false, true} { + var result bytes.Buffer + require.NoError(t, Render(t.Context(), &result, original, 120, color)) + plain := ansi.Strip(result.String()) + assert.NotContains(t, plain, "## mimeType") + assert.Contains(t, plain, "mimeType: application/pdf") + assert.Contains(t, plain, "mimeType: audio/wav") + assert.Contains(t, plain, "Not a document table") + assert.Contains(t, plain, "Document body value") + assert.NotContains(t, plain, "[HTML table not rendered:") + assert.Contains(t, plain, "Transcript.") + } + assert.True(t, strings.HasPrefix(original, "---\nmimeType: application/pdf\n")) + assert.Contains(t, original, bodyTable) + assert.NotContains(t, original, "**Metadata**") +} diff --git a/cli/azd/extensions/azure.ai.content-understanding/internal/preview/multilingual_test.go b/cli/azd/extensions/azure.ai.content-understanding/internal/preview/multilingual_test.go new file mode 100644 index 00000000000..11d703f65b8 --- /dev/null +++ b/cli/azd/extensions/azure.ai.content-understanding/internal/preview/multilingual_test.go @@ -0,0 +1,101 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package preview + +import ( + "bytes" + "fmt" + "strings" + "testing" + + "github.com/charmbracelet/x/ansi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMultilingualPreview(t *testing.T) { + const identifier = "RECORD0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" + for _, sample := range []struct { + language, heading, city, greeting string + }{ + {"zh-TW", "城市", "台北", "你好,世界"}, + {"ja", "都市", "東京", "こんにちは"}, + {"ko", "도시", "서울", "안녕하세요"}, + {"fr", "Ville", "Québec", "Bonjour"}, + {"de", "Stadt", "München", "Grüße"}, + {"hi", "शहर", "दिल्ली", "नमस्ते"}, + {"ar", "المدينة", "القاهرة", "مرحبا"}, + {"graphemes", "地點👩🏽‍💻", "Cafe\u0301 🇹🇼", "👩🏽‍💻"}, + } { + t.Run(sample.language, func(t *testing.T) { + source := "# " + sample.language + "\n\n**" + sample.greeting + "**\n\n" + + "\n\n" + + "\n
" + sample.heading + "Description
" + sample.city + "" + identifier + "
\n" + for _, width := range []int{20, 40, 80, 120} { + for _, color := range []bool{false, true} { + t.Run(fmt.Sprintf("width=%d/color=%t", width, color), func(t *testing.T) { + var output bytes.Buffer + require.NoError(t, Render(t.Context(), &output, source, width, color)) + plain := ansi.Strip(output.String()) + assert.Contains(t, plain, sample.heading) + assert.Contains(t, plain, sample.city) + assert.Contains(t, plain, sample.greeting) + assert.Contains(t, plain, identifier) + assert.NotContains(t, plain, "\ufffd") + assert.NotContains(t, plain, "[HTML table not rendered:") + if !color { + assert.NotContains(t, output.String(), "\x1b") + } + if width <= 40 { + assert.Contains(t, plain, "Row 1") + positions := map[string]int{} + for line := range strings.SplitSeq(plain, "\n") { + for _, value := range []string{sample.city, identifier} { + if prefix, ok := strings.CutSuffix(line, value); ok { + positions[value] = ansi.StringWidth(prefix) + } + } + } + require.Len(t, positions, 2) + expected := ansi.StringWidth(sample.heading) + 1 + + max(len(sample.heading), len("Description")) - len(sample.heading) + 2 + assert.Equal(t, expected, positions[sample.city]) + assert.Equal(t, max(len(sample.heading), len("Description"))+3, positions[identifier]) + t.Logf("pinned cards: localized value column=%d, ASCII value column=%d", + positions[sample.city], positions[identifier]) + } else { + assert.NotContains(t, plain, "Row 1") + } + }) + } + } + t.Run("table-column-measurements", func(t *testing.T) { + source := "\n\n" + + "\n" + + "\n
" + sample.heading + "Status
" + sample.city + "READY
" + sample.greeting + "DONE
\n" + plain := renderPreview(t, source, 120) + columns := map[string]int{} + for line := range strings.SplitSeq(plain, "\n") { + for _, suffix := range []string{"Status", "READY", "DONE"} { + if prefix, ok := strings.CutSuffix(line, suffix); ok { + columns[suffix] = ansi.StringWidth(prefix) + } + } + } + require.Len(t, columns, 3) + if sample.language == "hi" { + assert.Equal(t, map[string]int{"Status": 8, "READY": 5, "DONE": 7}, columns, + "characterization: pinned azd rune widths differ from ANSI grapheme widths for these Indic strings") + t.Logf("known Hindi table-alignment limitation, zero-based ANSI columns: %v", columns) + return + } + expected := max(ansi.StringWidth(sample.heading), ansi.StringWidth(sample.city), + ansi.StringWidth(sample.greeting)) + 3 + for name, column := range columns { + assert.Equal(t, expected, column, sample.language+"/"+name) + } + }) + }) + } +} diff --git a/cli/azd/extensions/azure.ai.content-understanding/internal/preview/render.go b/cli/azd/extensions/azure.ai.content-understanding/internal/preview/render.go new file mode 100644 index 00000000000..60d4ef147ce --- /dev/null +++ b/cli/azd/extensions/azure.ai.content-understanding/internal/preview/render.go @@ -0,0 +1,265 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package preview + +import ( + "bytes" + "context" + "fmt" + "io" + "slices" + "strconv" + "strings" + + "github.com/azure/azure-dev/cli/azd/pkg/output" + "github.com/charmbracelet/glamour" + "github.com/charmbracelet/x/ansi" + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/extension" + "github.com/yuin/goldmark/text" + "golang.org/x/net/html" + "golang.org/x/net/html/atom" +) + +type table struct { + caption string + headers []string + rows [][]string +} + +// Render writes a human-readable preview without changing the source result. +// Width controls layout selection, not a hard bound on retained long values. +func Render(ctx context.Context, writer io.Writer, source string, width int, color bool) error { + if err := ctx.Err(); err != nil { + return err + } + if width < 1 { + return fmt.Errorf("preview width must be positive") + } + style := "ascii" + if color { + style = "dark" + } + renderer, err := glamour.NewTermRenderer(glamour.WithStandardStyle(style), glamour.WithWordWrap(width)) + if err != nil { + return fmt.Errorf("initialize Markdown preview: %w", err) + } + source = safeText(source) + source, err = formatMetadata(source) + if err != nil { + return err + } + data := []byte(source) + document := goldmark.New(goldmark.WithExtensions(extension.GFM)).Parser().Parse(text.NewReader(data)) + var result strings.Builder + result.WriteString("Preview only; original Markdown/HTML is unchanged. " + + "URLs omit credentials, queries and fragments.\n\n") + cursor := 0 + err = ast.Walk(document, func(node ast.Node, entering bool) (ast.WalkStatus, error) { + if err := ctx.Err(); err != nil { + return ast.WalkStop, err + } + block, ok := node.(*ast.HTMLBlock) + if !entering || !ok || block.Lines().Len() == 0 { + return ast.WalkContinue, nil + } + start := block.Lines().At(0).Start + end := block.Lines().At(block.Lines().Len() - 1).Stop + if block.HasClosure() { + end = max(end, block.ClosureLine.Stop) + } + raw := source[start:end] + if !strings.Contains(strings.ToLower(raw), "\nTotals\nNameValue\n" + + "A & B**literal**\n\n\nAfter table.\n" + result := renderPreview(t, source, 120) + assert.Contains(t, result, "Bold") + assert.Contains(t, result, "italic") + assert.Contains(t, result, "both") + assert.NotContains(t, result, "***both***") + assert.Contains(t, result, "A & B") + assert.Contains(t, result, "**literal**") + assert.NotContains(t, result, "") + assert.NotContains(t, result, "Row 1") + assert.Less(t, strings.Index(result, "Totals"), strings.Index(result, "A & B")) + assert.Contains(t, result, "After table.") + + var colored bytes.Buffer + require.NoError(t, Render(t.Context(), &colored, "**Bold**\n", 80, true)) + assert.Contains(t, colored.String(), "\x1b") + assert.Contains(t, ansi.Strip(colored.String()), "Bold") +} + +func TestWideTableSelectsCardsAndKeepsValues(t *testing.T) { + const identifier = "RECORD0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" + const link = "https://example.com/reports/2026/quarterly/network/event-log/details" + source := "\n\n\n
IdentifierURL
" + identifier + + "" + link + "
\n" + for _, width := range []int{20, 40, 80, 120} { + t.Run(strconv.Itoa(width), func(t *testing.T) { + result := renderPreview(t, source, width) + assert.Contains(t, result, identifier) + assert.Contains(t, result, link) + if width < 120 { + assert.Contains(t, result, "Row 1") + assert.Contains(t, result, "Identifier:") + assert.Contains(t, result, "URL:") + } else { + assert.NotContains(t, result, "Row 1") + } + }) + } +} + +func TestUnsupportedTablesPreserveSource(t *testing.T) { + for _, source := range []string{ + "
AB
Merged
", + "
A
Rich
", + "
A
", + "
AA
12
", + "
A
Nested
", + "
Not a heading
", + } { + t.Run(source, func(t *testing.T) { + result := renderPreview(t, source+"\n", 80) + assert.Contains(t, result, "[HTML table not rendered:") + assert.Contains(t, result, source) + }) + } +} + +func TestCodeFenceAndNativeMarkdownTablesStayWithGlamour(t *testing.T) { + source := "```html\n
A
B
\n```\n\n" + + "| Name | Value |\n| --- | --- |\n| Native | 42 |\n" + result := renderPreview(t, source, 120) + assert.Contains(t, result, "") + assert.Contains(t, result, "Native") + assert.Contains(t, result, "42") + assert.NotContains(t, result, "[HTML table not rendered:") + assert.NotContains(t, result, "Row 1") +} + +func TestCardsRetainOriginalRowHeadings(t *testing.T) { + source := "
" + + "
Row_RowDetails
OriginalAnotherRECORD0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ
\n" + result := renderPreview(t, source, 20) + for _, value := range []string{"Row 1", "Row:", "_Row:", "Original", "Another"} { + assert.Contains(t, result, value) + } +} + +func TestPinnedCardsUnicodeAlignmentCharacterization(t *testing.T) { + source := "" + + "
城市Description
台北RECORD0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ
\n" + result := renderPreview(t, source, 20) + columns := map[string]int{} + for line := range strings.SplitSeq(result, "\n") { + for _, value := range []string{"台北", "RECORD0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"} { + if prefix, _, ok := strings.Cut(line, value); ok { + columns[value] = ansi.StringWidth(prefix) + } + } + } + require.Len(t, columns, 2) + assert.NotEqual(t, columns["台北"], columns["RECORD0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"], + "azd v1.37.0 cards still pad labels by byte length; this test records, not fixes, the upstream limitation") +} + +type failingWriter struct{ err error } + +func (w failingWriter) Write([]byte) (int, error) { return 0, w.err } + +func TestPreviewErrorsAndCancellation(t *testing.T) { + errWrite := errors.New("writer unavailable") + require.ErrorIs(t, Render(t.Context(), failingWriter{errWrite}, "Result", 80, false), errWrite) + ctx, cancel := context.WithCancel(t.Context()) + cancel() + var result bytes.Buffer + require.ErrorIs(t, Render(ctx, &result, "Result", 80, false), context.Canceled) + assert.Empty(t, result.String()) + require.ErrorContains(t, Render(t.Context(), &result, "Result", 0, false), "width") + assert.Empty(t, result.String()) +} + +func TestPreviewSafetyForMarkdownAndHTML(t *testing.T) { + // #nosec G101 -- Synthetic credentials exercise URL non-disclosure. + const unsafeURL = "https://preview-user:preview-password@example.com/doc?sig=private-signature#private-fragment" + for _, source := range []string{ + "[Document](" + unsafeURL + ")\n", + unsafeURL + "\n", + "https://preview-user:preview-password@example.com/doc?sig=private-signature#private-fragment\n", + "
URL
" + unsafeURL + "
\n", + "
URL
Document
\n", + "
URL
Document
\n", + } { + result := renderPreview(t, source, 120) + for _, secret := range []string{"preview-user", "preview-password", "private-signature", "private-fragment"} { + assert.NotContains(t, result, secret) + } + assert.Contains(t, result, "example.com/doc") + } + result := renderPreview(t, "\x1b]52;c;private-clipboard\x07**Safe**\r\x08\n"+ + "
Name
Cell
\n", 80) + assert.NotContains(t, result, "private-clipboard") + assert.NotContains(t, result, "\r") + assert.NotContains(t, result, "\b") + assert.Contains(t, result, "Safe") + assert.Contains(t, result, "Cell") +} + +func TestSafetyPreservesUnrelatedEscapesAndMarkdownDelimiters(t *testing.T) { + // #nosec G101 -- Synthetic credentials exercise URL non-disclosure. + source := "<table> [Link](https://user:password@example.com/path?sig=secret#fragment) &" + assert.Equal(t, "<table> [Link](https://example.com/path) &", safeText(source)) + assert.Equal(t, "<table> &", safeText("<table> &")) + assert.Equal(t, "https://example.com/path", safeText("https://example.com/path?")) + assert.Equal(t, "[redacted URL]", safeText("https://user:password@example.com/%invalid?sig=secret")) +} diff --git a/cli/azd/extensions/azure.ai.content-understanding/internal/preview/safety.go b/cli/azd/extensions/azure.ai.content-understanding/internal/preview/safety.go new file mode 100644 index 00000000000..dfc877ed392 --- /dev/null +++ b/cli/azd/extensions/azure.ai.content-understanding/internal/preview/safety.go @@ -0,0 +1,85 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package preview + +import ( + "net/url" + "regexp" + "strings" + "unicode" + + "github.com/charmbracelet/x/ansi" + "golang.org/x/net/html" +) + +var ( + previewURL = regexp.MustCompile("(?i)\\b[a-z][a-z0-9+.-]*://[^\\s\\x00-\\x1f<>\"'`]+") + entity = regexp.MustCompile(`&(?:#[0-9]+|#[xX][0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]+);`) +) + +func safeText(source string) string { + clean := func(value string) string { + return strings.Map(func(r rune) rune { + if unicode.IsControl(r) && r != '\n' && r != '\t' || unicode.Is(unicode.Bidi_Control, r) { + return -1 + } + return r + }, ansi.Strip(value)) + } + source = clean(strings.ReplaceAll(source, "\r\n", "\n")) + // Encoded terminal controls must not become active when Markdown/HTML decodes them. + source = entity.ReplaceAllStringFunc(source, func(value string) string { + decoded := html.UnescapeString(value) + if clean(decoded) != decoded { + return clean(decoded) + } + return value + }) + + // Match decoded URLs, but map replacements back to source offsets so escaped + // Markdown/HTML elsewhere is not accidentally promoted to active markup. + var decoded strings.Builder + offsets := make([]int, 0, len(source)+1) + cursor := 0 + for _, match := range entity.FindAllStringIndex(source, -1) { + for index := cursor; index < match[0]; index++ { + decoded.WriteByte(source[index]) + offsets = append(offsets, index) + } + value := html.UnescapeString(source[match[0]:match[1]]) + decoded.WriteString(value) + for range len(value) { + offsets = append(offsets, match[0]) + } + cursor = match[1] + } + for index := cursor; index < len(source); index++ { + decoded.WriteByte(source[index]) + offsets = append(offsets, index) + } + offsets = append(offsets, len(source)) + text := decoded.String() + var result strings.Builder + cursor = 0 + for _, match := range previewURL.FindAllStringIndex(text, -1) { + raw := strings.TrimRight(text[match[0]:match[1]], ").,;:!]}") + end := match[0] + len(raw) + parsed, err := url.Parse(raw) + replacement := "[redacted URL]" + if err == nil && parsed.Host != "" { + parsed.User = nil + parsed.RawQuery, parsed.Fragment, parsed.RawFragment = "", "", "" + parsed.ForceQuery = false + replacement = parsed.String() + } + if replacement == raw { + continue + } + result.WriteString(source[cursor:offsets[match[0]]]) + result.WriteString(replacement) + cursor = offsets[end] + } + result.WriteString(source[cursor:]) + return result.String() +} From 737e2018e79515409f0f3c4b70d79b410022ee9c Mon Sep 17 00:00:00 2001 From: Chien Yuan Chang <186005646+chienyuanchang@users.noreply.github.com> Date: Wed, 4 Nov 2026 10:13:53 -0800 Subject: [PATCH 07/22] fix(ai-cu): clarify version output and separate update notices Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../azure.ai.content-understanding/docs/usage.md | 7 +++++++ .../internal/cmd/root_test.go | 8 ++++++++ .../internal/cmd/version.go | 4 +++- 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/cli/azd/extensions/azure.ai.content-understanding/docs/usage.md b/cli/azd/extensions/azure.ai.content-understanding/docs/usage.md index 3ff6dc96fb6..21c57dcf89e 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/docs/usage.md +++ b/cli/azd/extensions/azure.ai.content-understanding/docs/usage.md @@ -5,6 +5,13 @@ development. Start with the [README](https://github.com/Azure/azure-dev/blob/mai For resource creation, permissions, model deployment, and service defaults, use the separate [Microsoft Foundry provisioning guide](https://github.com/Azure/azure-dev/blob/main/cli/azd/extensions/azure.ai.content-understanding/docs/provisioning.md). +## Check the installed extension version + +Run `azd ai cu version` to display the extension title, version, and extension +name. The text output includes surrounding blank lines to separate it from azd +update notices. For scripts, use `azd ai cu version --output json`, which returns +the extension ID as `name` and the installed version as `version`. + ## Connect to Microsoft Foundry and check setup You need a Microsoft Foundry resource endpoint and an identity with access to diff --git a/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/root_test.go b/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/root_test.go index cfd4cc7e3ec..7552079cc9f 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/root_test.go +++ b/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/root_test.go @@ -188,6 +188,14 @@ func TestMetadataUsesRuntimeTree(t *testing.T) { assert.Error(t, extensions.ValidateAgainstSchema(schema, document)) } +func TestVersionTextOutput(t *testing.T) { + stdout, stderr, err := executeRoot(t, "version") + require.NoError(t, err) + assert.Empty(t, stderr) + assert.Equal(t, "\nAzure Content Understanding Extension for azd\nVersion: "+Version+ + "\nExtension name: "+extensionID+"\n\n", stdout) +} + func TestVersionJSONShape(t *testing.T) { stdout, stderr, err := executeRoot(t, "version", "--output", "json") require.NoError(t, err) diff --git a/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/version.go b/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/version.go index 049612938ed..17c8728a468 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/version.go +++ b/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/version.go @@ -37,7 +37,9 @@ func newVersionCommand(outputFormat *string) *cobra.Command { } return nil } - _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s %s\n", extensionID, Version) + _, err := fmt.Fprintf(cmd.OutOrStdout(), + "\nAzure Content Understanding Extension for azd\nVersion: %s\nExtension name: %s\n\n", + Version, extensionID) return err }, }, azdext.FlagOptions{Name: "output", AllowedValues: []string{"json"}}) From d58b9d3406d3700b6c2351e4d7c3e2a656996739 Mon Sep 17 00:00:00 2001 From: Chien Yuan Chang <186005646+chienyuanchang@users.noreply.github.com> Date: Wed, 4 Nov 2026 10:22:31 -0800 Subject: [PATCH 08/22] refine test data --- .../internal/preview/multilingual_test.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/cli/azd/extensions/azure.ai.content-understanding/internal/preview/multilingual_test.go b/cli/azd/extensions/azure.ai.content-understanding/internal/preview/multilingual_test.go index 11d703f65b8..fc1bfd455e4 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/internal/preview/multilingual_test.go +++ b/cli/azd/extensions/azure.ai.content-understanding/internal/preview/multilingual_test.go @@ -22,11 +22,9 @@ func TestMultilingualPreview(t *testing.T) { {"zh-TW", "城市", "台北", "你好,世界"}, {"ja", "都市", "東京", "こんにちは"}, {"ko", "도시", "서울", "안녕하세요"}, - {"fr", "Ville", "Québec", "Bonjour"}, {"de", "Stadt", "München", "Grüße"}, {"hi", "शहर", "दिल्ली", "नमस्ते"}, {"ar", "المدينة", "القاهرة", "مرحبا"}, - {"graphemes", "地點👩🏽‍💻", "Cafe\u0301 🇹🇼", "👩🏽‍💻"}, } { t.Run(sample.language, func(t *testing.T) { source := "# " + sample.language + "\n\n**" + sample.greeting + "**\n\n" + From 73862d5503058971b50584a67553e0c4be8ed27d Mon Sep 17 00:00:00 2001 From: Chien Yuan Chang <186005646+chienyuanchang@users.noreply.github.com> Date: Wed, 4 Nov 2026 10:33:56 -0800 Subject: [PATCH 09/22] fix(ai-cu): highlight provisioning guidance and prompt questions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../docs/provisioning.md | 4 ++ .../internal/cmd/diagnostics.go | 9 +++ .../internal/cmd/infra.go | 10 ++-- .../internal/cmd/infra_host.go | 3 +- .../internal/cmd/infra_prompts.go | 27 +++++---- .../internal/cmd/infra_prompts_test.go | 60 +++++++++++++++++-- .../internal/cmd/root_test.go | 2 +- 7 files changed, 91 insertions(+), 24 deletions(-) diff --git a/cli/azd/extensions/azure.ai.content-understanding/docs/provisioning.md b/cli/azd/extensions/azure.ai.content-understanding/docs/provisioning.md index e1f369f1d3f..d0cc7e02834 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/docs/provisioning.md +++ b/cli/azd/extensions/azure.ai.content-understanding/docs/provisioning.md @@ -83,6 +83,10 @@ for role definitions. ## Interactive user workflow +The generation wizard highlights commands, paths, and identifiers in its guidance, +and uses blue question text to distinguish prompts from explanations. Set +`NO_COLOR=1` for plain text. JSON and silent output modes remain uncolored. + Sign in first, then use two steps: **generate files**, followed by **deploy resources**. Only the second step creates or changes Azure resources and can incur charges. Have your subscription and intended region ready, and check diff --git a/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/diagnostics.go b/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/diagnostics.go index 0260e6c3933..c243260a55d 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/diagnostics.go +++ b/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/diagnostics.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "os" + "strings" "sync" "azure.ai.content-understanding/internal/config" @@ -38,6 +39,14 @@ func actionText(format, text string) string { return output.WithHighLightFormat("%s", text) } +func guidanceText(format, text string) string { + parts := strings.Split(text, "`") + for i := 1; i < len(parts)-1; i += 2 { + parts[i] = actionText(format, parts[i]) + } + return strings.Join(parts, "`") +} + func commentText(format, text string) string { if format == "json" || format == "none" || os.Getenv("NO_COLOR") != "" { return text diff --git a/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/infra.go b/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/infra.go index d2c530f8f25..87d9b5d44da 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/infra.go +++ b/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/infra.go @@ -144,14 +144,14 @@ func runInfraGenerate( showGuidance := !extCtx.NoPrompt && extCtx.OutputFormat != "json" && extCtx.OutputFormat != "none" if showGuidance { if _, err := fmt.Fprintf(cmd.OutOrStdout(), - "azd ai cu infra generate -> %s\n\n"+ + guidanceText(extCtx.OutputFormat, "`azd ai cu infra generate` -> %s\n\n"+ "`azd ai cu infra generate` generates an azd template. After the Microsoft Foundry resource "+ "is provisioned, its post-provision script can optionally deploy supported chat completion "+ - "and embeddings models for prebuilt analyzers such as prebuilt-invoice and for custom analyzers.\n"+ - "It doesn't create any Azure resources itself - you run azd up afterwards "+ + "and embeddings models for prebuilt analyzers such as `prebuilt-invoice` and for custom analyzers.\n"+ + "It doesn't create any Azure resources itself - you run `azd up` afterwards "+ "to do the actual provisioning.\n"+ - "Choose the Azure subscription used to provision resources, then configure the deployment.\n\n", - output); err != nil { + "Choose the Azure subscription used to provision resources, then configure the deployment.\n\n"), + actionText(extCtx.OutputFormat, output)); err != nil { return fmt.Errorf("write infrastructure introduction: %w", err) } } diff --git a/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/infra_host.go b/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/infra_host.go index 276a283c8da..51ae22b23b1 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/infra_host.go +++ b/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/infra_host.go @@ -71,7 +71,8 @@ func (h *azdInfraHost) Subscription( } if !noPrompt { response, err := h.client.Prompt().PromptSubscription(ctx, &azdext.PromptSubscriptionRequest{ - Message: "Select the Azure subscription for the generated Content Understanding project", + Message: actionText(h.outputFormat, + "Select the Azure subscription for the generated Content Understanding project"), }) if err != nil { return nil, err diff --git a/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/infra_prompts.go b/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/infra_prompts.go index 89c326caec7..8002e2d8927 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/infra_prompts.go +++ b/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/infra_prompts.go @@ -32,7 +32,7 @@ func (h *azdInfraHost) PromptGeneration( if err := ctx.Err(); err != nil { return err } - if _, err := fmt.Fprintf(h.output, "\n%s\n", text); err != nil { + if _, err := fmt.Fprintf(h.output, "\n%s\n", guidanceText(h.outputFormat, text)); err != nil { return fmt.Errorf("write infrastructure guidance: %w", err) } return nil @@ -50,7 +50,7 @@ func (h *azdInfraHost) PromptGeneration( return err } response, err := h.client.Prompt().Prompt(ctx, &azdext.PromptRequest{Options: &azdext.PromptOptions{ - Message: message, HelpMessage: help, DefaultValue: *value, + Message: actionText(h.outputFormat, message), HelpMessage: help, DefaultValue: *value, Required: value == &choices.Environment, }}) if err != nil { @@ -80,9 +80,9 @@ func (h *azdInfraHost) PromptGeneration( if err := promptText(prompts.environment, "Enter your environment name (1-64 letters, numbers, -, _, ., or parentheses)", "Input your azd environment name. An azd environment name:\n"+ - " - lets azd store this deployment's config + outputs under .azure// in the generated directory.\n"+ - " - seeds your Azure resource names (e.g. rg-, proj-).\n"+ - " - keeps separate stacks apart, such as dev, test, or prod.", + " - lets azd store this deployment's config + outputs under `.azure//` in the generated directory.\n"+ + " - seeds your Azure resource names (e.g. `rg-`, `proj-`).\n"+ + " - keeps separate stacks apart, such as `dev`, `test`, or `prod`.", &choices.Environment, "invalid_environment"); err != nil { return choices, err } @@ -93,7 +93,7 @@ func (h *azdInfraHost) PromptGeneration( return choices, err } options := &azdext.SelectOptions{ - Message: "Azure region (where the Foundry resource is created)", + Message: actionText(h.outputFormat, "Azure region (where the Foundry resource is created)"), HelpMessage: "Region for the new Microsoft Foundry resource.", EnableFiltering: new(true), } index := int32(0) @@ -118,9 +118,9 @@ func (h *azdInfraHost) PromptGeneration( "Optional Microsoft Foundry resource name prefix (lowercase letters, numbers, hyphen; blank to skip)", "Microsoft Foundry resource naming\n"+ "The resource name becomes part of the public endpoint host "+ - "(for example, https://.services.ai.azure.com), so it must be globally unique.\n"+ - "If you provide a prefix, azd constructs the resource name as -. "+ - "Without a prefix, it uses aif-.", + "(for example, `https://.services.ai.azure.com`), so it must be globally unique.\n"+ + "If you provide a prefix, azd constructs the resource name as `-`. "+ + "Without a prefix, it uses `aif-`.", &choices.FoundryPrefix, "invalid_foundry_prefix"); err != nil { return choices, err } @@ -134,7 +134,7 @@ func (h *azdInfraHost) PromptGeneration( return choices, err } response, err := h.client.Prompt().Confirm(ctx, &azdext.ConfirmRequest{Options: &azdext.ConfirmOptions{ - Message: "Assign RBAC roles to your user on the Microsoft Foundry resource?", + Message: actionText(h.outputFormat, "Assign RBAC roles to your user on the Microsoft Foundry resource?"), HelpMessage: "Requires permission to create role assignments, such as Owner, User Access Administrator, " + "or Role Based Access Control Administrator. If skipped, ask an administrator to grant access for Entra ID.", DefaultValue: new(false), @@ -156,7 +156,7 @@ func (h *azdInfraHost) PromptGeneration( func (h *azdInfraHost) promptModels(ctx context.Context, models []infra.Model) ([]infra.Model, error) { const message = "Select live CU-supported models to deploy" options := &azdext.MultiSelectOptions{ - Message: message, + Message: actionText(h.outputFormat, message), HelpMessage: "Accept or change the recommended selections. Clear all selections to deploy no models. " + "Select only one version of each model family.", AllowEmptySelection: new(true), EnableFiltering: new(true), @@ -167,7 +167,8 @@ func (h *azdInfraHost) promptModels(ctx context.Context, models []infra.Model) ( if !ok || (local.Code != "ambiguous_model_version" && local.Code != "no_deployable_models") { return nil, err } - options.Message = warningText(h.outputFormat, "No models preselected: "+local.Message) + "\n" + message + options.Message = warningText(h.outputFormat, "No models preselected: "+local.Message) + "\n" + + actionText(h.outputFormat, message) } for _, model := range models { label := fmt.Sprintf("%s | %s | %s", model.Selector(), model.Kind, model.SKU) @@ -204,7 +205,7 @@ func (h *azdInfraHost) promptModels(ctx context.Context, models []infra.Model) ( if err == nil { // A host without a terminal can accept preselected choices at EOF; require an explicit opt-in to deploy. confirmation, err := h.client.Prompt().Confirm(ctx, &azdext.ConfirmRequest{Options: &azdext.ConfirmOptions{ - Message: "Deploy these models: " + strings.Join(selectors, ", ") + "?", + Message: actionText(h.outputFormat, "Deploy these models: "+strings.Join(selectors, ", ")+"?"), HelpMessage: "Model deployment can incur Azure charges. No models will be deployed unless you confirm.", DefaultValue: new(false), }}) diff --git a/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/infra_prompts_test.go b/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/infra_prompts_test.go index 2388b12a7cd..9afbe7db2d5 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/infra_prompts_test.go +++ b/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/infra_prompts_test.go @@ -331,6 +331,11 @@ func TestInfraGenerateSectionsAndActionColors(t *testing.T) { if mode == "NO_COLOR" { assert.NotContains(t, text, "\x1b") } else { + if mode == "default" { + assert.Contains(t, text, "`\x1b[94mazd ai cu infra generate\x1b[0m`") + assert.Contains(t, text, "`\x1b[94mprebuilt-invoice\x1b[0m`") + assert.Contains(t, text, "`\x1b[94mazd up\x1b[0m`") + } assert.Contains(t, text, "\x1b[94mazd auth login\x1b[0m\x1b[90m # (one-time)\x1b[0m") assert.Contains(t, text, "\x1b[94mazd up\x1b[0m\x1b[90m # "+ @@ -343,6 +348,53 @@ func TestInfraGenerateSectionsAndActionColors(t *testing.T) { } } +func TestInfraGuidanceAndQuestionColors(t *testing.T) { + setHelpColor(t, true) + for _, mode := range []string{"default", "NO_COLOR", "json", "none"} { + t.Run(mode, func(t *testing.T) { + t.Setenv("NO_COLOR", "") + if mode == "NO_COLOR" { + t.Setenv("NO_COLOR", "1") + } + format := mode + if mode == "NO_COLOR" { + format = "default" + } + rpc, host, choices := generationPromptFixture(t) + var text bytes.Buffer + host.output, host.outputFormat = &text, format + checkQuestion := func(message string) { + if mode == "default" { + assert.True(t, strings.HasPrefix(message, "\x1b[94m"), message) + assert.True(t, strings.HasSuffix(message, "\x1b[0m"), message) + } else { + assert.NotContains(t, message, "\x1b") + } + } + rpc.promptText = func(req *azdext.PromptRequest) (*azdext.PromptResponse, error) { + checkQuestion(req.GetOptions().GetMessage()) + return &azdext.PromptResponse{Value: req.GetOptions().GetDefaultValue()}, nil + } + rpc.selectOption = func(req *azdext.SelectRequest) (*azdext.SelectResponse, error) { + checkQuestion(req.GetOptions().GetMessage()) + return &azdext.SelectResponse{Value: req.GetOptions().SelectedIndex}, nil + } + rpc.confirm = func(req *azdext.ConfirmRequest) (*azdext.ConfirmResponse, error) { + checkQuestion(req.GetOptions().GetMessage()) + return &azdext.ConfirmResponse{Value: req.GetOptions().DefaultValue}, nil + } + _, err := host.PromptGeneration(t.Context(), choices, generationPrompts{true, true, true, true}) + require.NoError(t, err) + if mode == "default" { + assert.Contains(t, text.String(), "`\x1b[94m.azure//\x1b[0m`") + assert.Contains(t, text.String(), "`\x1b[94m-\x1b[0m`") + } else { + assert.NotContains(t, text.String(), "\x1b") + } + }) + } +} + func TestInfraGenerationHostPrompts(t *testing.T) { rpc, host, choices := generationPromptFixture(t) var output bytes.Buffer @@ -447,9 +499,9 @@ func TestInfraGenerationVisibleGuidance(t *testing.T) { rpc.promptText = func(request *azdext.PromptRequest) (*azdext.PromptResponse, error) { if request.GetOptions().GetRequired() { beforePrompt("Input your azd environment name. An azd environment name:", - "lets azd store this deployment's config + outputs under .azure//") - assert.Contains(t, output.String(), "seeds your Azure resource names (e.g. rg-, proj-)") - assert.Contains(t, output.String(), "keeps separate stacks apart, such as dev, test, or prod") + "lets azd store this deployment's config + outputs under `.azure//`") + assert.Contains(t, output.String(), "seeds your Azure resource names (e.g. `rg-`, `proj-`)") + assert.Contains(t, output.String(), "keeps separate stacks apart, such as `dev`, `test`, or `prod`") assert.Equal(t, "Enter your environment name (1-64 letters, numbers, -, _, ., or parentheses)", request.GetOptions().GetMessage()) } else { @@ -458,7 +510,7 @@ func TestInfraGenerationVisibleGuidance(t *testing.T) { "(lowercase letters, numbers, hyphen; blank to skip)", request.GetOptions().GetMessage()) assert.Contains(t, output.String(), "must be globally unique") assert.Contains(t, output.String(), - "If you provide a prefix, azd constructs the resource name as -.") + "If you provide a prefix, azd constructs the resource name as `-`.") } return &azdext.PromptResponse{Value: request.GetOptions().GetDefaultValue()}, nil } diff --git a/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/root_test.go b/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/root_test.go index 7552079cc9f..58596677756 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/root_test.go +++ b/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/root_test.go @@ -50,7 +50,7 @@ func TestRootWithoutHostOrProject(t *testing.T) { want string }{ {"help", []string{"--help"}, "Azure Content Understanding"}, - {"version", []string{"version"}, extensionID + " " + Version + "\n"}, + {"version", []string{"version"}, "Version: " + Version + "\nExtension name: " + extensionID + "\n\n"}, {"version flag", []string{"--version"}, Version}, {"json version", []string{"version", "--output", "json"}, `"name":"` + extensionID + `"`}, {"metadata", []string{"metadata"}, `"id": "` + extensionID + `"`}, From 6a20f269d60acad22df67986ba5ba3dba57a9a58 Mon Sep 17 00:00:00 2001 From: Chien Yuan Chang <186005646+chienyuanchang@users.noreply.github.com> Date: Wed, 4 Nov 2026 10:42:52 -0800 Subject: [PATCH 10/22] fix(ai-cu): rename terminal preview flag to pretty Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../azure.ai.content-understanding/README.md | 2 +- .../docs/usage.md | 10 +++---- .../internal/cmd/analyze.go | 24 +++++++-------- .../internal/cmd/analyze_render_test.go | 30 ++++++++++++++----- 4 files changed, 40 insertions(+), 26 deletions(-) diff --git a/cli/azd/extensions/azure.ai.content-understanding/README.md b/cli/azd/extensions/azure.ai.content-understanding/README.md index 724f518e5f3..9a0b1e0ba7a 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/README.md +++ b/cli/azd/extensions/azure.ai.content-understanding/README.md @@ -190,7 +190,7 @@ azd ai cu analyze invoice.pdf --output-file invoice.result.md Markdown and HTML tables remain unrendered by default, including when stdout is redirected. Opt in to a human-readable preview with `azd ai cu analyze invoice.pdf ---render`. Standard Markdown uses Glamour; supported simple HTML tables use the +--pretty`. Standard Markdown uses Glamour; supported simple HTML tables use the existing azd table/cards layout. The flag only applies to one streamed LLM-input result and cannot be combined with saved-output options or JSON. See [terminal preview behavior and limitations](docs/usage.md#optional-terminal-preview). diff --git a/cli/azd/extensions/azure.ai.content-understanding/docs/usage.md b/cli/azd/extensions/azure.ai.content-understanding/docs/usage.md index 21c57dcf89e..d9405685deb 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/docs/usage.md +++ b/cli/azd/extensions/azure.ai.content-understanding/docs/usage.md @@ -164,11 +164,11 @@ their own rendering: azd ai cu analyze invoice.pdf > invoice.result.md ``` -Opt in to a human-readable preview with `--render`; use `--render=false` or omit +Opt in to a human-readable preview with `--pretty`; use `--pretty=false` or omit the flag to retain original text: ```bash -azd ai cu analyze invoice.pdf --render +azd ai cu analyze invoice.pdf --pretty ``` The preview uses the existing Glamour renderer for Markdown, including standard @@ -199,14 +199,14 @@ Neither that issue nor Unicode card-label alignment is fixed by the adapter. Preview output removes terminal controls and URL credentials, queries, and fragments, including in fallback source. It is not a byte-for-byte result representation. Colors are used only for terminal writers when color is enabled; explicit -`--render` with a pipe, redirected file, or other writer produces an uncolored +`--pretty` with a pipe, redirected file, or other writer produces an uncolored preview at width 80. The preview does not fetch links or images. -`--render` requires a single streamed LLM-input result. It cannot be combined with +`--pretty` requires a single streamed LLM-input result. It cannot be combined with `--output json`, `--output none`, `--llm-input=false`, `--usage`, `--dry-run`, `--output-file`, or `--output-dir`; these conflicts are rejected before authentication. Saved result files, reports, and the default stdout format are -unchanged. To save original Markdown/HTML, omit `--render`. +unchanged. To save original Markdown/HTML, omit `--pretty`. ### Complete JSON response diff --git a/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/analyze.go b/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/analyze.go index 4cb52a53e9a..7864e6ac929 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/analyze.go +++ b/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/analyze.go @@ -29,7 +29,7 @@ type analyzeOptions struct { usage bool dryRun bool yes bool - render bool + pretty bool } func newAnalyzeCommand(extCtx *azdext.ExtensionContext, open sessionFactory) *cobra.Command { @@ -39,8 +39,8 @@ func newAnalyzeCommand(extCtx *azdext.ExtensionContext, open sessionFactory) *co Long: "Analyze local files, directories, or HTTPS URLs using Microsoft Entra authentication.\n\n" + "Uses prebuilt-layout when no analyzer is specified or configured.\n" + "Results use YAML front matter and Markdown by default. Batches save source-relative result files.\n" + - "Use --render for an opt-in human-readable preview of one streamed LLM-input result.\n" + - "Without --render, stdout retains the original Markdown and HTML tables, including when redirected.\n" + + "Use --pretty for an opt-in human-readable preview of one streamed LLM-input result.\n" + + "Without --pretty, stdout retains the original Markdown and HTML tables, including when redirected.\n" + "Use --output json for complete JSON, or --dry-run to inspect the plan without service calls.", Example: ` azd ai cu analyze invoice.pdf Analyze one file using the configured analyzer, or prebuilt-layout if unset. @@ -60,7 +60,7 @@ func newAnalyzeCommand(extCtx *azdext.ExtensionContext, open sessionFactory) *co azd ai cu analyze invoice.pdf --llm-input --output-file invoice.md Save YAML front matter and Markdown for generative AI model input. - azd ai cu analyze invoice.pdf --render + azd ai cu analyze invoice.pdf --pretty Preview Markdown with supported HTML tables; wide tables use labeled cards.`, RunE: func(cmd *cobra.Command, args []string) error { return exterrors.FromError(runAnalyzeCommand(cmd, extCtx, open, options, args)) @@ -82,7 +82,7 @@ func newAnalyzeCommand(extCtx *azdext.ExtensionContext, open sessionFactory) *co flags.BoolVar(&options.usage, "usage", false, "Include usage metadata in streamed results or per-input summaries") flags.BoolVar(&options.output.LLMInput, "llm-input", true, "Render YAML front matter and Markdown; set false for complete JSON; true conflicts with --output json") - flags.BoolVar(&options.render, "render", false, + flags.BoolVar(&options.pretty, "pretty", false, "Preview one streamed LLM-input result using Markdown and table/cards layout (default: original text)") flags.StringVar(&options.output.File, "output-file", "", "Save one result to this file") flags.StringVarP(&options.output.Directory, "output-dir", "d", "", "Save source-relative result files in this directory") @@ -114,7 +114,7 @@ func runAnalyzeCommand( } options.output.LLMInput = false } - if options.render { + if options.pretty { for _, conflict := range []struct { set bool name string @@ -128,8 +128,8 @@ func runAnalyzeCommand( } { if conflict.set { return commandError("conflicting_analyze_options", - "--render cannot be combined with "+conflict.name+".", - "Omit --render to keep original text, saved results, or structured output.") + "--pretty cannot be combined with "+conflict.name+".", + "Omit --pretty to keep original text, saved results, or structured output.") } } } @@ -170,10 +170,10 @@ func runAnalyzeCommand( if err != nil { return err } - if options.render && len(plan.Outputs) != 1 { + if options.pretty && len(plan.Outputs) != 1 { return commandError("conflicting_analyze_options", - "--render requires one input and cannot preview a batch.", - "Select one input, or omit --render to save batch results.") + "--pretty requires one input and cannot preview a batch.", + "Select one input, or omit --pretty to save batch results.") } if err := ctx.Err(); err != nil { return err @@ -228,7 +228,7 @@ func runAnalyzeCommand( if err := ctx.Err(); err != nil { return err } - if options.render { + if options.pretty { width, useColor := 80, false if file, ok := cmd.OutOrStdout().(*os.File); ok && azdext.IsInteractiveTerminal(file) { var err error diff --git a/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/analyze_render_test.go b/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/analyze_render_test.go index 17b359f5101..212afa30f8d 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/analyze_render_test.go +++ b/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/analyze_render_test.go @@ -36,8 +36,22 @@ func previewResponse(t *testing.T, fixture *analyzeFixture) json.RawMessage { return body } +func TestAnalyzePrettyFlagContract(t *testing.T) { + root := NewRootCommand() + command, _, err := root.Find([]string{"analyze"}) + require.NoError(t, err) + require.NotNil(t, command.Flags().Lookup("pretty")) + assert.Nil(t, command.Flags().Lookup("render")) + + fixture := newAnalyzeFixture(t) + _, _, err = fixture.execute(t.Context(), "https://example.com/doc.pdf", "--render") + require.Error(t, err) + assert.Zero(t, fixture.opens) + assert.Zero(t, fixture.client.calls.Load()) +} + func TestAnalyzeRenderingIsOptIn(t *testing.T) { - for _, flags := range [][]string{nil, {"--render=false"}, {"--render"}} { + for _, flags := range [][]string{nil, {"--pretty=false"}, {"--pretty"}} { t.Run(strings.Join(flags, " "), func(t *testing.T) { fixture := newAnalyzeFixture(t) body := previewResponse(t, fixture) @@ -46,7 +60,7 @@ func TestAnalyzeRenderingIsOptIn(t *testing.T) { assert.Empty(t, stderr) assert.NotContains(t, stdout, "\x1b", "injected/redirected output must not contain ANSI") assert.EqualValues(t, 1, fixture.client.calls.Load()) - if len(flags) != 0 && flags[0] == "--render" { + if len(flags) != 0 && flags[0] == "--pretty" { assert.Contains(t, stdout, "Preview only") assert.Contains(t, stdout, "Important") assert.Contains(t, stdout, "Example") @@ -72,9 +86,9 @@ func TestAnalyzeRenderRejectsConflictsBeforeAuthentication(t *testing.T) { t.Run(strings.Join(flags, " "), func(t *testing.T) { fixture := newAnalyzeFixture(t) t.Chdir(testutil.TempDir(t)) - args := append([]string{"https://example.com/doc.pdf", "--render"}, flags...) + args := append([]string{"https://example.com/doc.pdf", "--pretty"}, flags...) stdout, stderr, err := fixture.execute(t.Context(), args...) - require.ErrorContains(t, err, "--render") + require.ErrorContains(t, err, "--pretty") assert.Empty(t, stdout) assert.Empty(t, stderr) assert.Zero(t, fixture.opens) @@ -86,7 +100,7 @@ func TestAnalyzeRenderRejectsConflictsBeforeAuthentication(t *testing.T) { } fixture := newAnalyzeFixture(t) first, second := schemaFile(t, "first"), schemaFile(t, "second") - _, _, err := fixture.execute(t.Context(), first, second, "--render") + _, _, err := fixture.execute(t.Context(), first, second, "--pretty") require.ErrorContains(t, err, "batch") assert.Zero(t, fixture.opens) assert.Zero(t, fixture.client.calls.Load()) @@ -98,7 +112,7 @@ func TestAnalyzeOriginalFilesAndJSONRemainUnchanged(t *testing.T) { body := previewResponse(t, fixture) path := filepath.Join(testutil.TempDir(t), "result.md") _, stderr, err := fixture.execute(t.Context(), "https://example.com/doc.pdf", - "--render=false", "--output-file", path, "--output", format) + "--pretty=false", "--output-file", path, "--output", format) require.NoError(t, err) assert.Empty(t, stderr) saved, err := os.ReadFile(path) // #nosec G304 -- Fixed filename inside the test's private temporary directory. @@ -128,7 +142,7 @@ func TestAnalyzeRedirectedStdout(t *testing.T) { root.SetErr(&stderr) flags := []string{"analyze", "https://example.com/doc.pdf"} if render { - flags = append(flags, "--render") + flags = append(flags, "--pretty") } root.SetArgs(flags) runErr := root.ExecuteContext(t.Context()) @@ -155,7 +169,7 @@ func TestAnalyzeRenderedPreviewKeepsReportContract(t *testing.T) { fixture := newAnalyzeFixture(t) previewResponse(t, fixture) path := filepath.Join(testutil.TempDir(t), "report.json") - stdout, stderr, err := fixture.execute(t.Context(), "https://example.com/doc.pdf", "--render", "--report-file", path) + stdout, stderr, err := fixture.execute(t.Context(), "https://example.com/doc.pdf", "--pretty", "--report-file", path) require.NoError(t, err) assert.Empty(t, stderr) assert.Contains(t, stdout, "Preview only") From 7172eccd5d76d240d1cc21e34dbd25023ab942e0 Mon Sep 17 00:00:00 2001 From: Chien Yuan Chang <186005646+chienyuanchang@users.noreply.github.com> Date: Wed, 4 Nov 2026 10:55:14 -0800 Subject: [PATCH 11/22] docs(ai-cu): focus terminal preview guidance on user behavior Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../azure.ai.content-understanding/README.md | 4 +- .../docs/usage.md | 49 ++++++------------- 2 files changed, 18 insertions(+), 35 deletions(-) diff --git a/cli/azd/extensions/azure.ai.content-understanding/README.md b/cli/azd/extensions/azure.ai.content-understanding/README.md index 9a0b1e0ba7a..ec2107345c5 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/README.md +++ b/cli/azd/extensions/azure.ai.content-understanding/README.md @@ -190,8 +190,8 @@ azd ai cu analyze invoice.pdf --output-file invoice.result.md Markdown and HTML tables remain unrendered by default, including when stdout is redirected. Opt in to a human-readable preview with `azd ai cu analyze invoice.pdf ---pretty`. Standard Markdown uses Glamour; supported simple HTML tables use the -existing azd table/cards layout. The flag only applies to one streamed LLM-input +--pretty`. The preview formats Markdown and displays simple HTML tables as +tables or labeled cards. The flag only applies to one streamed LLM-input result and cannot be combined with saved-output options or JSON. See [terminal preview behavior and limitations](docs/usage.md#optional-terminal-preview). diff --git a/cli/azd/extensions/azure.ai.content-understanding/docs/usage.md b/cli/azd/extensions/azure.ai.content-understanding/docs/usage.md index d9405685deb..8324c993d6f 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/docs/usage.md +++ b/cli/azd/extensions/azure.ai.content-understanding/docs/usage.md @@ -171,42 +171,25 @@ the flag to retain original text: azd ai cu analyze invoice.pdf --pretty ``` -The preview uses the existing Glamour renderer for Markdown, including standard -bold/italic syntax. Each LLM-input YAML front matter section is displayed as a -separate Metadata code block, not interpreted as document headings or tables. -The existing azd table/cards formatter handles supported HTML -tables. It renders all columns without truncation and selects labeled cards when -the full table exceeds the terminal width. Individual long values can still -overflow. This reuses existing dependencies; the HTML extraction adapter is -extension code. - -Supported tables have one explicit header row, unique nonempty headings, and -rectangular nonempty plain-text data cells. Whitespace is normalized and captions -are retained. Merged cells, rich cell markup, empty cells, duplicate headings, and -unsupported structures show a notice and the table source instead. Markdown -markers inside plain-text HTML cells remain literal. Native Markdown tables stay -with Glamour and can shorten long links. The pinned azd cards implementation has -a known label-alignment limitation with mixed Unicode headings; values are not -lost. - -Offline preview probes cover Traditional Chinese, Japanese, Korean, French, -German, Hindi, Arabic, and emoji/combining-character sequences. They check text -retention, not service language support or visual font/shaping/RTL correctness. -The Hindi table probe exposes another pinned-formatter limitation: its rune-width -padding disagrees with ANSI grapheme-width measurements, so columns can lose alignment. -Neither that issue nor Unicode card-label alignment is fixed by the adapter. - -Preview output removes terminal controls and URL credentials, queries, and fragments, -including in fallback source. It is not a byte-for-byte result representation. -Colors are used only for terminal writers when color is enabled; explicit -`--pretty` with a pipe, redirected file, or other writer produces an uncolored -preview at width 80. The preview does not fetch links or images. +The preview formats Markdown for easier terminal reading and displays document +metadata in separate code blocks. Simple HTML tables show all columns, switching +to labeled cards when a table is too wide. + +Keep these limitations in mind: + +- Complex HTML tables, such as those with merged cells or formatted cell content, + show a notice and their source instead of a formatted table. +- Long values can overflow, and some Unicode text or emoji may not align correctly. + Right-to-left text display depends on your terminal. Markdown tables may shorten + long links. +- Preview text removes terminal controls and URL credentials, queries, and + fragments; it is not an exact copy of the result. Links and images are not fetched. +- Piped or redirected previews are uncolored and use an 80-column layout. `--pretty` requires a single streamed LLM-input result. It cannot be combined with `--output json`, `--output none`, `--llm-input=false`, `--usage`, `--dry-run`, -`--output-file`, or `--output-dir`; these conflicts are rejected before -authentication. Saved result files, reports, and the default stdout format are -unchanged. To save original Markdown/HTML, omit `--pretty`. +`--output-file`, or `--output-dir`. Saved result files, reports, and the default +stdout format are unchanged. To save original Markdown/HTML, omit `--pretty`. ### Complete JSON response From 36bc5f6d3f4b3ecd85dc55d1963ceebf75727ed9 Mon Sep 17 00:00:00 2001 From: Chien Yuan Chang <186005646+chienyuanchang@users.noreply.github.com> Date: Wed, 4 Nov 2026 11:00:28 -0800 Subject: [PATCH 12/22] fix(ai-cu): clarify pretty-print help and color terminal tables Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../azure.ai.content-understanding/README.md | 9 ++++---- .../docs/usage.md | 9 ++++---- .../internal/cmd/analyze.go | 6 ++--- .../internal/preview/render.go | 10 ++++++++- .../internal/preview/render_test.go | 22 +++++++++++++++++++ 5 files changed, 44 insertions(+), 12 deletions(-) diff --git a/cli/azd/extensions/azure.ai.content-understanding/README.md b/cli/azd/extensions/azure.ai.content-understanding/README.md index ec2107345c5..7c8fd2aa4c6 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/README.md +++ b/cli/azd/extensions/azure.ai.content-understanding/README.md @@ -189,11 +189,12 @@ azd ai cu analyze invoice.pdf --output-file invoice.result.md ``` Markdown and HTML tables remain unrendered by default, including when stdout is -redirected. Opt in to a human-readable preview with `azd ai cu analyze invoice.pdf ---pretty`. The preview formats Markdown and displays simple HTML tables as -tables or labeled cards. The flag only applies to one streamed LLM-input +redirected. Use `azd ai cu analyze invoice.pdf --pretty` to pretty-print results +in the terminal, with formatted Markdown and simple HTML tables displayed as +tables or labeled cards. Colors distinguish headings and tables when enabled. +The flag only applies to one streamed LLM-input result and cannot be combined with saved-output options or JSON. See -[terminal preview behavior and limitations](docs/usage.md#optional-terminal-preview). +[pretty-print behavior and limitations](docs/usage.md#pretty-print-results-in-the-terminal). For the complete JSON response, including service warnings and usage when present, use `--output json`. Display it in the console or save it to a file: diff --git a/cli/azd/extensions/azure.ai.content-understanding/docs/usage.md b/cli/azd/extensions/azure.ai.content-understanding/docs/usage.md index 8324c993d6f..db79ec38f1f 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/docs/usage.md +++ b/cli/azd/extensions/azure.ai.content-understanding/docs/usage.md @@ -154,7 +154,7 @@ azd ai cu analyze \ --output-file invoice.result.md ``` -### Optional terminal preview +### Pretty-print results in the terminal The default stdout retains the original Markdown, including HTML tables. This also applies to redirects and pipes, so GUI viewers and other consumers can do @@ -164,16 +164,17 @@ their own rendering: azd ai cu analyze invoice.pdf > invoice.result.md ``` -Opt in to a human-readable preview with `--pretty`; use `--pretty=false` or omit +Pretty-print results in the terminal with `--pretty`; use `--pretty=false` or omit the flag to retain original text: ```bash azd ai cu analyze invoice.pdf --pretty ``` -The preview formats Markdown for easier terminal reading and displays document +Pretty-printing formats Markdown for easier terminal reading and displays document metadata in separate code blocks. Simple HTML tables show all columns, switching -to labeled cards when a table is too wide. +to labeled cards when a table is too wide. When color is enabled, headings and +tables use distinct colors to make them easier to spot. Keep these limitations in mind: diff --git a/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/analyze.go b/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/analyze.go index 7864e6ac929..bd9ae9fed9e 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/analyze.go +++ b/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/analyze.go @@ -39,7 +39,7 @@ func newAnalyzeCommand(extCtx *azdext.ExtensionContext, open sessionFactory) *co Long: "Analyze local files, directories, or HTTPS URLs using Microsoft Entra authentication.\n\n" + "Uses prebuilt-layout when no analyzer is specified or configured.\n" + "Results use YAML front matter and Markdown by default. Batches save source-relative result files.\n" + - "Use --pretty for an opt-in human-readable preview of one streamed LLM-input result.\n" + + "Use --pretty to pretty-print one streamed LLM-input result in the terminal.\n" + "Without --pretty, stdout retains the original Markdown and HTML tables, including when redirected.\n" + "Use --output json for complete JSON, or --dry-run to inspect the plan without service calls.", Example: ` azd ai cu analyze invoice.pdf @@ -61,7 +61,7 @@ func newAnalyzeCommand(extCtx *azdext.ExtensionContext, open sessionFactory) *co Save YAML front matter and Markdown for generative AI model input. azd ai cu analyze invoice.pdf --pretty - Preview Markdown with supported HTML tables; wide tables use labeled cards.`, + Pretty-print Markdown and tables in the terminal; wide tables use labeled cards.`, RunE: func(cmd *cobra.Command, args []string) error { return exterrors.FromError(runAnalyzeCommand(cmd, extCtx, open, options, args)) }, @@ -83,7 +83,7 @@ func newAnalyzeCommand(extCtx *azdext.ExtensionContext, open sessionFactory) *co flags.BoolVar(&options.output.LLMInput, "llm-input", true, "Render YAML front matter and Markdown; set false for complete JSON; true conflicts with --output json") flags.BoolVar(&options.pretty, "pretty", false, - "Preview one streamed LLM-input result using Markdown and table/cards layout (default: original text)") + "Pretty-print one streamed LLM-input result in the terminal (default: original text)") flags.StringVar(&options.output.File, "output-file", "", "Save one result to this file") flags.StringVarP(&options.output.Directory, "output-dir", "d", "", "Save source-relative result files in this directory") flags.StringVar(&options.output.Existing, "on-existing", "", diff --git a/cli/azd/extensions/azure.ai.content-understanding/internal/preview/render.go b/cli/azd/extensions/azure.ai.content-understanding/internal/preview/render.go index 60d4ef147ce..a97de9a91cc 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/internal/preview/render.go +++ b/cli/azd/extensions/azure.ai.content-understanding/internal/preview/render.go @@ -8,6 +8,7 @@ import ( "context" "fmt" "io" + "os" "slices" "strconv" "strings" @@ -15,6 +16,7 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/output" "github.com/charmbracelet/glamour" "github.com/charmbracelet/x/ansi" + terminalColor "github.com/fatih/color" "github.com/yuin/goldmark" "github.com/yuin/goldmark/ast" "github.com/yuin/goldmark/extension" @@ -38,6 +40,9 @@ func Render(ctx context.Context, writer io.Writer, source string, width int, col if width < 1 { return fmt.Errorf("preview width must be positive") } + color = color && os.Getenv("NO_COLOR") == "" + tableColor := terminalColor.New(terminalColor.FgCyan) + tableColor.EnableColor() style := "ascii" if color { style = "dark" @@ -92,7 +97,10 @@ func Render(ctx context.Context, writer io.Writer, source string, width int, col return ast.WalkStop, err } if parsed.caption != "" { - result.WriteString(parsed.caption + "\n") + rendered = parsed.caption + "\n" + rendered + } + if color { + rendered = tableColor.Sprint(ansi.Strip(rendered)) } result.WriteString(rendered) result.WriteByte('\n') diff --git a/cli/azd/extensions/azure.ai.content-understanding/internal/preview/render_test.go b/cli/azd/extensions/azure.ai.content-understanding/internal/preview/render_test.go index ff78f1d5d89..17ac8977f9b 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/internal/preview/render_test.go +++ b/cli/azd/extensions/azure.ai.content-understanding/internal/preview/render_test.go @@ -67,6 +67,28 @@ func TestWideTableSelectsCardsAndKeepsValues(t *testing.T) { } } +func TestPrettyPrintTableColorsPreserveContentAndLayout(t *testing.T) { + t.Setenv("NO_COLOR", "") + const source = "" + + "
Totals
NameValue
ExampleRECORD0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ
\n" + for _, width := range []int{20, 120} { + t.Run(strconv.Itoa(width), func(t *testing.T) { + plain := renderPreview(t, source, width) + var colored bytes.Buffer + require.NoError(t, Render(t.Context(), &colored, source, width, true)) + assert.Contains(t, colored.String(), "\x1b[36mTotals") + assert.Equal(t, plain, ansi.Strip(colored.String())) + if width == 20 { + assert.Contains(t, colored.String(), "Row 1") + } + t.Setenv("NO_COLOR", "1") + var disabled bytes.Buffer + require.NoError(t, Render(t.Context(), &disabled, source, width, true)) + assert.Equal(t, plain, disabled.String()) + }) + } +} + func TestUnsupportedTablesPreserveSource(t *testing.T) { for _, source := range []string{ "
AB
Merged
", From 5db056fbac2f8514b404d132cd0a3c554573c541 Mon Sep 17 00:00:00 2001 From: Chien Yuan Chang <186005646+chienyuanchang@users.noreply.github.com> Date: Wed, 4 Nov 2026 11:06:06 -0800 Subject: [PATCH 13/22] docs(ai-cu): add release notes for terminal pretty-printing Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../CHANGELOG.md | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/cli/azd/extensions/azure.ai.content-understanding/CHANGELOG.md b/cli/azd/extensions/azure.ai.content-understanding/CHANGELOG.md index 908321bd95c..5bf23016a53 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/CHANGELOG.md +++ b/cli/azd/extensions/azure.ai.content-understanding/CHANGELOG.md @@ -1,6 +1,23 @@ # Release History -## 0.1.1-preview (Unreleased) +## 0.1.2-preview (Unreleased) + +### Features Added + +- Add `azd ai cu analyze --pretty` to pretty-print a single result in the + terminal, with formatted Markdown, colored headings, and simple HTML tables + displayed as tables or labeled cards. Default output and saved results remain + unchanged. + +### Other Changes + +- Make `azd ai cu version` easier to read with a descriptive title, labeled + version and extension name, and spacing before azd update notices. JSON output + remains unchanged. +- Highlight commands, paths, and identifiers in infrastructure generation + guidance, and use blue text for wizard questions. Respect `NO_COLOR`. + +## 0.1.1-preview (2026-11-03) ### Features Added From 226f84ed5ca8215f414cb048d98ae9921d0bc477 Mon Sep 17 00:00:00 2001 From: Chien Yuan Chang <186005646+chienyuanchang@users.noreply.github.com> Date: Wed, 4 Nov 2026 12:27:41 -0800 Subject: [PATCH 14/22] chore(ai-cu): bump extension version to 0.1.2-preview Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../extensions/azure.ai.content-understanding/extension.yaml | 2 +- cli/azd/extensions/azure.ai.content-understanding/version.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/azd/extensions/azure.ai.content-understanding/extension.yaml b/cli/azd/extensions/azure.ai.content-understanding/extension.yaml index d18b6e331ba..5fdc06774f9 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/extension.yaml +++ b/cli/azd/extensions/azure.ai.content-understanding/extension.yaml @@ -12,4 +12,4 @@ tags: - ai - content-understanding usage: azd ai cu [options] -version: 0.1.1-preview +version: 0.1.2-preview diff --git a/cli/azd/extensions/azure.ai.content-understanding/version.txt b/cli/azd/extensions/azure.ai.content-understanding/version.txt index 9ff8406fee4..15b416cc5ea 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/version.txt +++ b/cli/azd/extensions/azure.ai.content-understanding/version.txt @@ -1 +1 @@ -0.1.1-preview +0.1.2-preview From 48c678e91647bb1f0ea818e876808515ff409853 Mon Sep 17 00:00:00 2001 From: Chien Yuan Chang <186005646+chienyuanchang@users.noreply.github.com> Date: Wed, 4 Nov 2026 13:24:05 -0800 Subject: [PATCH 15/22] fix(ai-cu): preserve reference links across HTML tables Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../docs/usage.md | 3 +- .../internal/preview/render.go | 23 +++++++++- .../internal/preview/render_test.go | 46 +++++++++++++++++++ 3 files changed, 69 insertions(+), 3 deletions(-) diff --git a/cli/azd/extensions/azure.ai.content-understanding/docs/usage.md b/cli/azd/extensions/azure.ai.content-understanding/docs/usage.md index db79ec38f1f..6d817f306ec 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/docs/usage.md +++ b/cli/azd/extensions/azure.ai.content-understanding/docs/usage.md @@ -172,7 +172,8 @@ azd ai cu analyze invoice.pdf --pretty ``` Pretty-printing formats Markdown for easier terminal reading and displays document -metadata in separate code blocks. Simple HTML tables show all columns, switching +metadata in separate code blocks. Reference-style links work even when their +definitions are on the other side of a table. Simple HTML tables show all columns, switching to labeled cards when a table is too wide. When color is enabled, headings and tables use distinct colors to make them easier to spot. diff --git a/cli/azd/extensions/azure.ai.content-understanding/internal/preview/render.go b/cli/azd/extensions/azure.ai.content-understanding/internal/preview/render.go index a97de9a91cc..a15344512cd 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/internal/preview/render.go +++ b/cli/azd/extensions/azure.ai.content-understanding/internal/preview/render.go @@ -58,6 +58,25 @@ func Render(ctx context.Context, writer io.Writer, source string, width int, col } data := []byte(source) document := goldmark.New(goldmark.WithExtensions(extension.GFM)).Parser().Parse(text.NewReader(data)) + var references strings.Builder + err = ast.Walk(document, func(node ast.Node, entering bool) (ast.WalkStatus, error) { + if err := ctx.Err(); err != nil { + return ast.WalkStop, err + } + if _, ok := node.(*ast.LinkReferenceDefinition); entering && ok { + references.Write(node.Lines().Value(data)) + references.WriteString("\n\n") + } + return ast.WalkContinue, nil + }) + if err != nil { + return err + } + // Each fragment needs the document's definitions in source order so links + // resolve across tables and duplicate labels retain their first definition. + renderMarkdown := func(fragment string) (string, error) { + return renderer.Render(references.String() + fragment) + } var result strings.Builder result.WriteString("Preview only; original Markdown/HTML is unchanged. " + "URLs omit credentials, queries and fragments.\n\n") @@ -79,7 +98,7 @@ func Render(ctx context.Context, writer io.Writer, source string, width int, col if !strings.Contains(strings.ToLower(raw), "\n \"Report title\"\n", "https://example.com/report"}, + {"multiple-tables", "See [the report][report].\n\n" + table + table + + "[report]: https://example.com/report\n", "https://example.com/report"}, + {"collapsed-reference", "See [report][].\n\n" + table + + "[report]: https://example.com/report\n", "https://example.com/report"}, + {"shortcut-reference", "See [report].\n\n" + table + + "[report]: https://example.com/report\n", "https://example.com/report"}, + {"image-reference", "![Report image][report]\n\n" + table + + "[report]: https://example.com/report\n", "https://example.com/report"}, + {"nested-definition", "> [report]: https://example.com/report\n\n" + table + + "See [the report][report].\n", "https://example.com/report"}, + {"escaped-label", "See [the report][report\\]].\n\n" + table + + "[report\\]]: \n", "https://example.com/report"}, + {"fenced-definition-is-literal", "```markdown\n[report]: https://example.com/literal\n```\n\n" + + "See [the report][report].\n\n" + table + "[report]: https://example.com/report\n", + "https://example.com/report"}, + {"unrendered-table", "See [the report][report].\n\n" + + "
Name
Example
\n\n" + + "[report]: https://example.com/report\n", "https://example.com/report"}, + } { + t.Run(sample.name, func(t *testing.T) { + for _, color := range []bool{false, true} { + var result bytes.Buffer + require.NoError(t, Render(t.Context(), &result, sample.source, 120, color)) + plain := ansi.Strip(result.String()) + assert.Contains(t, plain, sample.destination) + assert.NotContains(t, plain, "[the report][report]") + assert.NotContains(t, plain, "https://example.com/second") + assert.Contains(t, plain, "Example") + } + }) + } +} + func TestPrettyPrintTableColorsPreserveContentAndLayout(t *testing.T) { t.Setenv("NO_COLOR", "") const source = "" + From 701dab3594351bc5580d707f80b974c62f625b74 Mon Sep 17 00:00:00 2001 From: Chien Yuan Chang <186005646+chienyuanchang@users.noreply.github.com> Date: Fri, 6 Nov 2026 11:07:45 -0800 Subject: [PATCH 16/22] fix(ai-cu): preserve document structure in pretty previews Keep metadata delimiters, page comments and source line breaks. Align H1 styling with other headings, replace the blanket preview banner with table layout notices, and mark generated card row labels as dim comments. Retain existing URL safety filtering. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../CHANGELOG.md | 3 +- .../docs/usage.md | 10 +- .../internal/cmd/analyze_render_test.go | 8 +- .../internal/preview/metadata.go | 41 ++----- .../internal/preview/metadata_test.go | 24 ++-- .../internal/preview/multilingual_test.go | 2 +- .../internal/preview/render.go | 112 +++++++++++++----- .../internal/preview/render_test.go | 80 ++++++++++++- 8 files changed, 203 insertions(+), 77 deletions(-) diff --git a/cli/azd/extensions/azure.ai.content-understanding/CHANGELOG.md b/cli/azd/extensions/azure.ai.content-understanding/CHANGELOG.md index 5bf23016a53..529c029abfe 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/CHANGELOG.md +++ b/cli/azd/extensions/azure.ai.content-understanding/CHANGELOG.md @@ -7,7 +7,8 @@ - Add `azd ai cu analyze --pretty` to pretty-print a single result in the terminal, with formatted Markdown, colored headings, and simple HTML tables displayed as tables or labeled cards. Default output and saved results remain - unchanged. + unchanged. Preserve metadata delimiters, page comments, and source line + breaks; show layout notices only when a table falls back to source or cards. ### Other Changes diff --git a/cli/azd/extensions/azure.ai.content-understanding/docs/usage.md b/cli/azd/extensions/azure.ai.content-understanding/docs/usage.md index 6d817f306ec..aac3dc71f77 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/docs/usage.md +++ b/cli/azd/extensions/azure.ai.content-understanding/docs/usage.md @@ -172,9 +172,15 @@ azd ai cu analyze invoice.pdf --pretty ``` Pretty-printing formats Markdown for easier terminal reading and displays document -metadata in separate code blocks. Reference-style links work even when their +metadata with its original `---` delimiters. Source line breaks and page comments +remain visible. Metadata, page comments, and generated row annotations use a dim +color when color is enabled; H1 headings retain the `#` marker and use the same +heading color as H2/H3/H4, without a separate highlight background. +Reference-style links work even when their definitions are on the other side of a table. Simple HTML tables show all columns, switching -to labeled cards when a table is too wide. When color is enabled, headings and +to labeled cards when a table is too wide. A notice explains the switch, and +generated labels such as `` are clearly marked as comments. +When color is enabled, layout notices are yellow, and headings and tables use distinct colors to make them easier to spot. Keep these limitations in mind: diff --git a/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/analyze_render_test.go b/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/analyze_render_test.go index 212afa30f8d..a02bd30f8ec 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/analyze_render_test.go +++ b/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/analyze_render_test.go @@ -61,7 +61,7 @@ func TestAnalyzeRenderingIsOptIn(t *testing.T) { assert.NotContains(t, stdout, "\x1b", "injected/redirected output must not contain ANSI") assert.EqualValues(t, 1, fixture.client.calls.Load()) if len(flags) != 0 && flags[0] == "--pretty" { - assert.Contains(t, stdout, "Preview only") + assert.NotContains(t, stdout, "Preview only") assert.Contains(t, stdout, "Important") assert.Contains(t, stdout, "Example") assert.NotContains(t, stdout, "
Totals
NameValue
") @@ -153,7 +153,8 @@ func TestAnalyzeRedirectedStdout(t *testing.T) { assert.Empty(t, stderr.String()) assert.NotContains(t, string(result), "\x1b") if render { - assert.Contains(t, string(result), "Preview only") + assert.NotContains(t, string(result), "Preview only") + assert.Contains(t, string(result), "Important") assert.NotContains(t, string(result), "
") } else { var envelope struct{ Result json.RawMessage } @@ -172,7 +173,8 @@ func TestAnalyzeRenderedPreviewKeepsReportContract(t *testing.T) { stdout, stderr, err := fixture.execute(t.Context(), "https://example.com/doc.pdf", "--pretty", "--report-file", path) require.NoError(t, err) assert.Empty(t, stderr) - assert.Contains(t, stdout, "Preview only") + assert.NotContains(t, stdout, "Preview only") + assert.Contains(t, stdout, "Example") report, err := os.ReadFile(path) // #nosec G304 -- Fixed filename inside the test's private temporary directory. require.NoError(t, err) var parsed map[string]any diff --git a/cli/azd/extensions/azure.ai.content-understanding/internal/preview/metadata.go b/cli/azd/extensions/azure.ai.content-understanding/internal/preview/metadata.go index 391aa4567f3..ec792005adc 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/internal/preview/metadata.go +++ b/cli/azd/extensions/azure.ai.content-understanding/internal/preview/metadata.go @@ -6,7 +6,6 @@ package preview import ( "fmt" "regexp" - "strings" "github.com/yuin/goldmark" "github.com/yuin/goldmark/ast" @@ -19,19 +18,15 @@ var ( metadataEnd = regexp.MustCompile(`(?m)^---(?:\n|$)`) ) -// formatMetadata recognizes the RenderLLM content boundaries, not arbitrary +// metadataBlocks recognizes the RenderLLM content boundaries, not arbitrary // horizontal rules. Masking candidate YAML keeps its opaque values out of the // Markdown parser while retaining fences surrounding literal examples. -func formatMetadata(source string) (string, error) { - type block struct { - start, end int - yaml string - } - var blocks []block +func metadataBlocks(source string) ([]text.Segment, error) { + var blocks []text.Segment masked := []byte(source) for _, match := range metadataStart.FindAllStringIndex(source, -1) { start := match[1] - len("---\n") - if len(blocks) != 0 && start < blocks[len(blocks)-1].end { + if len(blocks) != 0 && start < blocks[len(blocks)-1].Stop { continue } body := match[1] @@ -49,7 +44,7 @@ func formatMetadata(source string) (string, error) { len(node.Content[0].Content) < 2 || node.Content[0].Content[0].Value != "mimeType" { continue } - blocks = append(blocks, block{start: start, end: end, yaml: content}) + blocks = append(blocks, text.NewSegment(start, end)) for index := start; index < end; index++ { if masked[index] != '\n' { masked[index] = ' ' @@ -57,7 +52,7 @@ func formatMetadata(source string) (string, error) { } } if len(blocks) == 0 { - return source, nil + return nil, nil } document := goldmark.New().Parser().Parse(text.NewReader(masked)) var protected []text.Segment @@ -76,14 +71,13 @@ func formatMetadata(source string) (string, error) { } }) if err != nil { - return "", fmt.Errorf("locate preview metadata: %w", err) + return nil, fmt.Errorf("locate preview metadata: %w", err) } - var result strings.Builder - cursor := 0 + var result []text.Segment for _, block := range blocks { literal := false for _, segment := range protected { - if block.start >= segment.Start && block.start < segment.Stop { + if block.Start >= segment.Start && block.Start < segment.Stop { literal = true break } @@ -91,20 +85,7 @@ func formatMetadata(source string) (string, error) { if literal { continue } - result.WriteString(source[cursor:block.start]) - longest, run := 0, 0 - for _, value := range []byte(block.yaml) { - if value == '`' { - run++ - longest = max(longest, run) - } else { - run = 0 - } - } - fence := strings.Repeat("`", max(3, longest+1)) - result.WriteString("**Metadata**\n\n" + fence + "yaml\n" + block.yaml + fence + "\n\n") - cursor = block.end + result = append(result, block) } - result.WriteString(source[cursor:]) - return result.String(), nil + return result, nil } diff --git a/cli/azd/extensions/azure.ai.content-understanding/internal/preview/metadata_test.go b/cli/azd/extensions/azure.ai.content-understanding/internal/preview/metadata_test.go index 276d1c006df..5f4e63d6a70 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/internal/preview/metadata_test.go +++ b/cli/azd/extensions/azure.ai.content-understanding/internal/preview/metadata_test.go @@ -16,16 +16,18 @@ import ( "github.com/stretchr/testify/require" ) -func TestMetadataUsesDedicatedYAMLBlock(t *testing.T) { +func TestMetadataPreservesOriginalDelimiters(t *testing.T) { source := "---\nmimeType: application/pdf\npages: 1\n---\n# Title\n\nBody.\n" - formatted, err := formatMetadata(source) + blocks, err := metadataBlocks(source) require.NoError(t, err) - assert.Equal(t, "**Metadata**\n\n```yaml\nmimeType: application/pdf\npages: 1\n```\n\n# Title\n\nBody.\n", formatted) + require.Len(t, blocks, 1) + assert.Equal(t, "---\nmimeType: application/pdf\npages: 1\n---\n", source[blocks[0].Start:blocks[0].Stop]) for _, color := range []bool{false, true} { var output bytes.Buffer require.NoError(t, Render(t.Context(), &output, source, 80, color)) plain := ansi.Strip(output.String()) - assert.Contains(t, plain, "Metadata") + assert.NotContains(t, plain, "Metadata") + assert.Contains(t, plain, "---\nmimeType: application/pdf\npages: 1\n---\n") assert.Contains(t, plain, "mimeType: application/pdf") assert.NotContains(t, plain, "## mimeType") assert.Contains(t, plain, "Title") @@ -44,9 +46,9 @@ func TestMetadataLeavesMarkdownAndLiteralExamplesAlone(t *testing.T) { "# Examples\n\n~~~markdown\nExample:\n\n*****\n\n---\nmimeType: text/plain\n---\n~~~\n", "\n", } { - formatted, err := formatMetadata(source) + blocks, err := metadataBlocks(source) require.NoError(t, err) - assert.Equal(t, source, formatted) + assert.Empty(t, blocks) } } @@ -70,11 +72,11 @@ func TestMetadataAcrossProjectedContentsAndOpaqueValues(t *testing.T) { require.NoError(t, err) original, err := analysis.RenderLLM(payload) require.NoError(t, err) - formatted, err := formatMetadata(original) + blocks, err := metadataBlocks(original) require.NoError(t, err) - assert.Equal(t, 2, strings.Count(formatted, "**Metadata**")) - assert.Contains(t, formatted, "`````yaml", "opaque backticks cannot close the metadata code block") - assert.Contains(t, formatted, literalExample, "document code examples must stay literal") + require.Len(t, blocks, 2) + assert.Contains(t, original[blocks[0].Start:blocks[0].Stop], "Not a document table") + assert.Contains(t, original, literalExample, "document code examples must stay literal") for _, color := range []bool{false, true} { var result bytes.Buffer require.NoError(t, Render(t.Context(), &result, original, 120, color)) @@ -84,7 +86,7 @@ func TestMetadataAcrossProjectedContentsAndOpaqueValues(t *testing.T) { assert.Contains(t, plain, "mimeType: audio/wav") assert.Contains(t, plain, "Not a document table") assert.Contains(t, plain, "Document body value") - assert.NotContains(t, plain, "[HTML table not rendered:") + assert.NotContains(t, plain, "HTML table not rendered:") assert.Contains(t, plain, "Transcript.") } assert.True(t, strings.HasPrefix(original, "---\nmimeType: application/pdf\n")) diff --git a/cli/azd/extensions/azure.ai.content-understanding/internal/preview/multilingual_test.go b/cli/azd/extensions/azure.ai.content-understanding/internal/preview/multilingual_test.go index fc1bfd455e4..98971991bd4 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/internal/preview/multilingual_test.go +++ b/cli/azd/extensions/azure.ai.content-understanding/internal/preview/multilingual_test.go @@ -41,7 +41,7 @@ func TestMultilingualPreview(t *testing.T) { assert.Contains(t, plain, sample.greeting) assert.Contains(t, plain, identifier) assert.NotContains(t, plain, "\ufffd") - assert.NotContains(t, plain, "[HTML table not rendered:") + assert.NotContains(t, plain, "HTML table not rendered:") if !color { assert.NotContains(t, output.String(), "\x1b") } diff --git a/cli/azd/extensions/azure.ai.content-understanding/internal/preview/render.go b/cli/azd/extensions/azure.ai.content-understanding/internal/preview/render.go index a15344512cd..42bffbe882b 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/internal/preview/render.go +++ b/cli/azd/extensions/azure.ai.content-understanding/internal/preview/render.go @@ -5,6 +5,7 @@ package preview import ( "bytes" + "cmp" "context" "fmt" "io" @@ -15,6 +16,7 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/output" "github.com/charmbracelet/glamour" + "github.com/charmbracelet/glamour/styles" "github.com/charmbracelet/x/ansi" terminalColor "github.com/fatih/color" "github.com/yuin/goldmark" @@ -31,6 +33,11 @@ type table struct { rows [][]string } +type previewBlock struct { + start, end int + table bool +} + // Render writes a human-readable preview without changing the source result. // Width controls layout selection, not a hard bound on retained long values. func Render(ctx context.Context, writer io.Writer, source string, width int, color bool) error { @@ -42,21 +49,38 @@ func Render(ctx context.Context, writer io.Writer, source string, width int, col } color = color && os.Getenv("NO_COLOR") == "" tableColor := terminalColor.New(terminalColor.FgCyan) + commentColor := terminalColor.New(terminalColor.FgHiBlack, terminalColor.Faint) + warningColor := terminalColor.New(terminalColor.FgYellow) tableColor.EnableColor() - style := "ascii" + commentColor.EnableColor() + warningColor.EnableColor() + style := styles.ASCIIStyleConfig if color { - style = "dark" + style = styles.DarkStyleConfig } - renderer, err := glamour.NewTermRenderer(glamour.WithStandardStyle(style), glamour.WithWordWrap(width)) + style.H1 = style.H2 + style.H1.Prefix = "# " + renderer, err := glamour.NewTermRenderer( + glamour.WithStyles(style), glamour.WithWordWrap(width), glamour.WithPreservedNewLines(), + ) if err != nil { return fmt.Errorf("initialize Markdown preview: %w", err) } source = safeText(source) - source, err = formatMetadata(source) + metadata, err := metadataBlocks(source) if err != nil { return err } data := []byte(source) + var blocks []previewBlock + for _, segment := range metadata { + blocks = append(blocks, previewBlock{start: segment.Start, end: segment.Stop}) + for index := segment.Start; index < segment.Stop; index++ { + if data[index] != '\n' { + data[index] = ' ' + } + } + } document := goldmark.New(goldmark.WithExtensions(extension.GFM)).Parser().Parse(text.NewReader(data)) var references strings.Builder err = ast.Walk(document, func(node ast.Node, entering bool) (ast.WalkStatus, error) { @@ -77,10 +101,6 @@ func Render(ctx context.Context, writer io.Writer, source string, width int, col renderMarkdown := func(fragment string) (string, error) { return renderer.Render(references.String() + fragment) } - var result strings.Builder - result.WriteString("Preview only; original Markdown/HTML is unchanged. " + - "URLs omit credentials, queries and fragments.\n\n") - cursor := 0 err = ast.Walk(document, func(node ast.Node, entering bool) (ast.WalkStatus, error) { if err := ctx.Err(); err != nil { return ast.WalkStop, err @@ -95,40 +115,78 @@ func Render(ctx context.Context, writer io.Writer, source string, width int, col end = max(end, block.ClosureLine.Stop) } raw := source[start:end] - if !strings.Contains(strings.ToLower(raw), "", reason) + if color { + notice = warningColor.Sprint(notice) + } + fmt.Fprintf(&result, "\n%s\n%s\n", notice, raw) } else { - rendered, err := renderTable(parsed, width) + rendered, cards, err := renderTable(parsed, width) if err != nil { - return ast.WalkStop, err + return err + } + if cards { + notice := "" + if color { + notice = warningColor.Sprint(notice) + } + result.WriteString(notice + "\n") } if parsed.caption != "" { rendered = parsed.caption + "\n" + rendered } if color { - rendered = tableColor.Sprint(ansi.Strip(rendered)) + var styled strings.Builder + for line := range strings.SplitAfterSeq(ansi.Strip(rendered), "\n") { + if cards && strings.HasPrefix(strings.TrimSpace(line), ""}, CardTitle: true, }}, columns...) result.Reset() if err := formatter.Format(rows, &result, options); err != nil { - return "", fmt.Errorf("render cards: %w", err) + return "", false, fmt.Errorf("render cards: %w", err) } break } - return result.String(), nil + return result.String(), options.ForceCards, nil } diff --git a/cli/azd/extensions/azure.ai.content-understanding/internal/preview/render_test.go b/cli/azd/extensions/azure.ai.content-understanding/internal/preview/render_test.go index c184b0b0cf6..f808be0f351 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/internal/preview/render_test.go +++ b/cli/azd/extensions/azure.ai.content-understanding/internal/preview/render_test.go @@ -46,6 +46,82 @@ func TestMarkdownStylesAndHTMLTable(t *testing.T) { assert.Contains(t, ansi.Strip(colored.String()), "Bold") } +func TestPrettyPreservesDocumentStructure(t *testing.T) { + t.Setenv("NO_COLOR", "") + const metadata = "---\nmimeType: application/pdf\npages: 1\n---\n" + const comment = "" + source := metadata + "# Document title\n\nFirst source line.\nSecond source line.\n\n" + comment + "\n" + for _, color := range []bool{false, true} { + var result bytes.Buffer + require.NoError(t, Render(t.Context(), &result, source, 120, color)) + plain := ansi.Strip(result.String()) + assert.Contains(t, plain, metadata) + assert.Contains(t, plain, comment) + assert.Contains(t, plain, "# Document title") + assert.Contains(t, plain, "Document title") + assert.Regexp(t, `First source line\.\s*\n\s*Second source line\.`, plain) + assert.NotContains(t, plain, "Preview only") + assert.NotContains(t, plain, "URLs omit") + if color { + assert.Contains(t, result.String(), "\x1b[90;2m---") + assert.Contains(t, result.String(), "\x1b[90;2m"+comment) + } + } + commentWithTable := "" + plain := renderPreview(t, commentWithTable+"\n", 120) + assert.Contains(t, plain, commentWithTable) + assert.NotContains(t, plain, "HTML table not rendered:") +} + +func TestH1UsesTheOtherHeadingStyles(t *testing.T) { + t.Setenv("NO_COLOR", "") + const source = "# First\n\n## Second\n\n### Third\n\n#### Fourth\n" + for _, color := range []bool{false, true} { + var result bytes.Buffer + require.NoError(t, Render(t.Context(), &result, source, 120, color)) + plain := ansi.Strip(result.String()) + for _, heading := range []string{"# First", "## Second", "### Third", "#### Fourth"} { + assert.Contains(t, plain, heading) + } + if color { + for line := range strings.SplitSeq(result.String(), "\n") { + for _, title := range []string{"First", "Second", "Third", "Fourth"} { + if strings.Contains(line, title) { + assert.Contains(t, line, "\x1b[38;5;39;1m", "all headings use the shared heading color") + assert.NotContains(t, line, "\x1b[48;", "H1 has no dedicated background") + } + } + } + } + } +} + +func TestPrettyLayoutNoticesAndRowAnnotations(t *testing.T) { + t.Setenv("NO_COLOR", "") + const table = "
" + + "" + + "
NameValue
FirstRECORD0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ
SecondRECORD9876543210ABCDEFGHIJKLMNOPQRSTUVWXYZ
" + for _, width := range []int{20, 160} { + var result bytes.Buffer + require.NoError(t, Render(t.Context(), &result, table, width, true)) + plain := ansi.Strip(result.String()) + if width == 20 { + assert.Contains(t, result.String(), "\x1b[33m") + assert.Contains(t, plain, "") + assert.Regexp(t, "\x1b\\[90;2m[^\\n]*", result.String()) + } else { + assert.NotContains(t, plain, "Table exceeds terminal width") + assert.NotContains(t, plain, "" @@ -109,7 +111,9 @@ func TestPrettyLayoutNoticesAndRowAnnotations(t *testing.T) { assert.Contains(t, result.String(), "\x1b[33m") assert.Contains(t, plain, "") - assert.Regexp(t, "\x1b\\[90;2m[^\\n]*", result.String()) + assert.Regexp(t, "\x1b\\[90m[^\\n]*", result.String()) + assert.NotContains(t, result.String(), "\x1b[90;2m") + assert.NotContains(t, result.String(), "\x1b[2m") } else { assert.NotContains(t, plain, "Table exceeds terminal width") assert.NotContains(t, plain, "` are clearly marked as comments. -When color is enabled, layout notices are yellow, and headings and -tables use distinct colors to make them easier to spot. +Pretty-printing formats Markdown for terminal reading. The preview includes +document metadata with `---` delimiters and page comments, and keeps source line +breaks. Reference-style links work across tables. + +Simple HTML tables show all columns, switching to labeled cards when a table is +too wide. A notice explains the switch, and generated row labels use comment +syntax, such as ``. + +When color is enabled, metadata, page comments, and generated row labels are gray. +Headings share a consistent color, tables use a distinct color, and layout notices +are yellow. Keep these limitations in mind: @@ -195,8 +193,8 @@ Keep these limitations in mind: `--pretty` requires a single streamed LLM-input result. It cannot be combined with `--output json`, `--output none`, `--llm-input=false`, `--usage`, `--dry-run`, -`--output-file`, or `--output-dir`. Saved result files, reports, and the default -stdout format are unchanged. To save original Markdown/HTML, omit `--pretty`. +`--output-file`, or `--output-dir`. The flag affects only the streamed preview, +not saved result files or reports. To save Markdown/HTML, omit `--pretty`. ### Complete JSON response diff --git a/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/analyze.go b/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/analyze.go index 1115afbf164..98fec1eaa34 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/analyze.go +++ b/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/analyze.go @@ -40,7 +40,7 @@ func newAnalyzeCommand(extCtx *azdext.ExtensionContext, open sessionFactory) *co Long: "Analyze local files, directories, or HTTPS URLs using Microsoft Entra authentication.\n\n" + "Uses prebuilt-layout when no analyzer is specified or configured.\n" + "Results use YAML front matter and Markdown by default. Batches save source-relative result files.\n" + - "Use --pretty to pretty-print one streamed LLM-input result in the terminal.\n" + + "Use --pretty (-p) to pretty-print one streamed LLM-input result in the terminal.\n" + "Without --pretty, stdout retains the original Markdown and HTML tables, including when redirected.\n" + "Use --output json for complete JSON, or --dry-run to inspect the plan without service calls.", Example: ` azd ai cu analyze invoice.pdf @@ -83,7 +83,7 @@ func newAnalyzeCommand(extCtx *azdext.ExtensionContext, open sessionFactory) *co flags.BoolVar(&options.usage, "usage", false, "Include usage metadata in streamed results or per-input summaries") flags.BoolVar(&options.output.LLMInput, "llm-input", true, "Render YAML front matter and Markdown; set false for complete JSON; true conflicts with --output json") - flags.BoolVar(&options.pretty, "pretty", false, + flags.BoolVarP(&options.pretty, "pretty", "p", false, "Pretty-print one streamed LLM-input result in the terminal (default: original text)") flags.StringVar(&options.output.File, "output-file", "", "Save one result to this file") flags.StringVarP(&options.output.Directory, "output-dir", "d", "", "Save source-relative result files in this directory") diff --git a/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/analyze_render_test.go b/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/analyze_render_test.go index a02bd30f8ec..aa4cb5d53fc 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/analyze_render_test.go +++ b/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/analyze_render_test.go @@ -41,6 +41,9 @@ func TestAnalyzePrettyFlagContract(t *testing.T) { command, _, err := root.Find([]string{"analyze"}) require.NoError(t, err) require.NotNil(t, command.Flags().Lookup("pretty")) + assert.Same(t, command.Flags().Lookup("pretty"), command.Flags().ShorthandLookup("p")) + setHelpColor(t, false) + assert.Contains(t, renderHelp(t, command), "-p, --pretty") assert.Nil(t, command.Flags().Lookup("render")) fixture := newAnalyzeFixture(t) @@ -51,7 +54,9 @@ func TestAnalyzePrettyFlagContract(t *testing.T) { } func TestAnalyzeRenderingIsOptIn(t *testing.T) { - for _, flags := range [][]string{nil, {"--pretty=false"}, {"--pretty"}} { + for _, flags := range [][]string{ + nil, {"--pretty=false"}, {"-p=false"}, {"--pretty"}, {"-p"}, {"--pretty=true"}, {"-p=true"}, + } { t.Run(strings.Join(flags, " "), func(t *testing.T) { fixture := newAnalyzeFixture(t) body := previewResponse(t, fixture) @@ -60,7 +65,7 @@ func TestAnalyzeRenderingIsOptIn(t *testing.T) { assert.Empty(t, stderr) assert.NotContains(t, stdout, "\x1b", "injected/redirected output must not contain ANSI") assert.EqualValues(t, 1, fixture.client.calls.Load()) - if len(flags) != 0 && flags[0] == "--pretty" { + if len(flags) != 0 && !strings.HasSuffix(flags[0], "=false") { assert.NotContains(t, stdout, "Preview only") assert.Contains(t, stdout, "Important") assert.Contains(t, stdout, "Example") @@ -79,31 +84,33 @@ func TestAnalyzeRenderingIsOptIn(t *testing.T) { } func TestAnalyzeRenderRejectsConflictsBeforeAuthentication(t *testing.T) { - for _, flags := range [][]string{ - {"--output", "json"}, {"--output", "none"}, {"--llm-input=false"}, {"--usage"}, {"--dry-run"}, - {"--output-file", "result.md"}, {"--output-dir", "results"}, {"--output-file="}, {"--output-dir="}, - } { - t.Run(strings.Join(flags, " "), func(t *testing.T) { - fixture := newAnalyzeFixture(t) - t.Chdir(testutil.TempDir(t)) - args := append([]string{"https://example.com/doc.pdf", "--pretty"}, flags...) - stdout, stderr, err := fixture.execute(t.Context(), args...) - require.ErrorContains(t, err, "--pretty") - assert.Empty(t, stdout) - assert.Empty(t, stderr) - assert.Zero(t, fixture.opens) - assert.Zero(t, fixture.client.calls.Load()) - entries, err := os.ReadDir(".") - require.NoError(t, err) - assert.Empty(t, entries) - }) + for _, pretty := range []string{"--pretty", "-p"} { + for _, flags := range [][]string{ + {"--output", "json"}, {"--output", "none"}, {"--llm-input=false"}, {"--usage"}, {"--dry-run"}, + {"--output-file", "result.md"}, {"--output-dir", "results"}, {"--output-file="}, {"--output-dir="}, + } { + t.Run(pretty+" "+strings.Join(flags, " "), func(t *testing.T) { + fixture := newAnalyzeFixture(t) + t.Chdir(testutil.TempDir(t)) + args := append([]string{"https://example.com/doc.pdf", pretty}, flags...) + stdout, stderr, err := fixture.execute(t.Context(), args...) + require.ErrorContains(t, err, "--pretty") + assert.Empty(t, stdout) + assert.Empty(t, stderr) + assert.Zero(t, fixture.opens) + assert.Zero(t, fixture.client.calls.Load()) + entries, err := os.ReadDir(".") + require.NoError(t, err) + assert.Empty(t, entries) + }) + } + fixture := newAnalyzeFixture(t) + first, second := schemaFile(t, "first"), schemaFile(t, "second") + _, _, err := fixture.execute(t.Context(), first, second, pretty) + require.ErrorContains(t, err, "batch") + assert.Zero(t, fixture.opens) + assert.Zero(t, fixture.client.calls.Load()) } - fixture := newAnalyzeFixture(t) - first, second := schemaFile(t, "first"), schemaFile(t, "second") - _, _, err := fixture.execute(t.Context(), first, second, "--pretty") - require.ErrorContains(t, err, "batch") - assert.Zero(t, fixture.opens) - assert.Zero(t, fixture.client.calls.Load()) } func TestAnalyzeOriginalFilesAndJSONRemainUnchanged(t *testing.T) { From 1c1dd4455c9741db1f9a82db8314a1802d66879c Mon Sep 17 00:00:00 2001 From: Chien Yuan Chang <186005646+chienyuanchang@users.noreply.github.com> Date: Fri, 6 Nov 2026 14:28:26 -0800 Subject: [PATCH 21/22] fix(ai-cu): filter semicolonless encoded terminal controls Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../internal/preview/render_test.go | 47 +++++++++++++++++++ .../internal/preview/safety.go | 2 +- 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/cli/azd/extensions/azure.ai.content-understanding/internal/preview/render_test.go b/cli/azd/extensions/azure.ai.content-understanding/internal/preview/render_test.go index 9a0e02f15ff..597b332d8e1 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/internal/preview/render_test.go +++ b/cli/azd/extensions/azure.ai.content-understanding/internal/preview/render_test.go @@ -7,6 +7,7 @@ import ( "bytes" "context" "errors" + "fmt" "strconv" "strings" "testing" @@ -300,6 +301,12 @@ func TestSafetyPreservesUnrelatedEscapesAndMarkdownDelimiters(t *testing.T) { "<table> &", "A & B", "A &amp; B", + "A & B", + "A © B", + "A A B", + "A A Z", + "A &#27 B", + "A &#x1b Z", "# Title\n\n**Important** and *emphasis*.", "中文、日本語、한글 😀", "https://example.com/doc?page=2#section", @@ -321,9 +328,17 @@ func TestSafetyNormalizesNewlinesAndRemovesControls(t *testing.T) { {"ansi-color", "\x1b[31mRed\x1b[0m", "Red"}, {"decimal-entity", "AB", "AB"}, {"hex-entity", "AB", "AB"}, + {"decimal-entity-no-semicolon", "AB", "AB"}, + {"hex-entity-no-semicolon", "AZ", "AZ"}, + {"decimal-entity-at-end", "A", "A"}, + {"hex-entity-at-end", "A", "A"}, + {"hex-entity-uppercase", "AZ", "AZ"}, + {"hex-entity-longest-match", "Aƻ", "Aƻ"}, + {"decimal-entity-longest-match", "AĎB", "AĎB"}, {"encoded-newline-and-tab", "A B", "A B"}, {"bidi-controls", "中文\u202eABC\u202c\u2066日本語\u2069", "中文ABC日本語"}, {"encoded-bidi", "A‮B‬", "AB"}, + {"encoded-bidi-no-semicolon", "A‮Z‬", "AZ"}, } { t.Run(sample.name, func(t *testing.T) { actual := safeText(sample.source) @@ -333,6 +348,38 @@ func TestSafetyNormalizesNewlinesAndRemovesControls(t *testing.T) { } } +func TestPreviewFiltersSemicolonlessControlsBeforeRendering(t *testing.T) { + t.Setenv("NO_COLOR", "") + for _, encoded := range []string{"", "", "", "‮", "‮"} { + for _, sample := range []struct { + name, template string + width int + }{ + {"markdown", "Before %s[31mAfter\n", 120}, + {"metadata", "---\nmimeType: application/pdf\nnote: %s[31mAfter\n---\n", 120}, + {"comment", "\n", 120}, + {"table", "" + + "
%s[31mCaption
Name
Before %s[31mAfter
\n", 120}, + {"cards", "
Name
%s[31m" + + "RECORD0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ
\n", 20}, + {"fallback", "" + + "
Name
Before %s[31mAfter
\n", 120}, + } { + for _, color := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/%s/color=%t", encoded, sample.name, color), func(t *testing.T) { + source := strings.ReplaceAll(sample.template, "%s", encoded) + baseline := strings.ReplaceAll(sample.template, "%s", "") + var actual, expected bytes.Buffer + require.NoError(t, Render(t.Context(), &actual, source, sample.width, color)) + require.NoError(t, Render(t.Context(), &expected, baseline, sample.width, color)) + assert.Equal(t, expected.String(), actual.String(), + "encoded controls must not introduce bytes beyond the renderer's own styling") + }) + } + } + } +} + func TestPrettyDecodesEntitiesOnce(t *testing.T) { for _, sample := range []struct { source, expected string diff --git a/cli/azd/extensions/azure.ai.content-understanding/internal/preview/safety.go b/cli/azd/extensions/azure.ai.content-understanding/internal/preview/safety.go index f1b547956bc..330177b5350 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/internal/preview/safety.go +++ b/cli/azd/extensions/azure.ai.content-understanding/internal/preview/safety.go @@ -12,7 +12,7 @@ import ( "golang.org/x/net/html" ) -var entity = regexp.MustCompile(`&(?:#[0-9]+|#[xX][0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]+);`) +var entity = regexp.MustCompile(`&(?:#[0-9]+|#[xX][0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]+);?`) func safeText(source string) string { clean := func(value string) string { From 3b2c1cad7021d95170bc8459ffc8ddac126cf955 Mon Sep 17 00:00:00 2001 From: Chien Yuan Chang <186005646+chienyuanchang@users.noreply.github.com> Date: Fri, 6 Nov 2026 16:13:18 -0800 Subject: [PATCH 22/22] fix(ai-cu): render empty cells and headerless preview tables Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../docs/usage.md | 9 +- .../internal/preview/render.go | 202 +++++++++++++----- .../internal/preview/render_test.go | 171 ++++++++++++++- 3 files changed, 326 insertions(+), 56 deletions(-) diff --git a/cli/azd/extensions/azure.ai.content-understanding/docs/usage.md b/cli/azd/extensions/azure.ai.content-understanding/docs/usage.md index 2de13f20975..d218f20c5b1 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/docs/usage.md +++ b/cli/azd/extensions/azure.ai.content-understanding/docs/usage.md @@ -173,9 +173,8 @@ Pretty-printing formats Markdown for terminal reading. The preview includes document metadata with `---` delimiters and page comments, and keeps source line breaks. Reference-style links work across tables. -Simple HTML tables show all columns, switching to labeled cards when a table is -too wide. A notice explains the switch, and generated row labels use comment -syntax, such as ``. +HTML tables display all columns and switch to labeled cards when they are too wide +for the terminal. When color is enabled, metadata, page comments, and generated row labels are gray. Headings share a consistent color, tables use a distinct color, and layout notices @@ -183,8 +182,8 @@ are yellow. Keep these limitations in mind: -- Complex HTML tables, such as those with merged cells or formatted cell content, - show a notice and their source instead of a formatted table. +- Unsupported tables, such as those with merged cells or complex headers, display + an explanatory notice followed by their source HTML. - Long values can overflow, and some Unicode text or emoji may not align correctly. Right-to-left text display depends on your terminal. Markdown tables may shorten long links. diff --git a/cli/azd/extensions/azure.ai.content-understanding/internal/preview/render.go b/cli/azd/extensions/azure.ai.content-understanding/internal/preview/render.go index e0dab3d04d7..38460a06d8b 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/internal/preview/render.go +++ b/cli/azd/extensions/azure.ai.content-understanding/internal/preview/render.go @@ -11,7 +11,6 @@ import ( "io" "os" "slices" - "strconv" "strings" "github.com/azure/azure-dev/cli/azd/pkg/output" @@ -120,7 +119,11 @@ func Render(ctx context.Context, writer io.Writer, source string, width int, col if !isTable && !isComment { return ast.WalkSkipChildren, nil } - blocks = append(blocks, previewBlock{start: start, end: end, table: isTable}) + if isTable { + blocks = append(blocks, tableBlocks(raw, start)...) + } else { + blocks = append(blocks, previewBlock{start: start, end: end}) + } return ast.WalkSkipChildren, nil }) if err != nil { @@ -206,6 +209,45 @@ func Render(ctx context.Context, writer io.Writer, source string, width int, col return nil } +// tableBlocks separates tables from following footnotes within the same Markdown HTML block. +func tableBlocks(source string, offset int) []previewBlock { + var blocks []previewBlock + tokenizer := html.NewTokenizer(strings.NewReader(source)) + position, start, depth := offset, offset, 0 + for { + kind := tokenizer.Next() + if kind == html.ErrorToken { + break + } + tokenStart := position + position += len(tokenizer.Raw()) + token := tokenizer.Token() + switch { + case (kind == html.StartTagToken || kind == html.SelfClosingTagToken) && token.Data == "table": + if depth == 0 { + start = tokenStart + } + depth++ + case kind == html.EndTagToken && token.Data == "table" && depth > 0: + depth-- + if depth == 0 { + blocks = append(blocks, previewBlock{start: start, end: position, table: true}) + } + case kind == html.CommentToken && depth == 0: + blocks = append(blocks, previewBlock{start: tokenStart, end: position}) + case depth == 0 && kind != html.TextToken: + return []previewBlock{{start: offset, end: offset + len(source), table: true}} + } + } + if depth > 0 { + blocks = append(blocks, previewBlock{start: start, end: offset + len(source), table: true}) + } + if len(blocks) == 0 { + return []previewBlock{{start: offset, end: offset + len(source), table: true}} + } + return blocks +} + func parseTable(source string) (table, string, error) { result := table{} nodes, err := html.ParseFragment(strings.NewReader(source), @@ -226,6 +268,7 @@ func parseTable(source string) (table, string, error) { if root == nil { return result, "expected one standalone table", nil } + columnCount := 0 var visit func(*html.Node) string visit = func(node *html.Node) string { if node.Type == html.TextNode && strings.TrimSpace(node.Data) == "" { @@ -243,20 +286,25 @@ func parseTable(source string) (table, string, error) { } case "caption": value, ok := cellText(node) - if !ok || result.headers != nil || result.caption != "" { + if !ok || columnCount != 0 || result.caption != "" { return "unsupported caption" } result.caption = value case "tr": var values []string - header := result.headers == nil + header := false for cell := node.FirstChild; cell != nil; cell = cell.NextSibling { if cell.Type == html.TextNode && strings.TrimSpace(cell.Data) == "" { continue } - if cell.Type != html.ElementNode || - (header && cell.Data != "th") || (!header && cell.Data != "td") { - return "expected one header row followed by data rows" + if cell.Type != html.ElementNode || (cell.Data != "th" && cell.Data != "td") { + return "unsupported row structure" + } + if len(values) == 0 { + header = cell.Data == "th" + } + if (cell.Data == "th") != header || (header && columnCount != 0) { + return "multiple or mixed header rows are unsupported" } for _, attribute := range cell.Attr { if (attribute.Key != "rowspan" && attribute.Key != "colspan") || attribute.Val != "1" { @@ -264,27 +312,22 @@ func parseTable(source string) (table, string, error) { } } value, ok := cellText(cell) - if !ok || value == "" { - return "rich or empty cells are unsupported" + if !ok { + return "unsupported cell content" } values = append(values, value) } if len(values) == 0 { return "empty row" } + if columnCount == 0 { + columnCount = len(values) + } else if len(values) != columnCount { + return "inconsistent column counts" + } if header { - seen := map[string]bool{} - for _, value := range values { - if seen[value] { - return "duplicate headings" - } - seen[value] = true - } result.headers = values } else { - if len(values) != len(result.headers) { - return "inconsistent column counts" - } result.rows = append(result.rows, values) } default: @@ -293,8 +336,8 @@ func parseTable(source string) (table, string, error) { return "" } reason := visit(root) - if reason == "" && (len(result.headers) == 0 || len(result.rows) == 0) { - reason = "expected headers and at least one data row" + if reason == "" && columnCount == 0 { + reason = "table has no cells" } return result, reason, nil } @@ -311,40 +354,103 @@ func cellText(node *html.Node) (string, bool) { } func renderTable(value table, width int) (string, bool, error) { - columns := make([]output.PrettyColumn, len(value.headers)) + uniqueHeaders := len(value.headers) != 0 + seen := map[string]bool{} + for _, heading := range value.headers { + if heading == "" || seen[heading] { + uniqueHeaders = false + } + seen[heading] = true + } + var rendered string + if uniqueHeaders { + columns := make([]output.PrettyColumn, len(value.headers)) + for index, heading := range value.headers { + columns[index] = output.PrettyColumn{ + Column: output.Column{Heading: heading, ValueTemplate: fmt.Sprintf("{{index . %d}}", index)}, Priority: 1, + } + } + formatter := output.PrettyTableFormatter{ConsoleWidthFn: func() int { return width }} + // Document values must not inherit list-command column omission or truncation. + options := output.PrettyTableFormatterOptions{Columns: columns, FullThreshold: 1, CompactThreshold: 1} + var result bytes.Buffer + if err := formatter.Format(value.rows, &result, options); err != nil { + return "", false, fmt.Errorf("render table: %w", err) + } + rendered = result.String() + } else { + // The shared list formatter requires unique headings; document columns are positional. + rendered = renderPositionalTable(value) + } + for line := range strings.SplitSeq(rendered, "\n") { + if ansi.StringWidth(line) > width && len(value.rows) > 0 { + return renderTableCards(value, uniqueHeaders), true, nil + } + } + return rendered, false, nil +} + +func renderPositionalTable(value table) string { + columnCount := len(value.headers) + if columnCount == 0 { + columnCount = len(value.rows[0]) + } + widths := make([]int, columnCount) for index, heading := range value.headers { - columns[index] = output.PrettyColumn{ - Column: output.Column{Heading: heading, ValueTemplate: fmt.Sprintf("{{index . %d}}", index+1)}, Priority: 1, + widths[index] = ansi.StringWidth(heading) + } + for _, row := range value.rows { + for index, cell := range row { + widths[index] = max(widths[index], ansi.StringWidth(cell)) } } - rows := make([][]string, len(value.rows)) - for index, row := range value.rows { - rows[index] = append([]string{strconv.Itoa(index + 1)}, row...) + var result strings.Builder + writeRow := func(row []string) { + for index, cell := range row { + result.WriteString(cell) + if index < columnCount-1 { + result.WriteString(strings.Repeat(" ", widths[index]-ansi.StringWidth(cell)+3)) + } + } + result.WriteByte('\n') } - formatter := output.PrettyTableFormatter{ConsoleWidthFn: func() int { return width }} - // Document values must not inherit list-command column omission or truncation. - options := output.PrettyTableFormatterOptions{Columns: columns, FullThreshold: 1, CompactThreshold: 1} - var result bytes.Buffer - if err := formatter.Format(rows, &result, options); err != nil { - return "", false, fmt.Errorf("render table: %w", err) + if value.headers != nil { + writeRow(value.headers) + lineWidth := 3 * (columnCount - 1) + for _, columnWidth := range widths { + lineWidth += columnWidth + } + result.WriteString(strings.Repeat("─", lineWidth) + "\n") } - for line := range strings.SplitSeq(result.String(), "\n") { - if ansi.StringWidth(line) <= width { - continue + for _, row := range value.rows { + writeRow(row) + } + return result.String() +} + +func renderTableCards(value table, uniqueHeaders bool) string { + columnCount := len(value.rows[0]) + labels := make([]string, columnCount) + maxLabelLen := 0 + for index := range columnCount { + if value.headers != nil { + labels[index] = value.headers[index] } - heading := "Row" - for slices.Contains(value.headers, heading) { - heading = "_" + heading + if !uniqueHeaders { + labels[index] = strings.TrimSpace(fmt.Sprintf(" %s", index+1, labels[index])) + } + maxLabelLen = max(maxLabelLen, len(labels[index])) + } + var result strings.Builder + for index, row := range value.rows { + if index > 0 { + result.WriteByte('\n') } - options.ForceCards = true - options.Columns = append([]output.PrettyColumn{{ - Column: output.Column{Heading: heading, ValueTemplate: ""}, CardTitle: true, - }}, columns...) - result.Reset() - if err := formatter.Format(rows, &result, options); err != nil { - return "", false, fmt.Errorf("render cards: %w", err) + fmt.Fprintf(&result, "\n", index+1) + for column, cell := range row { + fmt.Fprintf(&result, "%s:%s%s\n", labels[column], + strings.Repeat(" ", maxLabelLen-len(labels[column])+2), cell) } - break } - return result.String(), options.ForceCards, nil + return result.String() } diff --git a/cli/azd/extensions/azure.ai.content-understanding/internal/preview/render_test.go b/cli/azd/extensions/azure.ai.content-understanding/internal/preview/render_test.go index 597b332d8e1..86776fa41c4 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/internal/preview/render_test.go +++ b/cli/azd/extensions/azure.ai.content-understanding/internal/preview/render_test.go @@ -220,10 +220,11 @@ func TestUnsupportedTablesPreserveSource(t *testing.T) { for _, source := range []string{ "
AB
Merged
", "
A
Rich
", - "
A
", - "
AA
12
", "
A
Nested
", - "
Not a heading
", + "
AB
Missing column
", + "
A
Another header
Data
", + "
AMixed
12
", + "
A
Late header
", } { t.Run(source, func(t *testing.T) { result := renderPreview(t, source+"\n", 80) @@ -233,6 +234,170 @@ func TestUnsupportedTablesPreserveSource(t *testing.T) { } } +func TestOrdinaryHTMLTablesPreserveCellPositions(t *testing.T) { + for _, sample := range []struct { + name, source string + headers []string + rows [][]string + }{ + {"empty cells", "AB" + + "SecondFirst", + []string{"A", "B"}, [][]string{{"", "Second"}, {"First", ""}, {"", ""}}}, + {"headerless", "SUBTOTAL$100.00AMOUNT DUE$110.00", + nil, [][]string{{"SUBTOTAL", "$100.00"}, {"AMOUNT DUE", "$110.00"}}}, + {"single headerless row", "Only value", nil, [][]string{{"Only value"}}}, + {"blank and duplicate headers", "ValueValueValue (column 2)" + + "FirstSecondThirdFourth", + []string{"", "Value", "Value", "Value (column 2)"}, [][]string{{"First", "Second", "Third", "Fourth"}}}, + {"unit spans", "AFirst", + []string{"A"}, [][]string{{"First"}}}, + {"header only", "A", []string{"A", ""}, nil}, + } { + t.Run(sample.name, func(t *testing.T) { + source := "" + sample.source + "
Original caption
\n\nFootnote.\n" + parsed, reason, err := parseTable("" + sample.source + "
") + require.NoError(t, err) + require.Empty(t, reason) + assert.Equal(t, sample.headers, parsed.headers) + assert.Equal(t, sample.rows, parsed.rows) + for _, width := range []int{1, 200} { + t.Run(strconv.Itoa(width), func(t *testing.T) { + plain := renderPreview(t, source, width) + assert.NotContains(t, plain, "HTML table not rendered") + assert.NotContains(t, plain, "") + assert.Contains(t, plain, "Original caption") + assert.Contains(t, plain, "Footnote.") + for _, row := range sample.rows { + for _, cell := range row { + if cell != "" { + assert.Equal(t, 1, strings.Count(plain, cell), cell) + } + } + } + var colored bytes.Buffer + require.NoError(t, Render(t.Context(), &colored, source, width, true)) + assert.Equal(t, plain, ansi.Strip(colored.String())) + if width == 200 { + assert.NotContains(t, plain, "" + for _, source := range []string{ + table + "\nFootnote immediately after table.\n", + table + "\n" + comment + "\nFootnote immediately after table.\n", + table + "\n" + table + "\nFootnote immediately after table.\n", + } { + result := renderPreview(t, source, 120) + assert.NotContains(t, result, "HTML table not rendered") + assert.NotContains(t, result, "") + assert.Contains(t, result, "Footnote immediately after table.") + assert.Equal(t, strings.Count(source, "SUBTOTAL"), strings.Count(result, "SUBTOTAL")) + if strings.Contains(source, comment) { + assert.Contains(t, result, comment) + } + } +} + +func TestTableFallbackReasons(t *testing.T) { + for _, sample := range []struct { + name, source, reason string + }{ + {"rowspan", "ABC", "merged cells"}, + {"colspan", "ABC", "merged cells"}, + {"multiple headers", "AB", "multiple or mixed header rows"}, + {"mixed headers", "AB", "multiple or mixed header rows"}, + {"headed column mismatch", "ABC", "inconsistent column counts"}, + {"headerless column mismatch", "ABC", "inconsistent column counts"}, + {"empty table", "", "table has no cells"}, + {"empty row", "", "empty row"}, + {"unknown content", "A", "unsupported cell content"}, + } { + t.Run(sample.name, func(t *testing.T) { + source := "" + sample.source + "
" + result := renderPreview(t, source+"\n", 120) + assert.Contains(t, result, "HTML table not rendered: "+sample.reason) + assert.Contains(t, result, source) + }) + } +} + +func TestCardsKeepEmptyFieldsAndIdentifyAmbiguousColumns(t *testing.T) { + for _, sample := range []struct { + name, source string + fields []string + }{ + {"empty fields", "ABValue" + + "", + []string{"", "A: \n", "B: Value", "", "B: \n"}}, + {"headerless fields", "First", + []string{"", ": First", ": \n"}}, + {"ambiguous headings", "ValueValueValue (column 2)" + + "FirstSecondThirdFourth", + []string{"", " Value", " Value", + "Value (column 2)", "First", "Second", "Third", "Fourth"}}, + } { + t.Run(sample.name, func(t *testing.T) { + result := renderPreview(t, ""+sample.source+"
\n", 1) + assert.Contains(t, result, "Table exceeds terminal width") + assert.NotContains(t, result, "HTML table not rendered") + for _, field := range sample.fields { + assert.Contains(t, result, field) + } + }) + } +} + func TestCodeFenceAndNativeMarkdownTablesStayWithGlamour(t *testing.T) { source := "```html\n
A
B
\n```\n\n" + "| Name | Value |\n| --- | --- |\n| Native | 42 |\n"