Files
kami d92349ca6e Store and describe images through a shared media intake (#252)
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.
2026-08-01 04:53:07 +04:00

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)
}
}