feat: add fission-aware ai prompts
This commit is contained in:
@@ -142,26 +142,18 @@ func (s *Server) handleAIAsk(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var req struct {
|
var req aiPromptRequest
|
||||||
Question string `json:"question"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(io.LimitReader(r.Body, 4*1024)).Decode(&req); err != nil {
|
if err := json.NewDecoder(io.LimitReader(r.Body, 4*1024)).Decode(&req); err != nil {
|
||||||
writeJSONError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
writeJSONError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(req.Question) == "" {
|
chatReq, err := buildLLMChatRequest(req)
|
||||||
writeJSONError(w, http.StatusBadRequest, "question is required")
|
if err != nil {
|
||||||
|
writeJSONError(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
body, _ := json.Marshal(map[string]any{
|
body, _ := json.Marshal(chatReq)
|
||||||
"model": "gpt-oss-120b",
|
|
||||||
"messages": []map[string]string{
|
|
||||||
{"role": "system", "content": "Ты умный ассистент. Отвечай кратко и по делу."},
|
|
||||||
{"role": "user", "content": req.Question},
|
|
||||||
},
|
|
||||||
"max_tokens": 1024,
|
|
||||||
})
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
-5
@@ -1405,7 +1405,7 @@
|
|||||||
var r = await fetch('/console/api/ai/ask', {
|
var r = await fetch('/console/api/ai/ask', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||||
body: JSON.stringify({ question: q })
|
body: JSON.stringify({ mode: 'chat', question: q })
|
||||||
});
|
});
|
||||||
var d = await r.json();
|
var d = await r.json();
|
||||||
msgs.textContent = msgs.textContent.replace('⏳ ...', '🤖 ' + (d.answer || d.error || 'Нет ответа'));
|
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.style.display = 'block'; resEl.style.background = 'var(--bg-alt)'; resEl.style.color = 'var(--fg)';
|
||||||
resEl.textContent = 'Запрашиваю у LLM...';
|
resEl.textContent = 'Запрашиваю у LLM...';
|
||||||
try {
|
try {
|
||||||
var q = 'Напиши функцию Fission на ' + lang + ' с именем "' + name + '". Функция должна: ' + desc + '. Верни только чистый код без пояснений и без markdown-блоков.';
|
var d = await requestJSON('/console/api/ai/ask', 'POST', {
|
||||||
var d = await requestJSON('/console/api/ai/ask', 'POST', { question: q });
|
mode: 'codegen',
|
||||||
|
language: lang,
|
||||||
|
name: name,
|
||||||
|
description: desc
|
||||||
|
});
|
||||||
var code = (d.answer || '').replace(/^```[\w]*\n?/, '').replace(/\n?```$/, '');
|
var code = (d.answer || '').replace(/^```[\w]*\n?/, '').replace(/\n?```$/, '');
|
||||||
document.getElementById('c-code').value = addLLMWarning(code, lang);
|
document.getElementById('c-code').value = addLLMWarning(code, lang);
|
||||||
document.getElementById('c-gen-prompt').style.display = 'none';
|
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.style.display = 'block'; resEl.style.background = 'var(--bg-alt)'; resEl.style.color = 'var(--fg)';
|
||||||
resEl.textContent = 'Запрашиваю у LLM...';
|
resEl.textContent = 'Запрашиваю у LLM...';
|
||||||
try {
|
try {
|
||||||
var q = 'Кратко объясни что делает этот ' + lang + ' код:\n' + code;
|
var d = await requestJSON('/console/api/ai/ask', 'POST', {
|
||||||
var d = await requestJSON('/console/api/ai/ask', 'POST', { question: q });
|
mode: 'explain',
|
||||||
|
language: lang,
|
||||||
|
code: code
|
||||||
|
});
|
||||||
resEl.style.background = '#1a2a3a'; resEl.style.color = '#8cf';
|
resEl.style.background = '#1a2a3a'; resEl.style.color = '#8cf';
|
||||||
resEl.textContent = d.answer || '(пустой ответ)';
|
resEl.textContent = d.answer || '(пустой ответ)';
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
Reference in New Issue
Block a user