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.
148 lines
4.1 KiB
Go
148 lines
4.1 KiB
Go
package vision
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"image"
|
|
"image/png"
|
|
"testing"
|
|
|
|
"github.com/kami/maven/internal/media"
|
|
)
|
|
|
|
type fakeProvider struct {
|
|
reply string
|
|
err error
|
|
seen int
|
|
lastQ string
|
|
lastDim int
|
|
}
|
|
|
|
func (f *fakeProvider) Describe(_ context.Context, im media.Image, prompt string) (string, error) {
|
|
f.seen++
|
|
f.lastQ = prompt
|
|
f.lastDim = im.Width
|
|
return f.reply, f.err
|
|
}
|
|
|
|
func pngPayload(t *testing.T, w, h int) []byte {
|
|
t.Helper()
|
|
var buf bytes.Buffer
|
|
if err := png.Encode(&buf, image.NewRGBA(image.Rect(0, 0, w, h))); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return buf.Bytes()
|
|
}
|
|
|
|
func testIntake(t *testing.T, p Provider) (*Intake, *media.Store) {
|
|
t.Helper()
|
|
s, err := media.Open(t.TempDir(), 0, 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return NewIntake(s, p, 64), s
|
|
}
|
|
|
|
func TestAcceptStoresThenDescribes(t *testing.T) {
|
|
fp := &fakeProvider{reply: "кот на подоконнике"}
|
|
in, store := testIntake(t, fp)
|
|
|
|
res, err := in.Accept(context.Background(), pngPayload(t, 200, 100), "telegram", "кто это?")
|
|
if err != nil {
|
|
t.Fatalf("accept: %v", err)
|
|
}
|
|
if res.Description != "кот на подоконнике" {
|
|
t.Errorf("description = %q", res.Description)
|
|
}
|
|
if fp.lastQ != "кто это?" {
|
|
t.Errorf("question not passed through: %q", fp.lastQ)
|
|
}
|
|
if fp.lastDim != 64 {
|
|
t.Errorf("image not downscaled to maxDim: width %d", fp.lastDim)
|
|
}
|
|
// The sniffed mime wins over anything a sender claimed.
|
|
got, _, err := store.Read(res.Blob.ID)
|
|
if err != nil {
|
|
t.Fatalf("blob not stored: %v", err)
|
|
}
|
|
if got.MIME != "image/png" || got.Source != "telegram" {
|
|
t.Errorf("blob metadata = %+v", got)
|
|
}
|
|
}
|
|
|
|
// The ordering promise: with no vision model on the box — today's real state —
|
|
// the image is still on disk and the id is still reported, so it can be
|
|
// described later instead of being lost.
|
|
func TestAcceptKeepsBlobWhenDescribeFails(t *testing.T) {
|
|
in, store := testIntake(t, Disabled{})
|
|
res, err := in.Accept(context.Background(), pngPayload(t, 32, 32), "web:upload", "")
|
|
if !errors.Is(err, ErrDisabled) {
|
|
t.Fatalf("got %v, want ErrDisabled", err)
|
|
}
|
|
if res.Blob.ID == "" {
|
|
t.Fatal("no blob id reported on a description failure")
|
|
}
|
|
if _, _, err := store.Read(res.Blob.ID); err != nil {
|
|
t.Errorf("blob was not kept: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRerunDescribesAStoredBlob(t *testing.T) {
|
|
fp := &fakeProvider{reply: "текст: ошибка E24"}
|
|
in, _ := testIntake(t, fp)
|
|
first, err := in.Accept(context.Background(), pngPayload(t, 40, 40), "telegram", "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
res, err := in.Rerun(context.Background(), first.Blob.ID, "прочитай текст")
|
|
if err != nil {
|
|
t.Fatalf("rerun: %v", err)
|
|
}
|
|
if res.Description != "текст: ошибка E24" {
|
|
t.Errorf("description = %q", res.Description)
|
|
}
|
|
if fp.lastQ != "прочитай текст" {
|
|
t.Errorf("new question not used: %q", fp.lastQ)
|
|
}
|
|
if fp.seen != 2 {
|
|
t.Errorf("provider called %d times, want 2", fp.seen)
|
|
}
|
|
}
|
|
|
|
func TestRerunRefusesAudioBlob(t *testing.T) {
|
|
in, store := testIntake(t, &fakeProvider{reply: "x"})
|
|
b, err := store.Put(media.KindAudio, "audio/wav", "capture:meeting", []byte("pcm bytes"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := in.Rerun(context.Background(), b.ID, ""); err == nil {
|
|
t.Error("audio blob was accepted as an image")
|
|
}
|
|
}
|
|
|
|
func TestRerunUnknownID(t *testing.T) {
|
|
in, _ := testIntake(t, &fakeProvider{})
|
|
if _, err := in.Rerun(context.Background(), "nope", ""); err == nil {
|
|
t.Error("malformed id accepted")
|
|
}
|
|
}
|
|
|
|
func TestAcceptRefusesNonImage(t *testing.T) {
|
|
in, _ := testIntake(t, &fakeProvider{})
|
|
if _, err := in.Accept(context.Background(), []byte("this is a text file"), "web:upload", ""); !errors.Is(err, media.ErrUnsupportedImage) {
|
|
t.Errorf("got %v, want ErrUnsupportedImage", err)
|
|
}
|
|
}
|
|
|
|
func TestNilProviderDegradesToDisabled(t *testing.T) {
|
|
s, err := media.Open(t.TempDir(), 0, 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
in := NewIntake(s, nil, 0)
|
|
if _, err := in.Accept(context.Background(), pngPayload(t, 8, 8), "x", ""); !errors.Is(err, ErrDisabled) {
|
|
t.Errorf("got %v, want ErrDisabled", err)
|
|
}
|
|
}
|