181 lines
5.2 KiB
Go
181 lines
5.2 KiB
Go
package api
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
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 ""
|
|
}
|