2ca5ffa4f9
capture_stop held the IPC request open for the whole map reduce, up to twenty minutes. A voice turn that says "хватит" waited for forty model calls before Maven said anything. Stop now returns the transcript and the summary runs on a goroutine in the daemon's WaitGroup, on the daemon context so a client that hung up does not cancel the only readable record of the meeting. With no summary and save_transcript false, writeNotes wrote nothing at all: an hour of meeting left a blob that prunes in seven days and no trace in the note store. The transcript is written instead when the summary is missing. That flag decides whether the verbatim record is kept in addition to a summary, not whether the meeting is remembered. The wire carries the session token now, and the contract comments say what the code does: the summary is usually absent from the stop response, and re running a stored blob is a manual job because no method takes a blob id. The save_transcript comment says the cost is recall corpus rather than disk. Found in review of #73.
261 lines
8.7 KiB
Go
261 lines
8.7 KiB
Go
package config
|
|
|
|
import (
|
|
"encoding/json"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// Absent blocks must read as off on a nil receiver: the daemon calls these
|
|
// helpers before it knows whether the operator configured anything.
|
|
func TestSensesOffByDefault(t *testing.T) {
|
|
var cfg Config
|
|
if cfg.Media.StoreDir() != "" {
|
|
t.Error("media store dir is set with no media block")
|
|
}
|
|
if cfg.Vision.LooksAtImages() {
|
|
t.Error("vision is on with no vision block")
|
|
}
|
|
if cfg.Capture.Records() {
|
|
t.Error("the recorder is on with no capture block")
|
|
}
|
|
if cfg.Capture.MaxDuration() != 0 {
|
|
t.Error("a nil capture block invented a duration")
|
|
}
|
|
if cfg.Speaker.Recognizes() {
|
|
t.Error("speaker recognition is on with no speaker block")
|
|
}
|
|
}
|
|
|
|
// The recorder is the capability that most needs its default to be off, so it
|
|
// gets its own test rather than a line in the one above.
|
|
func TestCaptureIsOffUntilExplicitlyEnabled(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
c *CaptureConfig
|
|
want bool
|
|
}{
|
|
{"absent", nil, false},
|
|
{"present but not enabled", &CaptureConfig{MaxMinutes: 60}, false},
|
|
{"enabled", &CaptureConfig{Enabled: true}, true},
|
|
}
|
|
for _, c := range cases {
|
|
if got := c.c.Records(); got != c.want {
|
|
t.Errorf("%s: Records() = %v, want %v", c.name, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCaptureBlockParsesFromJSON(t *testing.T) {
|
|
raw := `{"capture":{"enabled":true,"max_minutes":45,"stt_window":"2m",
|
|
"chunk_runes":2000,"max_chunks":10,"save_transcript":true}}`
|
|
var cfg Config
|
|
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
if !cfg.Capture.Records() {
|
|
t.Fatal("capture did not parse as enabled")
|
|
}
|
|
if cfg.Capture.MaxDuration() != 45*time.Minute {
|
|
t.Errorf("max duration = %v", cfg.Capture.MaxDuration())
|
|
}
|
|
if time.Duration(cfg.Capture.STTWindow) != 2*time.Minute {
|
|
t.Errorf("stt window = %v", time.Duration(cfg.Capture.STTWindow))
|
|
}
|
|
if cfg.Capture.ChunkRunes != 2000 || cfg.Capture.MaxChunks != 10 {
|
|
t.Errorf("summariser limits = %+v", cfg.Capture)
|
|
}
|
|
if !cfg.Capture.SaveTranscript {
|
|
t.Error("save_transcript did not parse")
|
|
}
|
|
}
|
|
|
|
// Keeping the verbatim record of what other people said is the heavier act, so
|
|
// it is separately opt-in from recording at all.
|
|
func TestTranscriptIsNotSavedByDefault(t *testing.T) {
|
|
var cfg Config
|
|
if err := json.Unmarshal([]byte(`{"capture":{"enabled":true}}`), &cfg); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if cfg.Capture.SaveTranscript {
|
|
t.Error("transcripts are saved without anyone asking")
|
|
}
|
|
if cfg.Capture.MaxDuration() != 0 {
|
|
t.Error("max_minutes defaulted in config instead of in the package")
|
|
}
|
|
}
|
|
|
|
// enabled with nothing to talk to is a misconfiguration, not a capability.
|
|
func TestVisionNeedsBothEnabledAndEndpoint(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
v *VisionConfig
|
|
want bool
|
|
}{
|
|
{"absent", nil, false},
|
|
{"endpoint but not enabled", &VisionConfig{Endpoint: "http://127.0.0.1:8081"}, false},
|
|
{"enabled but no endpoint", &VisionConfig{Enabled: true}, false},
|
|
{"enabled, blank endpoint", &VisionConfig{Enabled: true, Endpoint: " "}, false},
|
|
{"both", &VisionConfig{Enabled: true, Endpoint: "http://127.0.0.1:8081"}, true},
|
|
}
|
|
for _, c := range cases {
|
|
if got := c.v.LooksAtImages(); got != c.want {
|
|
t.Errorf("%s: LooksAtImages() = %v, want %v", c.name, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSensesBlocksParseFromJSON(t *testing.T) {
|
|
raw := `{
|
|
"db_path": "/tmp/x.db",
|
|
"socket_path": "/tmp/x.sock",
|
|
"media": {"dir": "media", "retention": "48h", "max_bytes": 1048576},
|
|
"vision": {
|
|
"enabled": true,
|
|
"endpoint": "http://127.0.0.1:8081",
|
|
"model": "qwen2.5-vl",
|
|
"max_dim": 640,
|
|
"max_tokens": 200,
|
|
"timeout": "45s",
|
|
"prompt": "Что тут?"
|
|
}
|
|
}`
|
|
var cfg Config
|
|
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
if cfg.Media.StoreDir() != "media" {
|
|
t.Errorf("media dir = %q", cfg.Media.StoreDir())
|
|
}
|
|
if time.Duration(cfg.Media.Retention) != 48*time.Hour {
|
|
t.Errorf("retention = %v", time.Duration(cfg.Media.Retention))
|
|
}
|
|
if cfg.Media.MaxBytes != 1<<20 {
|
|
t.Errorf("max_bytes = %d", cfg.Media.MaxBytes)
|
|
}
|
|
if !cfg.Vision.LooksAtImages() {
|
|
t.Fatal("vision did not parse as enabled")
|
|
}
|
|
if cfg.Vision.MaxDim != 640 || cfg.Vision.MaxTokens != 200 {
|
|
t.Errorf("vision limits = %+v", cfg.Vision)
|
|
}
|
|
if time.Duration(cfg.Vision.Timeout) != 45*time.Second {
|
|
t.Errorf("vision timeout = %v", time.Duration(cfg.Vision.Timeout))
|
|
}
|
|
if cfg.Vision.Prompt != "Что тут?" {
|
|
t.Errorf("prompt = %q", cfg.Vision.Prompt)
|
|
}
|
|
}
|
|
|
|
// A media dir set with no vision block is a valid state, and the useful one on a
|
|
// box with no vision model: images can be kept, they just cannot be described.
|
|
func TestMediaWithoutVisionIsValid(t *testing.T) {
|
|
var cfg Config
|
|
if err := json.Unmarshal([]byte(`{"media":{"dir":"/srv/media"}}`), &cfg); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if cfg.Media.StoreDir() != "/srv/media" {
|
|
t.Errorf("dir = %q", cfg.Media.StoreDir())
|
|
}
|
|
if cfg.Vision.LooksAtImages() {
|
|
t.Error("vision came on by itself")
|
|
}
|
|
}
|
|
|
|
// A voiceprint is a biometric of a named person. Nothing about it turns on by
|
|
// itself: no speaker block means no recognition, and no enrolment either.
|
|
func TestSpeakerIsOffUntilExplicitlyEnabled(t *testing.T) {
|
|
var cfg Config
|
|
if err := json.Unmarshal([]byte(`{}`), &cfg); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if cfg.Speaker.Recognizes() {
|
|
t.Error("speaker recognition came on with no config at all")
|
|
}
|
|
var empty Config
|
|
if err := json.Unmarshal([]byte(`{"speaker":{}}`), &empty); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if empty.Speaker.Recognizes() {
|
|
t.Error("an empty speaker block enabled recognition")
|
|
}
|
|
}
|
|
|
|
// Enabled alone is not enough: recognition needs a model, and on this box there
|
|
// is none. Recognizes() must stay false so the daemon reports the honest state
|
|
// instead of claiming a capability it cannot perform.
|
|
func TestSpeakerNeedsBothEnabledAndAModel(t *testing.T) {
|
|
var cfg Config
|
|
if err := json.Unmarshal([]byte(`{"speaker":{"enabled":true}}`), &cfg); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if cfg.Speaker.Recognizes() {
|
|
t.Error("enabled with no model_path claimed to recognise")
|
|
}
|
|
var only Config
|
|
if err := json.Unmarshal([]byte(`{"speaker":{"model_path":"/opt/x.onnx"}}`), &only); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if only.Speaker.Recognizes() {
|
|
t.Error("a model_path alone enabled recognition")
|
|
}
|
|
}
|
|
|
|
func TestSpeakerBlockParsesFromJSON(t *testing.T) {
|
|
const raw = `{"speaker":{"enabled":true,"model_path":"/opt/maven/models/spk/ecapa.onnx",` +
|
|
`"lib_path":"/opt/maven/lib","threshold":0.62,"min_seconds":1.5}}`
|
|
var cfg Config
|
|
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
if !cfg.Speaker.Recognizes() {
|
|
t.Fatal("speaker did not parse as enabled")
|
|
}
|
|
if cfg.Speaker.ModelPath != "/opt/maven/models/spk/ecapa.onnx" {
|
|
t.Errorf("model_path = %q", cfg.Speaker.ModelPath)
|
|
}
|
|
if cfg.Speaker.LibPath != "/opt/maven/lib" {
|
|
t.Errorf("lib_path = %q", cfg.Speaker.LibPath)
|
|
}
|
|
if cfg.Speaker.Threshold != 0.62 || cfg.Speaker.MinSeconds != 1.5 {
|
|
t.Errorf("thresholds = %+v", cfg.Speaker)
|
|
}
|
|
}
|
|
|
|
// A typo in the vision endpoint, or a media block with no dir, used to be
|
|
// logged once at wiring time and the capability just stayed off. A capability
|
|
// that silently does not exist is the hardest misconfiguration to notice, so
|
|
// both fail at startup now.
|
|
func TestSensesBlocksAreValidatedAtStartup(t *testing.T) {
|
|
bad := map[string]string{
|
|
"media with no dir": `{"media":{"retention":"48h"}}`,
|
|
"negative budget": `{"media":{"dir":"/srv/media","max_total_bytes":-1}}`,
|
|
"blob over the budget": `{"media":{"dir":"/srv/media","max_bytes":100,"max_total_bytes":10}}`,
|
|
"vision with no media dir": `{"vision":{"enabled":true,"endpoint":"http://127.0.0.1:8081"}}`,
|
|
"vision endpoint typo": `{"media":{"dir":"/srv/media"},"vision":{"enabled":true,"endpoint":"127.0.0.1:8081"}}`,
|
|
"vision on the wan": `{"media":{"dir":"/srv/media"},"vision":{"enabled":true,"endpoint":"http://8.8.8.8:8081"}}`,
|
|
"vision, empty endpoint": `{"media":{"dir":"/srv/media"},"vision":{"enabled":true}}`,
|
|
"capture with no store": `{"capture":{"enabled":true}}`,
|
|
}
|
|
for name, body := range bad {
|
|
t.Run(name, func(t *testing.T) {
|
|
if _, err := Load(writeConfig(t, body)); err == nil {
|
|
t.Fatal("want a startup error")
|
|
}
|
|
})
|
|
}
|
|
good := map[string]string{
|
|
"media alone": `{"media":{"dir":"/srv/media"}}`,
|
|
"media + vision": `{"media":{"dir":"/srv/media"},"vision":{"enabled":true,"endpoint":"http://127.0.0.1:8081"}}`,
|
|
"media + capture": `{"media":{"dir":"/srv/media"},"capture":{"enabled":true}}`,
|
|
"vision off": `{"vision":{"endpoint":"http://8.8.8.8:8081"}}`,
|
|
}
|
|
for name, body := range good {
|
|
t.Run(name, func(t *testing.T) {
|
|
if _, err := Load(writeConfig(t, body)); err != nil {
|
|
t.Fatalf("valid config refused: %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|