feat: AI-анализ terraform plan (уровни 0-5, Google Gemini)
This commit is contained in:
@@ -0,0 +1,150 @@
|
|||||||
|
// cmd/sless-plan/main.go
|
||||||
|
// Дата: 2026-03-12
|
||||||
|
// CLI-обёртка над `terraform plan`.
|
||||||
|
// Добавляет AI-анализ вывода если AI_HINT_LEVEL > 0.
|
||||||
|
// Существующий terraform plan не меняется — AI только дополняет вывод.
|
||||||
|
//
|
||||||
|
// Использование:
|
||||||
|
// sless-plan [terraform args...]
|
||||||
|
// sless-plan --ai-level=3 [terraform args...]
|
||||||
|
//
|
||||||
|
// Env-переменные:
|
||||||
|
// AI_HINT_LEVEL — уровень анализа 0–5 (0 = выключен, по умолчанию)
|
||||||
|
// AI_PROVIDER — провайдер: "google" (по умолчанию) или "cloud"
|
||||||
|
// GOOGLE_AI_API_KEY — API ключ Google Gemini
|
||||||
|
// GOOGLE_AI_MODEL — модель (по умолчанию "gemini-2.0-flash")
|
||||||
|
// CLOUD_LLM_ENDPOINT — endpoint будущего облачного LLM
|
||||||
|
// CLOUD_LLM_TOKEN — токен для облачного LLM
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/internal/ai"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Флаг --ai-level переопределяет env AI_HINT_LEVEL
|
||||||
|
aiLevel := flag.Int("ai-level", -1, "уровень AI-анализа 0–5 (-1 = читать из AI_HINT_LEVEL)")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
// Определяем уровень: флаг > env > 0
|
||||||
|
level := resolveLevel(*aiLevel)
|
||||||
|
|
||||||
|
// Собираем аргументы для terraform plan (всё что после флагов sless-plan)
|
||||||
|
tfArgs := append([]string{"plan"}, flag.Args()...)
|
||||||
|
|
||||||
|
// Запускаем terraform plan, захватывая вывод для AI
|
||||||
|
// но одновременно выводя его пользователю (tee-эффект)
|
||||||
|
planOutput, exitCode := runTerraform(tfArgs)
|
||||||
|
|
||||||
|
// Если уровень > 0 — запускаем AI анализ
|
||||||
|
if level > 0 {
|
||||||
|
runAIAnalysis(level, planOutput)
|
||||||
|
}
|
||||||
|
|
||||||
|
os.Exit(exitCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveLevel определяет уровень AI анализа.
|
||||||
|
// Приоритет: --ai-level flag > AI_HINT_LEVEL env > 0
|
||||||
|
func resolveLevel(flagValue int) int {
|
||||||
|
if flagValue >= 0 {
|
||||||
|
// Флаг явно задан
|
||||||
|
if flagValue > 5 {
|
||||||
|
flagValue = 5
|
||||||
|
}
|
||||||
|
return flagValue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Читаем из env
|
||||||
|
if envVal := os.Getenv("AI_HINT_LEVEL"); envVal != "" {
|
||||||
|
level, err := strconv.Atoi(envVal)
|
||||||
|
if err == nil && level >= 0 && level <= 5 {
|
||||||
|
return level
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TF_VAR_ai_hint_level тоже поддерживаем
|
||||||
|
if envVal := os.Getenv("TF_VAR_ai_hint_level"); envVal != "" {
|
||||||
|
level, err := strconv.Atoi(envVal)
|
||||||
|
if err == nil && level >= 0 && level <= 5 {
|
||||||
|
return level
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0 // по умолчанию — AI выключен
|
||||||
|
}
|
||||||
|
|
||||||
|
// runTerraform запускает terraform с переданными аргументами.
|
||||||
|
// Вывод идёт напрямую в stdout/stderr пользователя И захватывается для AI.
|
||||||
|
// Возвращает текст вывода и код завершения.
|
||||||
|
func runTerraform(args []string) (string, int) {
|
||||||
|
cmd := exec.Command("terraform", args...)
|
||||||
|
|
||||||
|
// Захватываем stdout для AI, но также пишем напрямую в os.Stdout
|
||||||
|
var buf bytes.Buffer
|
||||||
|
cmd.Stdout = io.MultiWriter(os.Stdout, &buf)
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
cmd.Stdin = os.Stdin
|
||||||
|
|
||||||
|
err := cmd.Run()
|
||||||
|
if err != nil {
|
||||||
|
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||||
|
return buf.String(), exitErr.ExitCode()
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stderr, "ошибка запуска terraform: %v\n", err)
|
||||||
|
return buf.String(), 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf.String(), 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// runAIAnalysis запускает AI анализ и выводит результат.
|
||||||
|
// Не прерывает работу если AI недоступен — только показывает предупреждение.
|
||||||
|
func runAIAnalysis(level int, planOutput string) {
|
||||||
|
cfg := ai.Config{
|
||||||
|
HintLevel: level,
|
||||||
|
Provider: getEnv("AI_PROVIDER", "google"),
|
||||||
|
GoogleAPIKey: os.Getenv("GOOGLE_AI_API_KEY"),
|
||||||
|
GoogleModel: getEnv("GOOGLE_AI_MODEL", "gemini-2.0-flash"),
|
||||||
|
CloudEndpoint: os.Getenv("CLOUD_LLM_ENDPOINT"),
|
||||||
|
CloudToken: os.Getenv("CLOUD_LLM_TOKEN"),
|
||||||
|
}
|
||||||
|
|
||||||
|
analyzer, err := ai.NewAnalyzer(cfg)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "\n⚠ AI анализ недоступен: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
result, err := analyzer.Analyze(ctx, planOutput)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "\n⚠ AI анализ завершился с ошибкой: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if result != "" {
|
||||||
|
fmt.Print(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getEnv возвращает значение env-переменной или defaultVal если не задана.
|
||||||
|
func getEnv(key, defaultVal string) string {
|
||||||
|
if val := os.Getenv(key); val != "" {
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
return defaultVal
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
// internal/ai/analyzer.go
|
||||||
|
// Дата: 2026-03-12
|
||||||
|
// Оркестратор AI-анализа: читает уровень, формирует промпт,
|
||||||
|
// вызывает LLM-провайдер, форматирует вывод для пользователя.
|
||||||
|
|
||||||
|
package ai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Analyzer — основной компонент AI-анализа.
|
||||||
|
// Создаётся один раз при старте sless-plan, используется для одного анализа.
|
||||||
|
type Analyzer struct {
|
||||||
|
provider LLMProvider
|
||||||
|
level int
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAnalyzer создаёт Analyzer из конфига.
|
||||||
|
// Возвращает ошибку если провайдер не может быть инициализирован.
|
||||||
|
func NewAnalyzer(cfg Config) (*Analyzer, error) {
|
||||||
|
if cfg.HintLevel == 0 {
|
||||||
|
// Уровень 0 — AI выключен, провайдер не нужен
|
||||||
|
return &Analyzer{level: 0}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
provider, err := NewProvider(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ошибка инициализации AI провайдера: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Analyzer{
|
||||||
|
provider: provider,
|
||||||
|
level: cfg.HintLevel,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsEnabled возвращает true если уровень > 0 и провайдер инициализирован.
|
||||||
|
func (a *Analyzer) IsEnabled() bool {
|
||||||
|
return a.level > 0 && a.provider != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Analyze отправляет вывод terraform plan в LLM и возвращает отформатированный результат.
|
||||||
|
// planText — текстовый вывод terraform plan.
|
||||||
|
func (a *Analyzer) Analyze(ctx context.Context, planText string) (string, error) {
|
||||||
|
if !a.IsEnabled() {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ограничиваем размер payload — большие планы обрезаем
|
||||||
|
// чтобы не превысить лимит токенов LLM и не отправлять лишнее
|
||||||
|
planText = truncatePlan(planText, 8000)
|
||||||
|
|
||||||
|
prompt := BuildPrompt(a.level, planText)
|
||||||
|
result, err := a.provider.Analyze(ctx, prompt)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("AI анализ не удался: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return FormatResult(a.level, a.provider.Name(), result), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatResult оборачивает ответ LLM в читаемый блок для вывода в терминал.
|
||||||
|
func FormatResult(level int, providerName, result string) string {
|
||||||
|
separator := strings.Repeat("─", 60)
|
||||||
|
header := fmt.Sprintf("AI Analysis (level %d: %s) via %s", level, LevelName(level), providerName)
|
||||||
|
return fmt.Sprintf("\n%s\n %s\n%s\n%s\n%s\n",
|
||||||
|
separator,
|
||||||
|
header,
|
||||||
|
separator,
|
||||||
|
result,
|
||||||
|
separator,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// truncatePlan обрезает текст плана до maxChars символов с пометкой об обрезке.
|
||||||
|
// Нужно чтобы не превышать лимиты токенов LLM и не отправлять лишние данные.
|
||||||
|
func truncatePlan(text string, maxChars int) string {
|
||||||
|
if len(text) <= maxChars {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
return text[:maxChars] + fmt.Sprintf("\n\n[... обрезано, показаны первые %d символов из %d ...]", maxChars, len(text))
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
// internal/ai/cloud_llm.go
|
||||||
|
// Дата: 2026-03-12
|
||||||
|
// Заглушка для будущего облачного LLM.
|
||||||
|
// Когда облако предоставит свой LLM — реализовать этот файл
|
||||||
|
// и переключить AI_PROVIDER=cloud. Остальной код не меняется.
|
||||||
|
|
||||||
|
package ai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CloudLLMProvider — будущая реализация LLMProvider для облачного LLM.
|
||||||
|
// Пока возвращает ошибку "не реализован".
|
||||||
|
type CloudLLMProvider struct {
|
||||||
|
endpoint string
|
||||||
|
token string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCloudLLMProvider создаёт заглушку провайдера облачного LLM.
|
||||||
|
func NewCloudLLMProvider(endpoint, token string) (*CloudLLMProvider, error) {
|
||||||
|
if endpoint == "" {
|
||||||
|
return nil, fmt.Errorf("CLOUD_LLM_ENDPOINT не задан")
|
||||||
|
}
|
||||||
|
return &CloudLLMProvider{
|
||||||
|
endpoint: endpoint,
|
||||||
|
token: token,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CloudLLMProvider) Name() string {
|
||||||
|
return fmt.Sprintf("cloud-llm/%s", c.endpoint)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Analyze — TODO: реализовать когда облако предоставит LLM API.
|
||||||
|
// Документация по API будет в doc/api/cloud-llm.md.
|
||||||
|
func (c *CloudLLMProvider) Analyze(_ context.Context, _ string) (string, error) {
|
||||||
|
return "", fmt.Errorf("cloud LLM провайдер ещё не реализован (endpoint: %s)", c.endpoint)
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
// internal/ai/google_gemini.go
|
||||||
|
// Дата: 2026-03-12
|
||||||
|
// Реализация LLMProvider для Google Gemini API.
|
||||||
|
// Использует REST API напрямую через net/http — без внешних SDK,
|
||||||
|
// чтобы не нарушать совместимость существующего go.mod.
|
||||||
|
|
||||||
|
package ai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const geminiBaseURL = "https://generativelanguage.googleapis.com/v1beta/models/%s:generateContent"
|
||||||
|
|
||||||
|
// GeminiProvider реализует LLMProvider через Google Gemini REST API.
|
||||||
|
type GeminiProvider struct {
|
||||||
|
apiKey string
|
||||||
|
model string
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewGeminiProvider создаёт провайдера для Google Gemini.
|
||||||
|
// apiKey — значение env GOOGLE_AI_API_KEY.
|
||||||
|
func NewGeminiProvider(apiKey, model string) (*GeminiProvider, error) {
|
||||||
|
if apiKey == "" {
|
||||||
|
return nil, fmt.Errorf("GOOGLE_AI_API_KEY не задан")
|
||||||
|
}
|
||||||
|
return &GeminiProvider{
|
||||||
|
apiKey: apiKey,
|
||||||
|
model: model,
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Timeout: 60 * time.Second,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *GeminiProvider) Name() string {
|
||||||
|
return fmt.Sprintf("google-gemini/%s", g.model)
|
||||||
|
}
|
||||||
|
|
||||||
|
// geminiRequest — структура запроса к Gemini API.
|
||||||
|
type geminiRequest struct {
|
||||||
|
Contents []geminiContent `json:"contents"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type geminiContent struct {
|
||||||
|
Parts []geminiPart `json:"parts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type geminiPart struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// geminiResponse — структура ответа от Gemini API.
|
||||||
|
type geminiResponse struct {
|
||||||
|
Candidates []struct {
|
||||||
|
Content struct {
|
||||||
|
Parts []struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
} `json:"parts"`
|
||||||
|
} `json:"content"`
|
||||||
|
} `json:"candidates"`
|
||||||
|
Error *struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
Code int `json:"code"`
|
||||||
|
} `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Analyze отправляет промпт в Gemini и возвращает текстовый ответ.
|
||||||
|
func (g *GeminiProvider) Analyze(ctx context.Context, prompt string) (string, error) {
|
||||||
|
reqBody := geminiRequest{
|
||||||
|
Contents: []geminiContent{
|
||||||
|
{Parts: []geminiPart{{Text: prompt}}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyBytes, err := json.Marshal(reqBody)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("ошибка маршалинга запроса: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
url := fmt.Sprintf(geminiBaseURL+"?key=%s", g.model, g.apiKey)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(bodyBytes))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("ошибка создания HTTP запроса: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := g.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("ошибка HTTP запроса к Gemini: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBytes, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("ошибка чтения ответа: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var gemResp geminiResponse
|
||||||
|
if err := json.Unmarshal(respBytes, &gemResp); err != nil {
|
||||||
|
return "", fmt.Errorf("ошибка парсинга ответа Gemini: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем HTTP-ошибку и ошибку в теле ответа
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
msg := fmt.Sprintf("HTTP %d", resp.StatusCode)
|
||||||
|
if gemResp.Error != nil {
|
||||||
|
msg = gemResp.Error.Message
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("Gemini API ошибка: %s", msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(gemResp.Candidates) == 0 || len(gemResp.Candidates[0].Content.Parts) == 0 {
|
||||||
|
return "", fmt.Errorf("Gemini вернул пустой ответ")
|
||||||
|
}
|
||||||
|
|
||||||
|
return gemResp.Candidates[0].Content.Parts[0].Text, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// internal/ai/prompts.go
|
||||||
|
// Дата: 2026-03-12
|
||||||
|
// Шаблоны system-промптов для каждого уровня анализа (1–5).
|
||||||
|
// Уровень 0 — AI не вызывается вообще (проверка в analyzer.go).
|
||||||
|
|
||||||
|
package ai
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// systemPrompts — system prompt для каждого уровня.
|
||||||
|
// Каждый уровень строго ограничен своей областью — не расширять без явного указания.
|
||||||
|
var systemPrompts = map[int]string{
|
||||||
|
1: `Ты — анализатор Terraform конфигурации. Твоя задача: ТОЛЬКО синтаксический анализ.
|
||||||
|
Найди синтаксические ошибки в HCL: опечатки в ключевых словах, незакрытые блоки, неверные типы значений, невалидные атрибуты.
|
||||||
|
НЕ давай рекомендаций по улучшению, НЕ анализируй логику, НЕ комментируй архитектуру.
|
||||||
|
Формат ответа: список найденных ошибок или "✓ Синтаксических ошибок не обнаружено."`,
|
||||||
|
|
||||||
|
2: `Ты — анализатор Terraform конфигурации. Твоя задача: синтаксис + базовые проблемы.
|
||||||
|
1. Синтаксические ошибки в HCL.
|
||||||
|
2. Базовые проблемы: неиспользуемые переменные/ресурсы, пустые обязательные значения, дублирующиеся ресурсы.
|
||||||
|
НЕ анализируй зависимости, архитектуру, безопасность.
|
||||||
|
Формат: список проблем с пометкой [SYNTAX] или [BASIC], или "✓ Проблем не обнаружено."`,
|
||||||
|
|
||||||
|
3: `Ты — анализатор Terraform конфигурации. Твоя задача: синтаксис + базовые проблемы + зависимости.
|
||||||
|
1. Синтаксические ошибки.
|
||||||
|
2. Базовые проблемы (неиспользуемые ресурсы, пустые значения).
|
||||||
|
3. Анализ зависимостей: потенциальные race conditions при apply/destroy, неявные зависимости, проблемы с порядком создания ресурсов.
|
||||||
|
НЕ давай рекомендаций по безопасности или рефакторингу.
|
||||||
|
Формат: список проблем с пометкой [SYNTAX], [BASIC] или [DEPS], или "✓ Проблем не обнаружено."`,
|
||||||
|
|
||||||
|
4: `Ты — анализатор Terraform конфигурации. Твоя задача: полный анализ + безопасность.
|
||||||
|
1. Синтаксические ошибки.
|
||||||
|
2. Базовые проблемы.
|
||||||
|
3. Зависимости и порядок операций.
|
||||||
|
4. Безопасность и best practices: открытые порты (0.0.0.0/0), избыточные права доступа, незашифрованные ресурсы, жёстко заданные секреты.
|
||||||
|
Формат: список проблем с пометкой [SYNTAX], [BASIC], [DEPS] или [SECURITY], или "✓ Проблем не обнаружено."`,
|
||||||
|
|
||||||
|
5: `Ты — старший DevOps-инженер, проводишь полный аудит Terraform конфигурации.
|
||||||
|
Анализируй:
|
||||||
|
1. Синтаксические ошибки.
|
||||||
|
2. Базовые проблемы.
|
||||||
|
3. Зависимости и порядок операций.
|
||||||
|
4. Безопасность и best practices.
|
||||||
|
5. Оптимизация: возможности для рефакторинга, использование модулей, устаревшие паттерны, альтернативные подходы.
|
||||||
|
Давай конкретные рекомендации с примерами кода где уместно.
|
||||||
|
Формат: список проблем и рекомендаций с пометкой [SYNTAX], [BASIC], [DEPS], [SECURITY] или [OPT].`,
|
||||||
|
}
|
||||||
|
|
||||||
|
// levelNames — человекочитаемые названия уровней для вывода.
|
||||||
|
var levelNames = map[int]string{
|
||||||
|
0: "OFF",
|
||||||
|
1: "SYNTAX",
|
||||||
|
2: "BASIC",
|
||||||
|
3: "MODERATE",
|
||||||
|
4: "DETAILED",
|
||||||
|
5: "FULL",
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildPrompt формирует финальный промпт для отправки в LLM.
|
||||||
|
// planText — вывод terraform plan (текст или JSON).
|
||||||
|
func BuildPrompt(level int, planText string) string {
|
||||||
|
sysPrompt, ok := systemPrompts[level]
|
||||||
|
if !ok {
|
||||||
|
sysPrompt = systemPrompts[1]
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s\n\nTerraform plan output:\n```\n%s\n```", sysPrompt, planText)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LevelName возвращает название уровня для вывода пользователю.
|
||||||
|
func LevelName(level int) string {
|
||||||
|
name, ok := levelNames[level]
|
||||||
|
if !ok {
|
||||||
|
return "UNKNOWN"
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
// internal/ai/provider.go
|
||||||
|
// Дата: 2026-03-12
|
||||||
|
// Интерфейс LLMProvider — абстракция над любым LLM-бэкендом.
|
||||||
|
// Сейчас реализован Google Gemini. В будущем — облачный LLM (drop-in замена).
|
||||||
|
|
||||||
|
package ai
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
// LLMProvider — единственный интерфейс для всех LLM-провайдеров.
|
||||||
|
// Менять провайдер = менять одну env-переменную AI_PROVIDER.
|
||||||
|
type LLMProvider interface {
|
||||||
|
// Analyze отправляет промпт в LLM и возвращает текстовый ответ.
|
||||||
|
Analyze(ctx context.Context, prompt string) (string, error)
|
||||||
|
// Name возвращает имя провайдера для логирования.
|
||||||
|
Name() string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config — конфигурация анализатора, читается из env-переменных.
|
||||||
|
type Config struct {
|
||||||
|
// Provider: "google" или "cloud" (в будущем)
|
||||||
|
Provider string
|
||||||
|
// HintLevel: 0–5, если 0 — AI не вызывается
|
||||||
|
HintLevel int
|
||||||
|
// Google Gemini
|
||||||
|
GoogleAPIKey string
|
||||||
|
GoogleModel string // по умолчанию "gemini-pro"
|
||||||
|
// Будущий облачный LLM
|
||||||
|
CloudEndpoint string
|
||||||
|
CloudToken string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewProvider — фабрика провайдеров. Выбор по Config.Provider.
|
||||||
|
// При добавлении нового провайдера — только сюда добавить case.
|
||||||
|
func NewProvider(cfg Config) (LLMProvider, error) {
|
||||||
|
switch cfg.Provider {
|
||||||
|
case "google":
|
||||||
|
model := cfg.GoogleModel
|
||||||
|
if model == "" {
|
||||||
|
model = "gemini-2.0-flash"
|
||||||
|
}
|
||||||
|
return NewGeminiProvider(cfg.GoogleAPIKey, model)
|
||||||
|
case "cloud":
|
||||||
|
return NewCloudLLMProvider(cfg.CloudEndpoint, cfg.CloudToken)
|
||||||
|
default:
|
||||||
|
// По умолчанию google, если AI_PROVIDER не задан
|
||||||
|
model := cfg.GoogleModel
|
||||||
|
if model == "" {
|
||||||
|
model = "gemini-2.0-flash"
|
||||||
|
}
|
||||||
|
return NewGeminiProvider(cfg.GoogleAPIKey, model)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user