diff --git a/console/internal/api/ai_check.go b/console/internal/api/ai_check.go index 0de2144..fc9dfdd 100644 --- a/console/internal/api/ai_check.go +++ b/console/internal/api/ai_check.go @@ -142,26 +142,18 @@ func (s *Server) handleAIAsk(w http.ResponseWriter, r *http.Request) { return } - var req struct { - Question string `json:"question"` - } + var req aiPromptRequest if err := json.NewDecoder(io.LimitReader(r.Body, 4*1024)).Decode(&req); err != nil { writeJSONError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) return } - if strings.TrimSpace(req.Question) == "" { - writeJSONError(w, http.StatusBadRequest, "question is required") + chatReq, err := buildLLMChatRequest(req) + if err != nil { + writeJSONError(w, http.StatusBadRequest, err.Error()) return } - body, _ := json.Marshal(map[string]any{ - "model": "gpt-oss-120b", - "messages": []map[string]string{ - {"role": "system", "content": "Ты умный ассистент. Отвечай кратко и по делу."}, - {"role": "user", "content": req.Question}, - }, - "max_tokens": 1024, - }) + body, _ := json.Marshal(chatReq) ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) defer cancel() diff --git a/console/internal/api/ai_prompts.go b/console/internal/api/ai_prompts.go new file mode 100644 index 0000000..99ee2de --- /dev/null +++ b/console/internal/api/ai_prompts.go @@ -0,0 +1,230 @@ +package api + +import ( + "fmt" + "strings" +) + +const ( + aiModeChat = "chat" + aiModeCodegen = "codegen" + aiModeExplain = "explain" +) + +type llmMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +type aiPromptRequest struct { + Mode string `json:"mode"` + Language string `json:"language"` + Name string `json:"name"` + Description string `json:"description"` + Code string `json:"code"` + Question string `json:"question"` +} + +type llmChatRequest struct { + Model string `json:"model"` + Messages []llmMessage `json:"messages"` + MaxTokens int `json:"max_tokens"` +} + +func buildLLMChatRequest(req aiPromptRequest) (llmChatRequest, error) { + mode := normalizeAIMode(req.Mode) + lang := normalizeLanguage(req.Language) + + switch mode { + case aiModeCodegen: + if lang == "" { + return llmChatRequest{}, fmt.Errorf("language is required") + } + return llmChatRequest{ + Model: "gpt-oss-120b", + Messages: []llmMessage{ + {Role: "system", Content: buildCodegenSystemPrompt(lang)}, + {Role: "user", Content: buildCodegenUserPrompt(lang, req.Name, req.Description)}, + }, + MaxTokens: 1024, + }, nil + case aiModeExplain: + if lang == "" { + return llmChatRequest{}, fmt.Errorf("language is required") + } + if strings.TrimSpace(req.Code) == "" { + return llmChatRequest{}, fmt.Errorf("code is required") + } + return llmChatRequest{ + Model: "gpt-oss-120b", + Messages: []llmMessage{ + {Role: "system", Content: buildExplainSystemPrompt(lang)}, + {Role: "user", Content: buildExplainUserPrompt(lang, req.Code)}, + }, + MaxTokens: 1024, + }, nil + default: + question := strings.TrimSpace(req.Question) + if question == "" { + return llmChatRequest{}, fmt.Errorf("question is required") + } + return llmChatRequest{ + Model: "gpt-oss-120b", + Messages: []llmMessage{ + {Role: "system", Content: buildChatSystemPrompt()}, + {Role: "user", Content: question}, + }, + MaxTokens: 1024, + }, nil + } +} + +func normalizeAIMode(mode string) string { + switch strings.ToLower(strings.TrimSpace(mode)) { + case aiModeCodegen: + return aiModeCodegen + case aiModeExplain: + return aiModeExplain + default: + return aiModeChat + } +} + +func normalizeLanguage(language string) string { + return strings.ToLower(strings.TrimSpace(language)) +} + +func buildCodegenSystemPrompt(lang string) string { + switch lang { + case "python": + return strings.TrimSpace(`Ты генератор кода для Fission Console. +Возвращай ТОЛЬКО код, без markdown, без пояснений, без fenced-блоков. +Генерируй только Fission-usable Python code. +Канон для Python: файл main.py, entrypoint main.main, функция def main(): без аргументов. +Не генерируй def main(ctx), def handler, Flask-приложение или обычный CLI-main. +Если нужен ответ HTTP-подобного вида, возвращай dict со status/body/headers или строку; не выдумывай лишние зависимости. +Если описание пользователя расплывчатое, выбери минимальную рабочую Fission-функцию.`) + + case "nodejs": + return strings.TrimSpace(`Ты генератор кода для Fission Console. +Возвращай ТОЛЬКО код, без markdown, без пояснений, без fenced-блоков. +Генерируй только Fission-usable Node.js code. +Канон для Node.js: user code должен быть CommonJS-style, обычно module.exports = async function (context) { ... }. +Не используй ESM import/export в user code. Не пиши обычный CLI main. +Возвращай объект вида { status, body, headers } или строку/JSON, совместимые с Fission node wrapper. +Если описание пользователя расплывчатое, выбери минимальную рабочую Fission-функцию.`) + + case "go": + return strings.TrimSpace(`Ты генератор кода для Fission Console. +Возвращай ТОЛЬКО код, без markdown, без пояснений, без fenced-блоков. +Генерируй только Fission-usable Go code. +Канон для Go: package main, экспортированная функция func Handler(w http.ResponseWriter, r *http.Request). +Не генерируй func main() как entrypoint и не делай CLI-программу. +Импортируй net/http и другие пакеты только если они реально нужны. +Если описание пользователя расплывчатое, выбери минимальную рабочую Fission-функцию.`) + + case "php": + return strings.TrimSpace(`Ты генератор кода для Fission Console. +Возвращай ТОЛЬКО код, без markdown, без пояснений, без fenced-блоков. +Генерируй только Fission-usable PHP code. +Канон для PHP: файл main.php, entrypoint main.php::handler, функция function handler($context). +Не пиши CLI-скрипт и не выдумывай другой entrypoint. +Для ответа используй $context["response"] или совместимый Fission response pattern. +Если описание пользователя расплывчатое, выбери минимальную рабочую Fission-функцию.`) + + case "ruby": + return strings.TrimSpace(`Ты генератор кода для Fission Console. +Возвращай ТОЛЬКО код, без markdown, без пояснений, без fenced-блоков. +Генерируй только Fission-usable Ruby code. +Канон для Ruby: файл handler.rb, entrypoint handler, функция def handler. +Не пиши CLI-скрипт и не выдумывай другой entrypoint. +Если описание пользователя расплывчатое, выбери минимальную рабочую Fission-функцию.`) + + default: + return strings.TrimSpace(`Ты генератор кода для Fission Console. +Возвращай ТОЛЬКО код, без markdown, без пояснений, без fenced-блоков. +Генерируй только Fission-usable code. +Если язык не распознан, выбери минимальную рабочую реализацию без лишней обвязки. +Если описание пользователя расплывчатое, выбери минимальную рабочую Fission-функцию.`) + } +} + +func buildCodegenUserPrompt(lang, name, description string) string { + funcName := strings.TrimSpace(name) + if funcName == "" { + funcName = defaultPromptFunctionName(lang) + } + goal := strings.TrimSpace(description) + if goal == "" { + goal = "Сгенерируй минимальную рабочую Fission-функцию." + } + + return strings.TrimSpace(fmt.Sprintf(`Сгенерируй функцию для Fission. +Language: %s +Function name: %s +What to do: %s + +Правила: +1) Верни только код. +2) Соблюдай канон Fission для этого языка. +3) Если пользователь описал задачу плохо или противоречиво, выбери самый безопасный и минимальный рабочий вариант. +4) Не добавляй markdown, пояснений и лишних файлов. +5) Код должен быть пригоден для вставки в console и запуска в Fission без ручной правки entrypoint.`, lang, funcName, goal)) +} + +func buildExplainSystemPrompt(lang string) string { + return strings.TrimSpace(fmt.Sprintf(`Ты анализатор кода для Fission Console. +Отвечай кратко, по делу и без markdown-блоков. +Сначала объясни, что делает код. +Затем отдельно скажи, подходит ли он для Fission %s и почему. +Если entrypoint, сигнатура или return shape не совпадают с каноном Fission, скажи это прямо. +Не переписывай код и не выдумывай поведение, которого нет.`, prettyLanguageName(lang))) +} + +func buildExplainUserPrompt(lang, code string) string { + return strings.TrimSpace(fmt.Sprintf(`Объясни этот %s код и проверь его на совместимость с Fission. + +Требования к ответу: +1) Что делает код. +2) Совместим ли он с Fission. +3) Если не совместим — что именно сломано (entrypoint, сигнатура, return, экспорт, файл). + +Код: +%s`, prettyLanguageName(lang), strings.TrimSpace(code))) +} + +func buildChatSystemPrompt() string { + return "Ты краткий и полезный ассистент для Fission Console. Отвечай по делу и без лишней болтовни." +} + +func prettyLanguageName(lang string) string { + switch normalizeLanguage(lang) { + case "nodejs": + return "Node.js" + case "python": + return "Python" + case "go": + return "Go" + case "php": + return "PHP" + case "ruby": + return "Ruby" + default: + return "этот язык" + } +} + +func defaultPromptFunctionName(lang string) string { + switch normalizeLanguage(lang) { + case "go": + return "Handler" + case "php": + return "handler" + case "ruby": + return "handler" + case "nodejs": + return "main" + default: + return "main" + } +} diff --git a/console/internal/api/ai_prompts_test.go b/console/internal/api/ai_prompts_test.go new file mode 100644 index 0000000..84855d0 --- /dev/null +++ b/console/internal/api/ai_prompts_test.go @@ -0,0 +1,282 @@ +package api + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestNormalizeAIMode(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {name: "blank defaults to chat", in: "", want: aiModeChat}, + {name: "case-insensitive codegen", in: " CODEGEN ", want: aiModeCodegen}, + {name: "case-insensitive explain", in: "Explain", want: aiModeExplain}, + {name: "unknown defaults to chat", in: "boom", want: aiModeChat}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := normalizeAIMode(tt.in); got != tt.want { + t.Fatalf("normalizeAIMode(%q) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} + +func TestDefaultPromptFunctionName(t *testing.T) { + tests := []struct { + lang string + want string + }{ + {lang: "python", want: "main"}, + {lang: "nodejs", want: "main"}, + {lang: "go", want: "Handler"}, + {lang: "php", want: "handler"}, + {lang: "ruby", want: "handler"}, + {lang: "unknown", want: "main"}, + } + for _, tt := range tests { + t.Run(tt.lang, func(t *testing.T) { + if got := defaultPromptFunctionName(tt.lang); got != tt.want { + t.Fatalf("defaultPromptFunctionName(%q) = %q, want %q", tt.lang, got, tt.want) + } + }) + } +} + +func TestBuildLLMChatRequestCodegenPython(t *testing.T) { + req, err := buildLLMChatRequest(aiPromptRequest{ + Mode: aiModeCodegen, + Language: "python", + Name: " ", + Description: " сделать простой echo endpoint ", + }) + if err != nil { + t.Fatalf("buildLLMChatRequest error = %v", err) + } + if req.Model != "gpt-oss-120b" { + t.Fatalf("unexpected model %q", req.Model) + } + if got, want := len(req.Messages), 2; got != want { + t.Fatalf("messages len = %d, want %d", got, want) + } + sys := req.Messages[0].Content + usr := req.Messages[1].Content + if !strings.Contains(sys, "Fission") || !strings.Contains(sys, "main.main") || !strings.Contains(sys, "def main():") { + t.Fatalf("python system prompt is missing Fission rules: %s", sys) + } + if !strings.Contains(sys, "не генерируй def main(ctx)") && !strings.Contains(strings.ToLower(sys), "def main(ctx)") { + t.Fatalf("python system prompt must forbid ctx main: %s", sys) + } + if !strings.Contains(usr, "Language: python") { + t.Fatalf("python user prompt missing language: %s", usr) + } + if !strings.Contains(usr, "Function name: main") { + t.Fatalf("blank name should fall back to main: %s", usr) + } + if !strings.Contains(usr, "echo endpoint") { + t.Fatalf("description must be present: %s", usr) + } + if !strings.Contains(usr, "Верни только код") || !strings.Contains(usr, "entrypoint") { + t.Fatalf("python user prompt missing constraints: %s", usr) + } +} + +func TestBuildLLMChatRequestCodegenNodeJS(t *testing.T) { + req, err := buildLLMChatRequest(aiPromptRequest{ + Mode: aiModeCodegen, + Language: "nodejs", + Name: "handler", + Description: "возвращает status/body и не падает на пустом input", + }) + if err != nil { + t.Fatalf("buildLLMChatRequest error = %v", err) + } + sys := req.Messages[0].Content + usr := req.Messages[1].Content + for _, want := range []string{"CommonJS", "module.exports", "Fission node wrapper", "status, body, headers"} { + if !strings.Contains(sys, want) { + t.Fatalf("node system prompt missing %q: %s", want, sys) + } + } + if !strings.Contains(usr, "Language: nodejs") || !strings.Contains(usr, "Function name: handler") { + t.Fatalf("node user prompt missing fields: %s", usr) + } +} + +func TestBuildLLMChatRequestCodegenGo(t *testing.T) { + req, err := buildLLMChatRequest(aiPromptRequest{ + Mode: aiModeCodegen, + Language: "go", + Description: "печатает ответ и ставит content-type", + }) + if err != nil { + t.Fatalf("buildLLMChatRequest error = %v", err) + } + sys := req.Messages[0].Content + usr := req.Messages[1].Content + for _, want := range []string{"package main", "func Handler(w http.ResponseWriter, r *http.Request)", "net/http"} { + if !strings.Contains(strings.ToLower(sys), strings.ToLower(want)) { + t.Fatalf("go system prompt missing %q: %s", want, sys) + } + } + if !strings.Contains(usr, "Function name: Handler") { + t.Fatalf("go user prompt must default to Handler: %s", usr) + } +} + +func TestBuildLLMChatRequestCodegenPHP(t *testing.T) { + req, err := buildLLMChatRequest(aiPromptRequest{Mode: aiModeCodegen, Language: "php", Description: "simple greeting"}) + if err != nil { + t.Fatalf("buildLLMChatRequest error = %v", err) + } + sys := req.Messages[0].Content + for _, want := range []string{"main.php", "main.php::handler", "function handler($context)", "$context[\"response\"]"} { + if !strings.Contains(sys, want) { + t.Fatalf("php system prompt missing %q: %s", want, sys) + } + } +} + +func TestBuildLLMChatRequestCodegenRuby(t *testing.T) { + req, err := buildLLMChatRequest(aiPromptRequest{Mode: aiModeCodegen, Language: "ruby", Description: "return hello"}) + if err != nil { + t.Fatalf("buildLLMChatRequest error = %v", err) + } + sys := req.Messages[0].Content + for _, want := range []string{"handler.rb", "entrypoint handler", "def handler"} { + if !strings.Contains(sys, want) { + t.Fatalf("ruby system prompt missing %q: %s", want, sys) + } + } +} + +func TestBuildLLMChatRequestExplain(t *testing.T) { + req, err := buildLLMChatRequest(aiPromptRequest{ + Mode: aiModeExplain, + Language: "python", + Code: "def main():\n return 'ok'\n", + }) + if err != nil { + t.Fatalf("buildLLMChatRequest error = %v", err) + } + sys := req.Messages[0].Content + usr := req.Messages[1].Content + for _, want := range []string{"объясни", "совместим ли он с Fission", "Если entrypoint", "не переписывай код"} { + if !strings.Contains(strings.ToLower(sys+"\n"+usr), strings.ToLower(want)) { + t.Fatalf("explain prompt missing %q: sys=%s usr=%s", want, sys, usr) + } + } +} + +func TestBuildLLMChatRequestChat(t *testing.T) { + req, err := buildLLMChatRequest(aiPromptRequest{Mode: aiModeChat, Question: "что делает слой 2?"}) + if err != nil { + t.Fatalf("buildLLMChatRequest error = %v", err) + } + if len(req.Messages) != 2 { + t.Fatalf("expected 2 messages, got %d", len(req.Messages)) + } + if req.Messages[1].Content != "что делает слой 2?" { + t.Fatalf("chat question mismatch: %q", req.Messages[1].Content) + } +} + +func TestBuildLLMChatRequestRejectsMissingFields(t *testing.T) { + if _, err := buildLLMChatRequest(aiPromptRequest{Mode: aiModeCodegen, Description: "x"}); err == nil { + t.Fatal("expected codegen without language to fail") + } + if _, err := buildLLMChatRequest(aiPromptRequest{Mode: aiModeExplain, Language: "python"}); err == nil { + t.Fatal("expected explain without code to fail") + } + if _, err := buildLLMChatRequest(aiPromptRequest{Mode: aiModeChat}); err == nil { + t.Fatal("expected chat without question to fail") + } +} + +func TestHandleAIAskUsesCodegenPrompt(t *testing.T) { + var gotReq llmChatRequest + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/chat/completions" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil { + t.Fatalf("decode upstream request: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"choices":[{"message":{"content":"module.exports = async function () { return { status: 200, body: \"ok\" }; }"}}]}`)) + })) + defer server.Close() + + s := &Server{http: server.Client(), llmURL: server.URL, llmKey: "secret"} + req := httptest.NewRequest(http.MethodPost, "/console/api/ai/ask", bytes.NewBufferString(`{"mode":"codegen","language":"python","description":"сделай echo"}`)) + rec := httptest.NewRecorder() + s.handleAIAsk(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + if gotReq.Model != "gpt-oss-120b" { + t.Fatalf("model = %q", gotReq.Model) + } + if len(gotReq.Messages) != 2 { + t.Fatalf("messages len = %d", len(gotReq.Messages)) + } + if !strings.Contains(gotReq.Messages[0].Content, "Python") || !strings.Contains(gotReq.Messages[1].Content, "echo") { + t.Fatalf("unexpected upstream prompts: %#v", gotReq.Messages) + } +} + +func TestHandleAIAskUsesExplainPrompt(t *testing.T) { + var gotReq llmChatRequest + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil { + t.Fatalf("decode upstream request: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"choices":[{"message":{"content":"код делает hello"}}]}`)) + })) + defer server.Close() + + s := &Server{http: server.Client(), llmURL: server.URL, llmKey: "secret"} + req := httptest.NewRequest(http.MethodPost, "/console/api/ai/ask", bytes.NewBufferString(`{"mode":"explain","language":"go","code":"package main\nfunc Handler(w http.ResponseWriter, r *http.Request) {}"}`)) + rec := httptest.NewRecorder() + s.handleAIAsk(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + if !strings.Contains(gotReq.Messages[0].Content, "Go") || !strings.Contains(gotReq.Messages[1].Content, "совместим") { + t.Fatalf("unexpected explain prompts: %#v", gotReq.Messages) + } +} + +func TestHandleAIAskKeepsChatModeSimple(t *testing.T) { + var gotReq llmChatRequest + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil { + t.Fatalf("decode upstream request: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"choices":[{"message":{"content":"ok"}}]}`)) + })) + defer server.Close() + + s := &Server{http: server.Client(), llmURL: server.URL, llmKey: "secret"} + req := httptest.NewRequest(http.MethodPost, "/console/api/ai/ask", bytes.NewBufferString(`{"question":"что такое слой 1?"}`)) + rec := httptest.NewRecorder() + s.handleAIAsk(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + if gotReq.Messages[1].Content != "что такое слой 1?" { + t.Fatalf("chat question mismatch: %#v", gotReq.Messages) + } +} diff --git a/console/ui/index.html b/console/ui/index.html index edff3f2..86a3bed 100644 --- a/console/ui/index.html +++ b/console/ui/index.html @@ -1405,7 +1405,7 @@ var r = await fetch('/console/api/ai/ask', { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders() }, - body: JSON.stringify({ question: q }) + body: JSON.stringify({ mode: 'chat', question: q }) }); var d = await r.json(); msgs.textContent = msgs.textContent.replace('⏳ ...', '🤖 ' + (d.answer || d.error || 'Нет ответа')); @@ -1430,8 +1430,12 @@ resEl.style.display = 'block'; resEl.style.background = 'var(--bg-alt)'; resEl.style.color = 'var(--fg)'; resEl.textContent = 'Запрашиваю у LLM...'; try { - var q = 'Напиши функцию Fission на ' + lang + ' с именем "' + name + '". Функция должна: ' + desc + '. Верни только чистый код без пояснений и без markdown-блоков.'; - var d = await requestJSON('/console/api/ai/ask', 'POST', { question: q }); + var d = await requestJSON('/console/api/ai/ask', 'POST', { + mode: 'codegen', + language: lang, + name: name, + description: desc + }); var code = (d.answer || '').replace(/^```[\w]*\n?/, '').replace(/\n?```$/, ''); document.getElementById('c-code').value = addLLMWarning(code, lang); document.getElementById('c-gen-prompt').style.display = 'none'; @@ -1455,8 +1459,11 @@ resEl.style.display = 'block'; resEl.style.background = 'var(--bg-alt)'; resEl.style.color = 'var(--fg)'; resEl.textContent = 'Запрашиваю у LLM...'; try { - var q = 'Кратко объясни что делает этот ' + lang + ' код:\n' + code; - var d = await requestJSON('/console/api/ai/ask', 'POST', { question: q }); + var d = await requestJSON('/console/api/ai/ask', 'POST', { + mode: 'explain', + language: lang, + code: code + }); resEl.style.background = '#1a2a3a'; resEl.style.color = '#8cf'; resEl.textContent = d.answer || '(пустой ответ)'; } catch (e) {