aa1a26532c
Maven can record a meeting when she is told to, transcribe it through the STT she already has, and write a summary note. The audio lives in the blob store #252 introduced, under the same retention loop. Nothing here listens. Recorder.Append is the only way audio enters and it refuses every frame unless someone explicitly started a session, so audio arriving at an idle core is dropped rather than buffered. The plan document asked for a keyword trigger ("maven record" heard in the room) and that is refused: noticing a keyword means listening to the room, which is the one behaviour this capability must not have. Off unless configured twice over. No media block means nowhere to keep audio, no capture block means no recorder, and in either case the four IPC methods answer ErrUnknownMethod. On an unconfigured box there is no wire path that begins a recording at all. A forgotten session ends itself at max_minutes, checked on every append, and the audio collected before the cap is kept. Stop with discard set is what "забудь, не записывай" maps to and it leaves nothing behind. The verbatim transcript is not saved unless save_transcript says so; the summary is. Long audio against n_ctx 4096 is handled by map-reduce over 3000-rune windows rather than by truncation, because a truncated meeting summary reads as complete and is not. Transcription is windowed at five minutes so the whisper worker stays responsive to the voice path. No second STT: internal/capture takes the stt.Transcriber the voice path already holds. Capture with voice off is refused rather than degraded, since hours of unreadable audio of other people is worse than no recording. The three write methods are AuthWrite, not AuthStepUp: step-up needs a passkey gesture the voice path cannot make, which would leave "запиши встречу" impossible by voice. capture_status is AuthRead. make build and make test both pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
161 lines
4.9 KiB
Go
161 lines
4.9 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")
|
|
}
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
}
|