d92349ca6e
Vision needs a second model this box does not have, so the shipped half is the part that works without one: an image arrives, is sniffed, is stored content-addressed, and is prepared for inference. The describing half is written and tested against a fake server, and refuses any endpoint that is not on this box. internal/media is the intake all three senses share — hearing and speaker recognition store their audio in the same place under the same retention. Blobs stay out of the sqlite store; only the derived text becomes a note, and only when the caller asks. Retention is enforced by an hourly prune loop rather than by a comment. The plan's RemoteProvider step is refused: no cloud model, inference stays on the box, and vision.NewLocal validates that at construction.
199 lines
6.1 KiB
Go
199 lines
6.1 KiB
Go
package vision
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"image"
|
|
"image/png"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/kami/maven/internal/media"
|
|
)
|
|
|
|
func testImage(t *testing.T) media.Image {
|
|
t.Helper()
|
|
var buf bytes.Buffer
|
|
if err := png.Encode(&buf, image.NewRGBA(image.Rect(0, 0, 32, 32))); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
im, err := media.PrepareImage(buf.Bytes(), "test", 32)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return im
|
|
}
|
|
|
|
func TestDisabledAlwaysRefuses(t *testing.T) {
|
|
_, err := Disabled{}.Describe(context.Background(), testImage(t), "что тут?")
|
|
if !errors.Is(err, ErrDisabled) {
|
|
t.Fatalf("got %v, want ErrDisabled", err)
|
|
}
|
|
}
|
|
|
|
// The whole reason this package has its own HTTP client instead of reusing
|
|
// internal/llm.Client: a vision endpoint that is not on this box is refused.
|
|
func TestNewLocalRefusesNonPrivateEndpoints(t *testing.T) {
|
|
bad := []string{
|
|
"https://api.openai.com",
|
|
"http://8.8.8.8:8080",
|
|
"https://vision.example.com", // a name could resolve anywhere
|
|
"ftp://127.0.0.1:8080", // wrong scheme
|
|
"", // nothing to talk to
|
|
}
|
|
for _, ep := range bad {
|
|
if _, err := NewLocal(Config{Endpoint: ep}); err == nil {
|
|
t.Errorf("NewLocal(%q) was accepted", ep)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNewLocalAcceptsLocalEndpoints(t *testing.T) {
|
|
for _, ep := range []string{"http://127.0.0.1:8081", "http://localhost:8081/", "http://192.168.1.104:8081", "http://[::1]:8081"} {
|
|
p, err := NewLocal(Config{Endpoint: ep})
|
|
if err != nil {
|
|
t.Errorf("NewLocal(%q): %v", ep, err)
|
|
continue
|
|
}
|
|
if strings.HasSuffix(p.Endpoint(), "/") {
|
|
t.Errorf("trailing slash kept: %q", p.Endpoint())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDescribeSendsImageAsDataURIAndReturnsText(t *testing.T) {
|
|
var gotBody map[string]any
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/v1/chat/completions" {
|
|
t.Errorf("path = %s", r.URL.Path)
|
|
}
|
|
raw, _ := io.ReadAll(r.Body)
|
|
if err := json.Unmarshal(raw, &gotBody); err != nil {
|
|
t.Errorf("unmarshal request: %v", err)
|
|
}
|
|
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":" На картинке кот "}}]}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
p, err := NewLocal(Config{Endpoint: srv.URL, Model: "qwen-vl"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
text, err := p.Describe(context.Background(), testImage(t), "кто на фото?")
|
|
if err != nil {
|
|
t.Fatalf("describe: %v", err)
|
|
}
|
|
if text != "На картинке кот" {
|
|
t.Errorf("text = %q (should be trimmed)", text)
|
|
}
|
|
|
|
msgs, ok := gotBody["messages"].([]any)
|
|
if !ok || len(msgs) != 1 {
|
|
t.Fatalf("messages = %#v", gotBody["messages"])
|
|
}
|
|
parts, ok := msgs[0].(map[string]any)["content"].([]any)
|
|
if !ok || len(parts) != 2 {
|
|
t.Fatalf("content parts = %#v", msgs[0])
|
|
}
|
|
if got := parts[0].(map[string]any)["text"]; got != "кто на фото?" {
|
|
t.Errorf("prompt = %v", got)
|
|
}
|
|
url := parts[1].(map[string]any)["image_url"].(map[string]any)["url"].(string)
|
|
if !strings.HasPrefix(url, "data:image/jpeg;base64,") {
|
|
t.Errorf("image not sent as a jpeg data uri: %.40s", url)
|
|
}
|
|
}
|
|
|
|
func TestDescribeUsesDefaultPromptWhenNoQuestion(t *testing.T) {
|
|
var sentPrompt string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
var body struct {
|
|
Messages []struct {
|
|
Content []struct {
|
|
Text string `json:"text"`
|
|
} `json:"content"`
|
|
} `json:"messages"`
|
|
}
|
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
|
sentPrompt = body.Messages[0].Content[0].Text
|
|
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"ок"}}]}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
p, err := NewLocal(Config{Endpoint: srv.URL, Prompt: "Опиши по-русски."})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := p.Describe(context.Background(), testImage(t), " "); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if sentPrompt != "Опиши по-русски." {
|
|
t.Errorf("prompt = %q", sentPrompt)
|
|
}
|
|
}
|
|
|
|
// A Thinking model sometimes leaves content empty and puts the answer in
|
|
// reasoning_content; internal/llm has the same fallback and vision needs it too.
|
|
func TestDescribeFallsBackToReasoningContent(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"","reasoning_content":"схема платы"}}]}`))
|
|
}))
|
|
defer srv.Close()
|
|
p, _ := NewLocal(Config{Endpoint: srv.URL})
|
|
text, err := p.Describe(context.Background(), testImage(t), "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if text != "схема платы" {
|
|
t.Errorf("text = %q", text)
|
|
}
|
|
}
|
|
|
|
func TestDescribeErrors(t *testing.T) {
|
|
t.Run("no choices", func(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
_, _ = w.Write([]byte(`{"choices":[]}`))
|
|
}))
|
|
defer srv.Close()
|
|
p, _ := NewLocal(Config{Endpoint: srv.URL})
|
|
if _, err := p.Describe(context.Background(), testImage(t), ""); !errors.Is(err, ErrEmptyReply) {
|
|
t.Errorf("got %v, want ErrEmptyReply", err)
|
|
}
|
|
})
|
|
t.Run("server error", func(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
}))
|
|
defer srv.Close()
|
|
p, _ := NewLocal(Config{Endpoint: srv.URL})
|
|
if _, err := p.Describe(context.Background(), testImage(t), ""); err == nil {
|
|
t.Error("500 was not an error")
|
|
}
|
|
})
|
|
t.Run("empty image", func(t *testing.T) {
|
|
p, _ := NewLocal(Config{Endpoint: "http://127.0.0.1:1"})
|
|
if _, err := p.Describe(context.Background(), media.Image{}, ""); !errors.Is(err, media.ErrEmpty) {
|
|
t.Errorf("got %v, want media.ErrEmpty", err)
|
|
}
|
|
})
|
|
t.Run("context cancelled", func(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
time.Sleep(200 * time.Millisecond)
|
|
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"поздно"}}]}`))
|
|
}))
|
|
defer srv.Close()
|
|
p, _ := NewLocal(Config{Endpoint: srv.URL})
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
|
|
defer cancel()
|
|
if _, err := p.Describe(ctx, testImage(t), ""); err == nil {
|
|
t.Error("cancelled context returned no error")
|
|
}
|
|
})
|
|
}
|