diff --git a/README.md b/README.md index e34aba872..cd0ef3cb1 100644 --- a/README.md +++ b/README.md @@ -673,15 +673,19 @@ _Context: global_ Use this command to update package documentation using an AI agent or to get manual instructions for update. -The AI agent supports two modes: -1. Rewrite mode (default): Full documentation regeneration +The AI agent supports three modes (--mode flag): +1. rewrite (default): Full documentation regeneration - Analyzes your package structure, data streams, and configuration - Generates comprehensive documentation following Elastic's templates - Creates or updates markdown files in /_dev/build/docs/ -2. Modify mode: Targeted documentation changes +2. modify: Targeted documentation changes - Makes specific changes to existing documentation - Requires existing documentation file at /_dev/build/docs/ - - Use --modify-prompt flag for non-interactive modifications + - Use --modify-prompt for non-interactive modifications +3. reformat: Reorganize existing content + - Reorganizes existing documentation to match template structure + - Only moves content between sections, does not modify or add content + - Adds TODO placeholders for empty sections Multi-file support: - Use --doc-file to specify which markdown file to update (defaults to README.md) @@ -689,22 +693,19 @@ Multi-file support: - Supports packages with multiple documentation files (e.g., README.md, vpc.md, etc.) Interactive workflow: -After confirming you want to use the AI agent, you'll choose between rewrite or modify mode. +After confirming you want to use the AI agent, you'll choose between rewrite, modify, or reformat mode. You can review results and request additional changes iteratively. Non-interactive mode: Use --non-interactive to skip all prompts and automatically accept the first result from the LLM. -Combine with --modify-prompt "instructions" for targeted non-interactive changes. +Combine with --mode=modify --modify-prompt "instructions" for targeted non-interactive changes. +Use --mode=reformat for non-interactive reformatting. If no LLM provider is configured, this command will print instructions for updating the documentation manually. Configuration options for LLM providers (environment variables or profile config): - GEMINI_API_KEY / llm.gemini.api_key: API key for Gemini -- GEMINI_MODEL / llm.gemini.model: Model ID (defaults to gemini-2.5-pro) -- LOCAL_LLM_ENDPOINT / llm.local.endpoint: Endpoint for local LLM server -- LOCAL_LLM_MODEL / llm.local.model: Model name for local LLM (defaults to llama2) -- LOCAL_LLM_API_KEY / llm.local.api_key: API key for local LLM (optional) -- LLM_EXTERNAL_PROMPTS / llm.external_prompts: Enable external prompt files (defaults to false). +- GEMINI_MODEL / llm.gemini.model: Model ID (defaults to gemini-2.5-pro). ### `elastic-package version` @@ -769,30 +770,45 @@ When using AI-powered documentation generation, **file content from your local f #### Operation Modes -The command supports two modes of operation: +The command supports three modes of operation, selected via the `--mode` flag: -1. **Rewrite Mode** (default): Full documentation regeneration +1. **rewrite** (default): Full documentation regeneration - Analyzes your package structure, data streams, and configuration - Generates comprehensive documentation following Elastic's templates - - Creates or updates the README.md file in `/_dev/build/docs/` + - Creates or updates markdown files in `/_dev/build/docs/` -2. **Modify Mode**: Targeted documentation changes +2. **modify**: Targeted documentation changes - Makes specific changes to existing documentation - - Requires existing README.md file at `/_dev/build/docs/README.md` - - Use `--modify-prompt` flag for non-interactive modifications + - Requires existing documentation file at `/_dev/build/docs/` + - Use `--modify-prompt` for non-interactive modifications + +3. **reformat**: Reorganize existing content + - Reorganizes existing documentation to match template structure + - Only moves content between sections, does not modify or add content + - Adds TODO placeholders for empty sections + +#### Command Flags + +| Flag | Description | +|------|-------------| +| `--mode` | Documentation update mode: `rewrite` (default), `modify`, or `reformat` | +| `--modify-prompt` | Modification instructions for modify mode (implies `--mode=modify` if mode not set) | +| `--doc-file` | Specify which markdown file to update (e.g., `README.md`, `vpc.md`). Defaults to `README.md` | +| `--non-interactive` | Skip all prompts and automatically accept the first result from the LLM | +| `--profile`, `-p` | Use a specific elastic-package profile with LLM configuration | #### Workflow Options **Interactive Mode** (default): The command will guide you through the process, allowing you to: -- Choose between rewrite or modify mode +- Choose between rewrite, modify, or reformat mode +- Select which documentation file to update (if multiple exist) - Review generated documentation - Request iterative changes - Accept or cancel the update **Non-Interactive Mode**: Use `--non-interactive` to skip all prompts and automatically accept the first result. -Combine with `--modify-prompt "instructions"` for targeted non-interactive changes. If no LLM provider is configured, the command will print manual instructions for updating documentation. @@ -812,18 +828,23 @@ Environment variables (e.g., `GEMINI_API_KEY`, `LOCAL_LLM_ENDPOINT`) take preced #### Usage Examples ```bash -# Interactive documentation update (rewrite mode) -elastic-package update documentation - -# Interactive modification mode +# Interactive documentation update (prompts for mode selection) elastic-package update documentation -# (choose "Modify" when prompted) -# Non-interactive rewrite +# Non-interactive rewrite (default mode) elastic-package update documentation --non-interactive -# Non-interactive targeted changes -elastic-package update documentation --modify-prompt "Add more details about authentication configuration" +# Non-interactive modify with instructions +elastic-package update documentation --non-interactive --mode=modify --modify-prompt "Add troubleshooting section" + +# Shorthand for modify (--modify-prompt implies --mode=modify) +elastic-package update documentation --non-interactive --modify-prompt "Fix typos in setup instructions" + +# Non-interactive reformat +elastic-package update documentation --non-interactive --mode=reformat + +# Update a specific documentation file +elastic-package update documentation --doc-file vpc.md # Use specific profile with LLM configuration elastic-package update documentation --profile production diff --git a/cmd/update.go b/cmd/update.go index 20ef00c46..61f7cf87d 100644 --- a/cmd/update.go +++ b/cmd/update.go @@ -26,7 +26,8 @@ func setupUpdateCommand() *cobraext.Command { RunE: updateDocumentationCommandAction, } updateDocumentationCmd.Flags().Bool("non-interactive", false, "run in non-interactive mode, accepting the first result from the LLM") - updateDocumentationCmd.Flags().String("modify-prompt", "", "modification instructions for targeted documentation changes (skips full rewrite)") + updateDocumentationCmd.Flags().String("mode", "", "documentation update mode: rewrite (default), modify, or reformat") + updateDocumentationCmd.Flags().String("modify-prompt", "", "modification instructions for modify mode (implies --mode=modify if mode not set)") updateDocumentationCmd.Flags().String("doc-file", "", "specify which markdown file to update (e.g., README.md, vpc.md). Defaults to README.md") cmd := &cobra.Command{ diff --git a/cmd/update_documentation.go b/cmd/update_documentation.go index e4fa31b87..1b1d65c7b 100644 --- a/cmd/update_documentation.go +++ b/cmd/update_documentation.go @@ -22,15 +22,19 @@ import ( const updateDocumentationLongDescription = `Use this command to update package documentation using an AI agent or to get manual instructions for update. -The AI agent supports two modes: -1. Rewrite mode (default): Full documentation regeneration +The AI agent supports three modes (--mode flag): +1. rewrite (default): Full documentation regeneration - Analyzes your package structure, data streams, and configuration - Generates comprehensive documentation following Elastic's templates - Creates or updates markdown files in /_dev/build/docs/ -2. Modify mode: Targeted documentation changes +2. modify: Targeted documentation changes - Makes specific changes to existing documentation - Requires existing documentation file at /_dev/build/docs/ - - Use --modify-prompt flag for non-interactive modifications + - Use --modify-prompt for non-interactive modifications +3. reformat: Reorganize existing content + - Reorganizes existing documentation to match template structure + - Only moves content between sections, does not modify or add content + - Adds TODO placeholders for empty sections Multi-file support: - Use --doc-file to specify which markdown file to update (defaults to README.md) @@ -38,12 +42,13 @@ Multi-file support: - Supports packages with multiple documentation files (e.g., README.md, vpc.md, etc.) Interactive workflow: -After confirming you want to use the AI agent, you'll choose between rewrite or modify mode. +After confirming you want to use the AI agent, you'll choose between rewrite, modify, or reformat mode. You can review results and request additional changes iteratively. Non-interactive mode: Use --non-interactive to skip all prompts and automatically accept the first result from the LLM. -Combine with --modify-prompt "instructions" for targeted non-interactive changes. +Combine with --mode=modify --modify-prompt "instructions" for targeted non-interactive changes. +Use --mode=reformat for non-interactive reformatting. If no LLM provider is configured, this command will print instructions for updating the documentation manually. @@ -52,10 +57,88 @@ Configuration options for LLM providers (environment variables or profile config - GEMINI_MODEL / llm.gemini.model: Model ID (defaults to gemini-2.5-pro)` const ( - modePromptRewrite = "Rewrite (full regeneration)" - modePromptModify = "Modify (targeted changes)" + modeRewrite = "rewrite" + modeModify = "modify" + modeReformat = "reformat" ) +var ( + validModes = []string{modeRewrite, modeModify, modeReformat} + modeLabels = map[string]string{ + modeRewrite: "Rewrite (full regeneration)", + modeModify: "Modify (targeted changes)", + modeReformat: "Reformat (reorganize structure)", + } +) + +// labelToMode converts a display label to its mode value +func labelToMode(label string) string { + for mode, l := range modeLabels { + if l == label { + return mode + } + } + return modeRewrite +} + +// modeLabelsSlice returns the display labels in order for UI selection +func modeLabelsSlice() []string { + return []string{modeLabels[modeRewrite], modeLabels[modeModify], modeLabels[modeReformat]} +} + +// isValidMode checks if a mode string is valid +func isValidMode(mode string) bool { + for _, m := range validModes { + if m == mode { + return true + } + } + return false +} + +// determineMode determines the mode from flags or prompts the user +func determineMode(cmd *cobra.Command, nonInteractive bool) (string, error) { + modeFlag, err := cmd.Flags().GetString("mode") + if err != nil { + return "", fmt.Errorf("failed to get mode flag: %w", err) + } + + modifyPrompt, err := cmd.Flags().GetString("modify-prompt") + if err != nil { + return "", fmt.Errorf("failed to get modify-prompt flag: %w", err) + } + + // If mode flag is explicitly set, validate and use it + if modeFlag != "" { + if !isValidMode(modeFlag) { + return "", fmt.Errorf("invalid mode %q: must be one of %v", modeFlag, validModes) + } + return modeFlag, nil + } + + // If modify-prompt is provided without mode, infer modify mode + if modifyPrompt != "" { + return modeModify, nil + } + + // In non-interactive mode, default to rewrite + if nonInteractive { + return modeRewrite, nil + } + + // Interactive mode: prompt user to choose + modePrompt := tui.NewSelect("What would you like to do with the documentation?", + modeLabelsSlice(), modeLabels[modeRewrite]) + + var selectedLabel string + err = tui.AskOne(modePrompt, &selectedLabel) + if err != nil { + return "", fmt.Errorf("mode selection failed: %w", err) + } + + return labelToMode(selectedLabel), nil +} + // getConfigValue retrieves a configuration value with fallback from environment variable to profile config func getConfigValue(profile *profile.Profile, envVar, configKey, defaultValue string) string { // First check environment variable @@ -180,27 +263,17 @@ func updateDocumentationCommandAction(cmd *cobra.Command, args []string) error { return fmt.Errorf("locating package root failed: %w", err) } - // Check for non-interactive flag nonInteractive, err := cmd.Flags().GetBool("non-interactive") if err != nil { return fmt.Errorf("failed to get non-interactive flag: %w", err) } - // Check for modify-prompt flag - modifyPrompt, err := cmd.Flags().GetString("modify-prompt") - if err != nil { - return fmt.Errorf("failed to get modify-prompt flag: %w", err) - } - - // Get profile for configuration access profile, err := cobraext.GetProfileFlag(cmd) if err != nil { return fmt.Errorf("failed to get profile: %w", err) } - // Get Gemini configuration apiKey, modelID := getGeminiConfig(profile) - if apiKey == "" { printNoProviderInstructions(cmd) return nil @@ -208,65 +281,44 @@ func updateDocumentationCommandAction(cmd *cobra.Command, args []string) error { cmd.Printf("Using Gemini provider with model: %s\n", modelID) - // Select which documentation file to update + // Select documentation file targetDocFile, err := selectDocumentationFile(cmd, packageRoot, nonInteractive) if err != nil { return fmt.Errorf("failed to select documentation file: %w", err) } - if !nonInteractive && targetDocFile != "README.md" { cmd.Printf("Selected documentation file: %s\n", targetDocFile) } - // Determine the mode based on user input - var useModifyMode bool - - // Skip confirmation prompt in non-interactive mode - if !nonInteractive { - // Prompt user for confirmation + // Handle confirmation and mode selection + if nonInteractive { + cmd.Println("Running in non-interactive mode - proceeding automatically.") + } else { confirmPrompt := tui.NewConfirm("Do you want to update the documentation using the AI agent?", true) - var confirm bool - err = tui.AskOne(confirmPrompt, &confirm, tui.Required) - if err != nil { + if err = tui.AskOne(confirmPrompt, &confirm, tui.Required); err != nil { return fmt.Errorf("prompt failed: %w", err) } - if !confirm { cmd.Println("Documentation update cancelled.") return nil } + } - // If no modify-prompt flag was provided, ask user to choose mode - if modifyPrompt == "" { - modePrompt := tui.NewSelect("Do you want to rewrite or modify the documentation?", []string{ - modePromptRewrite, - modePromptModify, - }, modePromptRewrite) - - var mode string - err = tui.AskOne(modePrompt, &mode) - if err != nil { - return fmt.Errorf("prompt failed: %w", err) - } - - useModifyMode = mode == "Modify (targeted changes)" - } else { - useModifyMode = true - } - } else { - cmd.Println("Running in non-interactive mode - proceeding automatically.") - useModifyMode = modifyPrompt != "" + // Determine mode + selectedMode, err := determineMode(cmd, nonInteractive) + if err != nil { + return err } - // Find repository root for file operations + // Find repository root repositoryRoot, err := files.FindRepositoryRootFrom(packageRoot) if err != nil { return fmt.Errorf("failed to find repository root: %w", err) } defer repositoryRoot.Close() - // Create the documentation agent using ADK + // Create agent docAgent, err := docagent.NewDocumentationAgent(cmd.Context(), docagent.AgentConfig{ APIKey: apiKey, ModelID: modelID, @@ -279,15 +331,19 @@ func updateDocumentationCommandAction(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to create documentation agent: %w", err) } - // Run the documentation update process based on selected mode - if useModifyMode { - err = docAgent.ModifyDocumentation(cmd.Context(), nonInteractive, modifyPrompt) - if err != nil { + // Execute based on mode + switch selectedMode { + case modeModify: + modifyPrompt, _ := cmd.Flags().GetString("modify-prompt") + if err = docAgent.ModifyDocumentation(cmd.Context(), nonInteractive, modifyPrompt); err != nil { return fmt.Errorf("documentation modification failed: %w", err) } - } else { - err = docAgent.UpdateDocumentation(cmd.Context(), nonInteractive) - if err != nil { + case modeReformat: + if err = docAgent.ReformatDocumentation(cmd.Context(), nonInteractive); err != nil { + return fmt.Errorf("documentation reformat failed: %w", err) + } + default: // modeRewrite + if err = docAgent.UpdateDocumentation(cmd.Context(), nonInteractive); err != nil { return fmt.Errorf("documentation update failed: %w", err) } } diff --git a/internal/llmagent/docagent/_static/reformat_prompt.txt b/internal/llmagent/docagent/_static/reformat_prompt.txt new file mode 100644 index 000000000..f58ee125c --- /dev/null +++ b/internal/llmagent/docagent/_static/reformat_prompt.txt @@ -0,0 +1,17 @@ +Reorganize this documentation to match the template section structure below. + +RULES: +1. ONLY MOVE content - do not modify, rewrite, or add new text +2. PRESERVE all original text exactly as written +3. For empty sections, add: <> +4. Content that doesn't fit any section goes under: # UNKNOWN SECTION CONTENT + +TEMPLATE SECTIONS: +%s + +CURRENT DOCUMENT TO REFORMAT: +--- +%s +--- + +Return ONLY the reformatted document with no explanation. diff --git a/internal/llmagent/docagent/docagent.go b/internal/llmagent/docagent/docagent.go index a443349a8..763bfa26f 100644 --- a/internal/llmagent/docagent/docagent.go +++ b/internal/llmagent/docagent/docagent.go @@ -337,120 +337,90 @@ func (d *DocumentationAgent) ModifyDocumentation(ctx context.Context, nonInterac return nil } -// runNonInteractiveMode handles the non-interactive documentation update flow -func (d *DocumentationAgent) runNonInteractiveMode(ctx context.Context, prompt string) error { - fmt.Println("Starting non-interactive documentation update process...") - fmt.Println("The LLM agent will analyze your package and generate documentation automatically.") - fmt.Println() - - // First attempt - result, err := d.executeTaskWithLogging(ctx, prompt) - if err != nil { - return err +// ReformatDocumentation runs the documentation reformat process using single-call mode. +// This is optimized for efficiency: reads document, makes one LLM call, writes result. +func (d *DocumentationAgent) ReformatDocumentation(ctx context.Context, nonInteractive bool) error { + // Check if documentation file exists + docPath := filepath.Join(d.packageRoot, "_dev", "build", "docs", d.targetDocFile) + if _, err := os.Stat(docPath); err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("cannot reformat documentation: %s does not exist at _dev/build/docs/%s", d.targetDocFile, d.targetDocFile) + } + return fmt.Errorf("failed to check %s: %w", d.targetDocFile, err) } - // Show the result - fmt.Println("\nšŸ“ Agent Response:") - fmt.Println(strings.Repeat("-", 50)) - fmt.Println(result.FinalContent) - fmt.Println(strings.Repeat("-", 50)) - - analysis := d.responseAnalyzer.AnalyzeResponse(result.FinalContent, result.Conversation) + // Backup original README content before making any changes + d.backupOriginalReadme() - switch analysis.Status { - case responseError: - fmt.Println("\nāŒ Error detected in LLM response.") - fmt.Println("In non-interactive mode, exiting due to error.") - return fmt.Errorf("LLM agent encountered an error: %s", result.FinalContent) + // Read current document content directly (no tool call needed) + currentContent, err := d.readCurrentReadme() + if err != nil { + return fmt.Errorf("failed to read current documentation: %w", err) } - // Check if documentation file was successfully updated - if updated, _ := d.handleReadmeUpdate(); updated { - fmt.Printf("\nšŸ“„ %s was updated successfully!\n", d.targetDocFile) - return nil + if strings.TrimSpace(currentContent) == "" { + return fmt.Errorf("current documentation is empty, nothing to reformat") } - // If documentation was not updated, but there was no error response, make another attempt with specific instructions - fmt.Printf("āš ļø %s was not updated. Trying again with specific instructions...\n", d.targetDocFile) - specificPrompt := fmt.Sprintf("You haven't updated the %s file yet. Please write the %s file in the _dev/build/docs/ directory based on your analysis. This is required to complete the task.", d.targetDocFile, d.targetDocFile) + fmt.Println("Starting documentation reformat...") + fmt.Printf("šŸ“„ Document size: %d characters\n", len(currentContent)) - if _, err := d.executeTaskWithLogging(ctx, specificPrompt); err != nil { - return fmt.Errorf("second attempt failed: %w", err) - } - - // Final check - if updated, _ := d.handleReadmeUpdate(); updated { - fmt.Printf("\nšŸ“„ %s was updated on second attempt!\n", d.targetDocFile) - return nil - } + // Build the reformat prompt with document content included + prompt := d.buildReformatPrompt(currentContent) + logger.Debugf("Reformat prompt size: %d characters", len(prompt)) - return fmt.Errorf("failed to create %s after two attempts", d.targetDocFile) -} + fmt.Println("šŸ¤– Sending to LLM...") -// runInteractiveMode handles the interactive documentation update flow -func (d *DocumentationAgent) runInteractiveMode(ctx context.Context, prompt string) error { - fmt.Println("Starting documentation update process...") - fmt.Println("The LLM agent will analyze your package and update the documentation.") - fmt.Println() + // Execute the task using the executor + result, err := d.executor.ExecuteTask(ctx, prompt) + if err != nil { + return fmt.Errorf("LLM call failed: %w", err) + } - for { - select { - case <-ctx.Done(): - return ctx.Err() - default: - } + if result.FinalContent == "" { + return fmt.Errorf("LLM returned empty response") + } - // Execute the task - result, err := d.executeTaskWithLogging(ctx, prompt) - if err != nil { - return err - } + fmt.Println("āœ… LLM response received") + logger.Debugf("Response size: %d characters", len(result.FinalContent)) - analysis := d.responseAnalyzer.AnalyzeResponse(result.FinalContent, result.Conversation) + // Extract the reformatted document from the response + reformattedContent := strings.TrimSpace(result.FinalContent) - switch analysis.Status { - case responseError: - newPrompt, shouldContinue, err := d.handleInteractiveError() - if err != nil { - return err - } - if !shouldContinue { - d.restoreOriginalReadme() - return fmt.Errorf("user chose to exit due to LLM error") - } - prompt = newPrompt - continue - } + // Write the reformatted content + if err := d.writeDocumentation(docPath, reformattedContent); err != nil { + return fmt.Errorf("failed to write reformatted documentation: %w", err) + } - // Display README content if updated - readmeUpdated, err := d.isReadmeUpdated() + // Display result in interactive mode + if !nonInteractive { + err = d.displayReadme() if err != nil { - logger.Debugf("could not determine if readme is updated: %v", err) - } - if readmeUpdated { - err = d.displayReadme() - if err != nil { - // This may be recoverable, only log the error - logger.Debugf("displaying readme: %v", err) - } + logger.Debugf("Could not display readme: %v", err) } - // Get and handle user action + // Get user confirmation action, err := d.getUserAction() if err != nil { return err } - actionResult := d.handleUserAction(action, readmeUpdated) - if actionResult.Err != nil { - return actionResult.Err - } - if actionResult.ShouldContinue { - prompt = actionResult.NewPrompt - continue + + switch action { + case ActionAccept: + fmt.Println("āœ… Documentation reformat completed!") + case ActionCancel: + fmt.Println("āŒ Reformat cancelled, restoring original.") + d.restoreOriginalReadme() + case ActionRequest: + // For reformat, we don't support iterative changes + fmt.Println("āš ļø Reformat mode doesn't support iterative changes. Accept or cancel.") + d.restoreOriginalReadme() } - // If we reach here, should exit - return nil + } else { + fmt.Printf("āœ… %s was reformatted successfully!\n", d.targetDocFile) } + + return nil } // logAgentResponse logs debug information about the agent response @@ -466,22 +436,6 @@ func (d *DocumentationAgent) logAgentResponse(result *TaskResult) { } } -// executeTaskWithLogging executes a task and logs the result -func (d *DocumentationAgent) executeTaskWithLogging(ctx context.Context, prompt string) (*TaskResult, error) { - fmt.Println("šŸ¤– LLM Agent is working...") - - result, err := d.executor.ExecuteTask(ctx, prompt) - if err != nil { - fmt.Println("āŒ Agent task failed") - fmt.Printf("āŒ result is %v\n", result) - return nil, fmt.Errorf("agent task failed: %w", err) - } - - fmt.Println("āœ… Task completed") - d.logAgentResponse(result) - return result, nil -} - // NewResponseAnalyzer creates a new ResponseAnalyzer with default patterns // // These responses should be chosen to represent LLM responses to states, but are unlikely to appear in generated diff --git a/internal/llmagent/docagent/interactive.go b/internal/llmagent/docagent/interactive.go index 1f185f81f..eeb9f97fe 100644 --- a/internal/llmagent/docagent/interactive.go +++ b/internal/llmagent/docagent/interactive.go @@ -108,32 +108,6 @@ func (d *DocumentationAgent) handleReadmeUpdate() (bool, error) { return true, nil } -// handleInteractiveError handles error responses in interactive mode -func (d *DocumentationAgent) handleInteractiveError() (string, bool, error) { - fmt.Println("\nāŒ Error detected in LLM response.") - - errorPrompt := tui.NewSelect("What would you like to do?", []string{ - ActionTryAgain, - ActionExit, - }, ActionTryAgain) - - var errorAction string - err := tui.AskOne(errorPrompt, &errorAction) - if err != nil { - return "", false, fmt.Errorf("prompt failed: %w", err) - } - - if errorAction == ActionExit { - fmt.Println("āš ļø Exiting due to LLM error.") - return "", false, nil - } - - // Continue with retry prompt - promptCtx := d.createPromptContext(d.manifest, "The previous attempt encountered an error. Please try a different approach to analyze the package and update the documentation.") - prompt := d.buildPrompt(PromptTypeRevision, promptCtx) - return prompt, true, nil -} - // handleUserAction processes the user's chosen action func (d *DocumentationAgent) handleUserAction(action string, readmeUpdated bool) ActionResult { switch action { diff --git a/internal/llmagent/docagent/prompts.go b/internal/llmagent/docagent/prompts.go index c5adc2cca..9d70c8465 100644 --- a/internal/llmagent/docagent/prompts.go +++ b/internal/llmagent/docagent/prompts.go @@ -8,11 +8,14 @@ import ( "fmt" "os" "path/filepath" + "regexp" + "strings" "github.com/elastic/elastic-package/internal/configuration/locations" "github.com/elastic/elastic-package/internal/environment" "github.com/elastic/elastic-package/internal/logger" "github.com/elastic/elastic-package/internal/packages" + "github.com/elastic/elastic-package/internal/packages/archetype" "github.com/elastic/elastic-package/internal/profile" ) @@ -22,6 +25,7 @@ const ( promptFileSectionGeneration = "section_generation_prompt.txt" promptFileModificationAnalysis = "modification_analysis_prompt.txt" promptFileModification = "modification_prompt.txt" + promptFileReformat = "reformat_prompt.txt" ) type PromptType int @@ -32,6 +36,7 @@ const ( PromptTypeSectionGeneration PromptTypeModificationAnalysis PromptTypeModification + PromptTypeReformat ) // loadPromptFile loads a prompt file from external location if enabled, otherwise uses embedded content @@ -115,6 +120,10 @@ func (d *DocumentationAgent) buildPrompt(promptType PromptType, ctx PromptContex promptFile = promptFileModification embeddedContent = ModificationPrompt formatArgs = d.buildModificationPromptArgs(ctx) + case PromptTypeReformat: + // Reformat uses a separate method: buildReformatPrompt() + // This case should not be reached in normal flow + return "" } promptContent := loadPromptFile(promptFile, embeddedContent, d.profile) @@ -245,6 +254,18 @@ func (d *DocumentationAgent) buildModificationPromptArgs(ctx PromptContext) []in } } +// buildReformatPrompt builds a single-call reformat prompt with document content included +func (d *DocumentationAgent) buildReformatPrompt(documentContent string) string { + // Get minimal template sections (headers only, no comments) + minimalSections := getMinimalTemplateSections() + + // Load the prompt template + promptContent := loadPromptFile(promptFileReformat, ReformatPrompt, d.profile) + + // Format with minimal sections and document content + return fmt.Sprintf(promptContent, minimalSections, documentContent) +} + // Helper to create context with service info func (d *DocumentationAgent) createPromptContext(manifest *packages.PackageManifest, changes string) PromptContext { return PromptContext{ @@ -253,3 +274,37 @@ func (d *DocumentationAgent) createPromptContext(manifest *packages.PackageManif Changes: changes, } } + +// extractTemplateSections extracts only markdown headers and minimal structure from the template, +// removing Go template comments ({{/* ... */}}) and placeholder content. +// This reduces prompt size by ~60% while preserving the section structure. +func extractTemplateSections(fullTemplate string) string { + // Remove multi-line Go template comments: {{/* ... */}} + commentRegex := regexp.MustCompile(`\{\{/\*[\s\S]*?\*/\}\}`) + stripped := commentRegex.ReplaceAllString(fullTemplate, "") + + // Remove template directives like {{- generatedHeader }} + directiveRegex := regexp.MustCompile(`\{\{[^}]*\}\}`) + stripped = directiveRegex.ReplaceAllString(stripped, "") + + // Process line by line to keep only headers and remove placeholder content + lines := strings.Split(stripped, "\n") + var result []string + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + + // Keep markdown headers + if strings.HasPrefix(trimmed, "#") { + result = append(result, trimmed) + } + } + + return strings.Join(result, "\n") +} + +// getMinimalTemplateSections returns a minimal section list for the reformat prompt +func getMinimalTemplateSections() string { + fullTemplate := archetype.GetPackageDocsReadmeTemplate() + return extractTemplateSections(fullTemplate) +} diff --git a/internal/llmagent/docagent/prompts_test.go b/internal/llmagent/docagent/prompts_test.go index abf82783f..8005e434e 100644 --- a/internal/llmagent/docagent/prompts_test.go +++ b/internal/llmagent/docagent/prompts_test.go @@ -190,3 +190,90 @@ func TestCreatePromptContext(t *testing.T) { assert.Equal(t, "docs/README.md", ctx.TargetDocFile) assert.Equal(t, "test changes", ctx.Changes) } + +func TestExtractTemplateSections(t *testing.T) { + t.Run("removes Go template comments", func(t *testing.T) { + input := `# Title +{{/* This is a comment */}} +## Section One +{{/* Another +multiline +comment */}} +### Subsection` + + result := extractTemplateSections(input) + + assert.Contains(t, result, "# Title") + assert.Contains(t, result, "## Section One") + assert.Contains(t, result, "### Subsection") + assert.NotContains(t, result, "This is a comment") + assert.NotContains(t, result, "multiline") + }) + + t.Run("removes template directives", func(t *testing.T) { + input := `{{- generatedHeader }} +# Title +{{ fields "data_stream" }} +## Section` + + result := extractTemplateSections(input) + + assert.Contains(t, result, "# Title") + assert.Contains(t, result, "## Section") + assert.NotContains(t, result, "generatedHeader") + assert.NotContains(t, result, "fields") + }) + + t.Run("keeps only headers", func(t *testing.T) { + input := `# Main Title +Some description text +## Section One +More text here +### Subsection +Even more text` + + result := extractTemplateSections(input) + + assert.Contains(t, result, "# Main Title") + assert.Contains(t, result, "## Section One") + assert.Contains(t, result, "### Subsection") + assert.NotContains(t, result, "description text") + assert.NotContains(t, result, "More text") + }) + + t.Run("handles real template format", func(t *testing.T) { + input := `{{- generatedHeader }} +{{/* +This template can be used as a starting point +*/}} +# {[.Manifest.Title]} Integration for Elastic + +## Overview +{{/* Complete this section */}} +The integration enables... + +### Compatibility +This is compatible with...` + + result := extractTemplateSections(input) + + assert.Contains(t, result, "# {[.Manifest.Title]} Integration for Elastic") + assert.Contains(t, result, "## Overview") + assert.Contains(t, result, "### Compatibility") + assert.NotContains(t, result, "starting point") + assert.NotContains(t, result, "Complete this section") + }) +} + +func TestGetMinimalTemplateSections(t *testing.T) { + sections := getMinimalTemplateSections() + + // Should contain key section headers + assert.Contains(t, sections, "## Overview") + assert.Contains(t, sections, "## Troubleshooting") + assert.Contains(t, sections, "## Reference") + + // Should not contain template comments + assert.NotContains(t, sections, "{{/*") + assert.NotContains(t, sections, "*/}}") +} diff --git a/internal/llmagent/docagent/resources.go b/internal/llmagent/docagent/resources.go index 67b83fc46..92e4db35c 100644 --- a/internal/llmagent/docagent/resources.go +++ b/internal/llmagent/docagent/resources.go @@ -23,3 +23,6 @@ var ModificationAnalysisPrompt string //go:embed _static/modification_prompt.txt var ModificationPrompt string + +//go:embed _static/reformat_prompt.txt +var ReformatPrompt string diff --git a/internal/llmagent/docagent/section_parser_deep_test.go b/internal/llmagent/docagent/section_parser_deep_test.go index 26c2d7cc4..5f40e27ac 100644 --- a/internal/llmagent/docagent/section_parser_deep_test.go +++ b/internal/llmagent/docagent/section_parser_deep_test.go @@ -7,8 +7,9 @@ package docagent import ( "testing" - "github.com/elastic/elastic-package/internal/packages/archetype" "github.com/stretchr/testify/assert" + + "github.com/elastic/elastic-package/internal/packages/archetype" ) func TestParseSections_DeepNesting(t *testing.T) { @@ -142,13 +143,13 @@ func TestParseSections_RealTemplate(t *testing.T) { // Verify section titles and structure expectedSections := map[string]int{ - "Overview": 2, // 2 subsections: Compatibility, How it works - "What data does this integration collect?": 1, // 1 subsection: Supported use cases - "What do I need to use this integration?": 0, // No subsections - "How do I deploy this integration?": 5, // 5 subsections - "Troubleshooting": 0, // No subsections - "Performance and scaling": 0, // No subsections - "Reference": 3, // 3 subsections + "Overview": 2, + "What data does this integration collect?": 1, + "What do I need to use this integration?": 0, + "How do I deploy this integration?": 6, + "Troubleshooting": 0, + "Performance and scaling": 0, + "Reference": 3, } for i, section := range sections { diff --git a/internal/llmagent/docagent/specialists/registry.go b/internal/llmagent/docagent/specialists/registry.go index c2b9b3660..c7172df82 100644 --- a/internal/llmagent/docagent/specialists/registry.go +++ b/internal/llmagent/docagent/specialists/registry.go @@ -123,4 +123,3 @@ func DefaultRegistry() *Registry { r.Register(NewURLValidatorAgent()) return r } - diff --git a/internal/llmagent/docagent/specialists/urlvalidator.go b/internal/llmagent/docagent/specialists/urlvalidator.go index 803370f2c..b51bdadf8 100644 --- a/internal/llmagent/docagent/specialists/urlvalidator.go +++ b/internal/llmagent/docagent/specialists/urlvalidator.go @@ -302,4 +302,3 @@ func isLocalhostURL(url string) bool { } return false } - diff --git a/internal/llmagent/docagent/specialists/validator.go b/internal/llmagent/docagent/specialists/validator.go index 37bb757d6..8a77e6756 100644 --- a/internal/llmagent/docagent/specialists/validator.go +++ b/internal/llmagent/docagent/specialists/validator.go @@ -152,4 +152,3 @@ func (v *ValidatorAgent) validateContent(content string) ValidationResult { Warnings: warnings, } } - diff --git a/internal/llmagent/tools/examples.go b/internal/llmagent/tools/examples.go index 310c012bb..78728af5c 100644 --- a/internal/llmagent/tools/examples.go +++ b/internal/llmagent/tools/examples.go @@ -8,7 +8,7 @@ import ( "bufio" "embed" "fmt" - "path/filepath" + "path" "strings" "google.golang.org/adk/tool" @@ -98,7 +98,7 @@ func getExampleHandler() functiontool.Func[GetExampleArgs, GetExampleResult] { } // Read the example file from embedded FS - filePath := filepath.Join("_static/examples", args.Name) + filePath := path.Join("_static/examples", args.Name) content, err := examplesFS.ReadFile(filePath) if err != nil { return GetExampleResult{Error: fmt.Sprintf("failed to read example file '%s': %v", args.Name, err)}, nil @@ -269,7 +269,7 @@ func findSectionByTitleInSubsections(sections []*exampleSection, titleLower stri // GetExampleContent retrieves the content of a specific example file. // If section is provided, only that section's content is returned. func GetExampleContent(name, section string) (string, error) { - filePath := filepath.Join("_static/examples", name) + filePath := path.Join("_static/examples", name) content, err := examplesFS.ReadFile(filePath) if err != nil { return "", fmt.Errorf("failed to read example file '%s': %w", name, err) diff --git a/internal/llmagent/tools/examples_test.go b/internal/llmagent/tools/examples_test.go index 48446bfcd..9667bfb55 100644 --- a/internal/llmagent/tools/examples_test.go +++ b/internal/llmagent/tools/examples_test.go @@ -83,38 +83,6 @@ func TestGetExampleHandler(t *testing.T) { } } -func TestGetDefaultExampleContent(t *testing.T) { - content := GetDefaultExampleContent() - - assert.NotEmpty(t, content) - assert.Contains(t, content, "# Fortinet FortiGate") - assert.Contains(t, content, "## Overview") -} - -func TestGetExampleContent(t *testing.T) { - t.Run("full file", func(t *testing.T) { - content, err := GetExampleContent("fortinet_fortigate.md", "") - require.NoError(t, err) - assert.Contains(t, content, "# Fortinet FortiGate") - }) - - t.Run("specific section", func(t *testing.T) { - content, err := GetExampleContent("fortinet_fortigate.md", "Overview") - require.NoError(t, err) - assert.Contains(t, content, "## Overview") - }) - - t.Run("non-existent file", func(t *testing.T) { - _, err := GetExampleContent("non_existent.md", "") - assert.Error(t, err) - }) - - t.Run("non-existent section", func(t *testing.T) { - _, err := GetExampleContent("fortinet_fortigate.md", "Non Existent") - assert.Error(t, err) - }) -} - func TestParseExampleSections(t *testing.T) { content := `# Title diff --git a/tools/readme/readme.md.tmpl b/tools/readme/readme.md.tmpl index bf9371eba..18655e9da 100644 --- a/tools/readme/readme.md.tmpl +++ b/tools/readme/readme.md.tmpl @@ -223,30 +223,45 @@ When using AI-powered documentation generation, **file content from your local f #### Operation Modes -The command supports two modes of operation: +The command supports three modes of operation, selected via the `--mode` flag: -1. **Rewrite Mode** (default): Full documentation regeneration +1. **rewrite** (default): Full documentation regeneration - Analyzes your package structure, data streams, and configuration - Generates comprehensive documentation following Elastic's templates - - Creates or updates the README.md file in `/_dev/build/docs/` + - Creates or updates markdown files in `/_dev/build/docs/` -2. **Modify Mode**: Targeted documentation changes +2. **modify**: Targeted documentation changes - Makes specific changes to existing documentation - - Requires existing README.md file at `/_dev/build/docs/README.md` - - Use `--modify-prompt` flag for non-interactive modifications + - Requires existing documentation file at `/_dev/build/docs/` + - Use `--modify-prompt` for non-interactive modifications + +3. **reformat**: Reorganize existing content + - Reorganizes existing documentation to match template structure + - Only moves content between sections, does not modify or add content + - Adds TODO placeholders for empty sections + +#### Command Flags + +| Flag | Description | +|------|-------------| +| `--mode` | Documentation update mode: `rewrite` (default), `modify`, or `reformat` | +| `--modify-prompt` | Modification instructions for modify mode (implies `--mode=modify` if mode not set) | +| `--doc-file` | Specify which markdown file to update (e.g., `README.md`, `vpc.md`). Defaults to `README.md` | +| `--non-interactive` | Skip all prompts and automatically accept the first result from the LLM | +| `--profile`, `-p` | Use a specific elastic-package profile with LLM configuration | #### Workflow Options **Interactive Mode** (default): The command will guide you through the process, allowing you to: -- Choose between rewrite or modify mode +- Choose between rewrite, modify, or reformat mode +- Select which documentation file to update (if multiple exist) - Review generated documentation - Request iterative changes - Accept or cancel the update **Non-Interactive Mode**: Use `--non-interactive` to skip all prompts and automatically accept the first result. -Combine with `--modify-prompt "instructions"` for targeted non-interactive changes. If no LLM provider is configured, the command will print manual instructions for updating documentation. @@ -266,18 +281,23 @@ Environment variables (e.g., `GEMINI_API_KEY`, `LOCAL_LLM_ENDPOINT`) take preced #### Usage Examples ```bash -# Interactive documentation update (rewrite mode) -elastic-package update documentation - -# Interactive modification mode +# Interactive documentation update (prompts for mode selection) elastic-package update documentation -# (choose "Modify" when prompted) -# Non-interactive rewrite +# Non-interactive rewrite (default mode) elastic-package update documentation --non-interactive -# Non-interactive targeted changes -elastic-package update documentation --modify-prompt "Add more details about authentication configuration" +# Non-interactive modify with instructions +elastic-package update documentation --non-interactive --mode=modify --modify-prompt "Add troubleshooting section" + +# Shorthand for modify (--modify-prompt implies --mode=modify) +elastic-package update documentation --non-interactive --modify-prompt "Fix typos in setup instructions" + +# Non-interactive reformat +elastic-package update documentation --non-interactive --mode=reformat + +# Update a specific documentation file +elastic-package update documentation --doc-file vpc.md # Use specific profile with LLM configuration elastic-package update documentation --profile production