package api import ( "archive/zip" "bytes" "encoding/json" "fmt" "io" "net/http" "strings" "time" "context" ) const ( // maxExplainCodeBytes — суммарный лимит кода из архива для отправки в LLM. // LLM бесплатный и слабый — не перегружаем контекст. maxExplainCodeBytes = 20 * 1024 ) // handleExplainArchive — POST /console/api/ai/explain-archive // Принимает zip-архив, извлекает код, отправляет в LLM с вопросом "что делает?". func (s *Server) handleExplainArchive(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed") return } if s.llmKey == "" { writeJSONError(w, http.StatusServiceUnavailable, "AI-ассистент не настроен (FISSION_LLM_KEY не задан)") return } r.Body = http.MaxBytesReader(w, r.Body, 10*1024*1024) if err := r.ParseMultipartForm(maxArchiveBytes); err != nil { writeJSONError(w, http.StatusBadRequest, "ошибка разбора multipart: "+err.Error()) return } f, _, err := r.FormFile("archive") if err != nil { writeJSONError(w, http.StatusBadRequest, "поле 'archive' обязательно") return } defer f.Close() var buf bytes.Buffer if _, err := buf.ReadFrom(f); err != nil { writeJSONError(w, http.StatusBadRequest, "ошибка чтения архива: "+err.Error()) return } if buf.Len() > maxArchiveBytes { writeJSONError(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("архив слишком большой: максимум %d KB", maxArchiveBytes/1024)) return } zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len())) if err != nil { writeJSONError(w, http.StatusBadRequest, "не удалось открыть zip: "+err.Error()) return } // Поддерживаемые расширения supported := map[string]bool{".py": true, ".js": true, ".rb": true, ".php": true, ".go": true} type fileChunk struct { name string code string } var chunks []fileChunk var totalBytes int for _, zf := range zr.File { if zf.FileInfo().IsDir() { continue } ext := strings.ToLower(archiveFileExt(zf.Name)) if !supported[ext] { continue } if totalBytes >= maxExplainCodeBytes { break } rc, err := zf.Open() if err != nil { continue } limit := maxExplainCodeBytes - totalBytes content, err := io.ReadAll(io.LimitReader(rc, int64(limit))) rc.Close() if err != nil || len(content) == 0 { continue } chunks = append(chunks, fileChunk{name: zf.Name, code: string(content)}) totalBytes += len(content) } if len(chunks) == 0 { writeJSONError(w, http.StatusBadRequest, "нет поддерживаемых файлов (.py, .js, .rb, .php, .go)") return } // Собираем текст для LLM var sb strings.Builder sb.WriteString("Ниже приведены файлы из архива serverless-функции. Опиши КРАТКО (2-5 предложений) что делает эта функция.\n\n") for _, c := range chunks { sb.WriteString(fmt.Sprintf("=== %s ===\n%s\n\n", c.name, c.code)) } prompt := sb.String() llmReqBody := llmChatRequest{ Model: "gpt-oss-120b", Messages: []llmMessage{ {Role: "system", Content: "Ты ассистент для анализа serverless-функций. Отвечай кратко, на русском языке."}, {Role: "user", Content: prompt}, }, MaxTokens: 512, } body, _ := json.Marshal(llmReqBody) ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) defer cancel() llmReq, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(s.llmURL, "/")+"/chat/completions", bytes.NewReader(body), ) if err != nil { writeJSONError(w, http.StatusInternalServerError, err.Error()) return } llmReq.Header.Set("Content-Type", "application/json") llmReq.Header.Set("Authorization", "Bearer "+s.llmKey) resp, err := s.http.Do(llmReq) if err != nil { writeJSONError(w, http.StatusBadGateway, "LLM недоступен: "+err.Error()) return } defer resp.Body.Close() var llmResp struct { Choices []struct { Message struct { Content string `json:"content"` } `json:"message"` } `json:"choices"` Error *struct { Message string `json:"message"` } `json:"error"` } if err := json.NewDecoder(io.LimitReader(resp.Body, 64*1024)).Decode(&llmResp); err != nil { writeJSONError(w, http.StatusBadGateway, "parse error: "+err.Error()) return } if llmResp.Error != nil { writeJSONError(w, http.StatusBadGateway, llmResp.Error.Message) return } if len(llmResp.Choices) == 0 { writeJSONError(w, http.StatusBadGateway, "пустой ответ от LLM") return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{ "answer": strings.TrimSpace(llmResp.Choices[0].Message.Content), }) } // archiveFileExt возвращает расширение файла из пути внутри zip (включая точку). func archiveFileExt(name string) string { for i := len(name) - 1; i >= 0 && name[i] != '/'; i-- { if name[i] == '.' { return name[i:] } } return "" }