1b6d51dc71
Pure move, plus a note on SpeakerConfig.LibPath: it is the one field in the tree with no reader, no default and no validation, because cmd/mavend's newSpeakerEmbedder discards the whole block — there is no speaker model on this box. It stays declared so a block written from the plan document matches, and the comment now says so rather than leaving the next reader to grep for it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
241 lines
9.7 KiB
Go
241 lines
9.7 KiB
Go
package config
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/kami/maven/internal/vision"
|
|
)
|
|
|
|
// The senses: seeing, hearing, and knowing who spoke. All three are off unless
|
|
// someone typed a path on purpose, and all three depend on the media block —
|
|
// nothing in this repo holds an image or a recording only in memory.
|
|
|
|
// MediaConfig — the on-disk blob store for images and captured audio
|
|
// (internal/media). It is shared by all three senses: vision intake, meeting
|
|
// capture, and speaker enrolment samples all write here.
|
|
//
|
|
// Absent ⇒ off, and off means Maven cannot accept an image or start a recording
|
|
// at all. That default is deliberate: a capability that keeps photos and audio of
|
|
// people on disk should require someone to have typed a path.
|
|
type MediaConfig struct {
|
|
// Dir — the blob store root, created 0700. Relative paths resolve against
|
|
// StateDir. Required; an empty dir means the store is not wired.
|
|
Dir string `json:"dir,omitempty"`
|
|
|
|
// Retention — how long a blob is kept before the tick prunes it. 0 ⇒
|
|
// media.DefaultRetention (7 days). This is the knob that stops recordings
|
|
// of people accumulating; raising it past a few weeks should need a reason.
|
|
Retention Duration `json:"retention,omitempty"`
|
|
|
|
// MaxBytes — per-blob cap. 0 ⇒ media.DefaultMaxBytes (64 MiB).
|
|
MaxBytes int64 `json:"max_bytes,omitempty"`
|
|
|
|
// MaxTotalBytes — whole-store cap. 0 ⇒ media.DefaultMaxTotalBytes (4 GiB).
|
|
// The per-blob cap bounds one call; this one bounds the sum of them, which
|
|
// is what actually decides whether the disk mavend's database lives on can
|
|
// be filled from outside.
|
|
MaxTotalBytes int64 `json:"max_total_bytes,omitempty"`
|
|
}
|
|
|
|
// StoreDir reports the configured blob directory, or "" when media is not
|
|
// wired. Safe on a nil receiver.
|
|
func (m *MediaConfig) StoreDir() string {
|
|
if m == nil {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(m.Dir)
|
|
}
|
|
|
|
// validateMedia fails a media dir that cannot be created here rather than at
|
|
// wiring time. A capability silently not existing is the hardest kind of
|
|
// misconfiguration to notice.
|
|
func (c *Config) validateMedia() error {
|
|
if c.Media == nil {
|
|
return nil
|
|
}
|
|
if c.Media.StoreDir() == "" {
|
|
return errors.New("media.dir is required when a media block is present")
|
|
}
|
|
if c.Media.MaxBytes < 0 || c.Media.MaxTotalBytes < 0 {
|
|
return errors.New("media: max_bytes and max_total_bytes cannot be negative")
|
|
}
|
|
if c.Media.MaxTotalBytes > 0 && c.Media.MaxBytes > c.Media.MaxTotalBytes {
|
|
return fmt.Errorf("media: max_bytes %d is above max_total_bytes %d",
|
|
c.Media.MaxBytes, c.Media.MaxTotalBytes)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// VisionConfig — the vision provider (internal/vision, docs/plans/07-vision.md).
|
|
//
|
|
// Absent, or enabled=false, ⇒ the daemon wires vision.Disabled and every attempt
|
|
// to look at an image answers that vision is not set up. There is no cloud
|
|
// option in this block on purpose: Endpoint must be a loopback or private
|
|
// address and internal/vision refuses anything else at startup, because
|
|
// inference stays on the box and a photo of his flat is the last thing to make
|
|
// an exception for.
|
|
type VisionConfig struct {
|
|
// Enabled — may she look at images. Default false.
|
|
Enabled bool `json:"enabled,omitempty"`
|
|
|
|
// Endpoint — base URL of a llama-server running a vision model with its
|
|
// mmproj, e.g. "http://127.0.0.1:8081". Loopback / private only.
|
|
Endpoint string `json:"endpoint,omitempty"`
|
|
|
|
// Model — model name sent in the request. llama-server ignores it.
|
|
Model string `json:"model,omitempty"`
|
|
|
|
// MaxDim — longest edge the image is scaled to before inference. 0 ⇒
|
|
// media.DefaultMaxDim (896).
|
|
MaxDim int `json:"max_dim,omitempty"`
|
|
|
|
// MaxTokens — cap on the description. 0 ⇒ vision.DefaultMaxTokens (300).
|
|
MaxTokens int `json:"max_tokens,omitempty"`
|
|
|
|
// Timeout — per-description budget. 0 ⇒ vision.DefaultTimeout (90s). A small
|
|
// VLM on an iGPU is slow; a tight timeout here just means no answer ever.
|
|
Timeout Duration `json:"timeout,omitempty"`
|
|
|
|
// Prompt — the default question when he only sent a picture. Empty ⇒
|
|
// vision.DefaultPrompt (Russian, "опиши что на изображении").
|
|
Prompt string `json:"prompt,omitempty"`
|
|
}
|
|
|
|
// LooksAtImages reports whether vision is configured well enough to try. Safe on
|
|
// a nil receiver, and false without an endpoint — enabled with nothing to talk
|
|
// to is a misconfiguration, not a capability.
|
|
func (v *VisionConfig) LooksAtImages() bool {
|
|
return v != nil && v.Enabled && strings.TrimSpace(v.Endpoint) != ""
|
|
}
|
|
|
|
// validateVision fails an endpoint that is a typo, or a vision block with
|
|
// nowhere to keep the bytes, at startup.
|
|
func (c *Config) validateVision() error {
|
|
if c.Vision == nil || !c.Vision.Enabled {
|
|
return nil
|
|
}
|
|
if strings.TrimSpace(c.Vision.Endpoint) == "" {
|
|
return errors.New("vision.enabled set but vision.endpoint is empty")
|
|
}
|
|
if err := vision.ValidateEndpoint(c.Vision.Endpoint); err != nil {
|
|
return err
|
|
}
|
|
if c.Media.StoreDir() == "" {
|
|
return errors.New("vision.enabled set but there is no media block to keep the bytes in")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// CaptureConfig — the meeting recorder (internal/capture,
|
|
// docs/plans/08-hearing.md).
|
|
//
|
|
// Absent, or enabled=false, ⇒ the recorder is not wired and the capture methods
|
|
// return "unknown method", so no client can start a recording however it asks.
|
|
// A media block is required too: audio is never held only in memory.
|
|
//
|
|
// There is deliberately no "auto", no keyword trigger and no duration default
|
|
// long enough to be forgotten about. Recording other people is an explicit act
|
|
// with a start, a stop, and a cap.
|
|
type CaptureConfig struct {
|
|
// Enabled — may she record a meeting when asked. Default false.
|
|
Enabled bool `json:"enabled,omitempty"`
|
|
|
|
// MaxMinutes — hard cap on one session; it stops itself there. 0 ⇒
|
|
// capture.DefaultMaxDuration (120 minutes).
|
|
MaxMinutes int `json:"max_minutes,omitempty"`
|
|
|
|
// STTWindow — audio handed to whisper per call. 0 ⇒
|
|
// capture.DefaultSTTWindow (5m). Larger windows transcribe slightly better
|
|
// and block the STT worker for longer.
|
|
STTWindow Duration `json:"stt_window,omitempty"`
|
|
|
|
// ChunkRunes — transcript runes per summarisation prompt. 0 ⇒
|
|
// capture.DefaultChunkRunes (3000), sized for the resident model's n_ctx of
|
|
// 4096. Raise this only if the resident model's context grows.
|
|
ChunkRunes int `json:"chunk_runes,omitempty"`
|
|
|
|
// MaxChunks — how many windows one meeting may be summarised in before the
|
|
// transcript is truncated and the summary says so. 0 ⇒
|
|
// capture.DefaultMaxChunks (40).
|
|
MaxChunks int `json:"max_chunks,omitempty"`
|
|
|
|
// SaveTranscript — write the full transcript as a note alongside the
|
|
// summary. Default false, and the cost is not disk: a note is embedded and
|
|
// becomes recall corpus, so every later question can surface verbatim words
|
|
// other people said in a room. That is the reason it takes a deliberate yes.
|
|
// The audio blob is pruned by media.retention either way; the notes are not.
|
|
//
|
|
// A meeting with no summary writes its transcript regardless. The choice
|
|
// here is transcript IN ADDITION to a summary, not whether the meeting is
|
|
// remembered at all.
|
|
SaveTranscript bool `json:"save_transcript,omitempty"`
|
|
}
|
|
|
|
// Records reports whether the recorder should be wired. Safe on a nil receiver.
|
|
func (c *CaptureConfig) Records() bool {
|
|
return c != nil && c.Enabled
|
|
}
|
|
|
|
// MaxDuration is the configured session cap as a duration, or 0 for the
|
|
// package default. Safe on a nil receiver.
|
|
func (c *CaptureConfig) MaxDuration() time.Duration {
|
|
if c == nil || c.MaxMinutes <= 0 {
|
|
return 0
|
|
}
|
|
return time.Duration(c.MaxMinutes) * time.Minute
|
|
}
|
|
|
|
// validateCapture refuses a recorder with nowhere to keep the audio.
|
|
func (c *Config) validateCapture() error {
|
|
if c.Capture.Records() && c.Media.StoreDir() == "" {
|
|
return errors.New("capture.enabled set but there is no media block to keep the audio in")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SpeakerConfig — voice identification (internal/speaker,
|
|
// docs/plans/10-speaker-recognition.md).
|
|
//
|
|
// Absent, or enabled=false, ⇒ no voiceprint is computed for any turn, the
|
|
// enrolment methods do not exist, and nobody can be enrolled. A voiceprint is
|
|
// biometric data about a person, so this one is off until someone typed a model
|
|
// path on purpose.
|
|
//
|
|
// It cannot currently be turned on: there is no speaker-embedding model on this
|
|
// box. See the plan document for what to download.
|
|
type SpeakerConfig struct {
|
|
// Enabled — may she work out who is speaking. Default false.
|
|
Enabled bool `json:"enabled,omitempty"`
|
|
|
|
// ModelPath — an ECAPA-TDNN (or equivalent) speaker-embedding ONNX model.
|
|
// Required; without it the recognizer runs disabled and says so once.
|
|
ModelPath string `json:"model_path,omitempty"`
|
|
|
|
// LibPath — onnxruntime shared library, as for the text embedder. Empty ⇒
|
|
// the same default the embedder block uses.
|
|
//
|
|
// Nothing reads it yet: cmd/mavend's newSpeakerEmbedder discards the whole
|
|
// block, because there is no speaker model on this box to load. It stays
|
|
// declared so the block a reader writes matches the plan document.
|
|
LibPath string `json:"lib_path,omitempty"`
|
|
|
|
// Threshold — cosine similarity a match must beat. 0 ⇒
|
|
// speaker.DefaultThreshold (0.7). Lower it and she starts calling guests by
|
|
// his name, which is the expensive direction of this error.
|
|
Threshold float64 `json:"threshold,omitempty"`
|
|
|
|
// MinSeconds — least speech an identification will look at. 0 ⇒
|
|
// speaker.DefaultMinSeconds (2s).
|
|
MinSeconds float64 `json:"min_seconds,omitempty"`
|
|
}
|
|
|
|
// Recognizes reports whether voice identification should be wired. Safe on a
|
|
// nil receiver, and false without a model path — enabled with nothing to embed
|
|
// with is a misconfiguration, not a capability.
|
|
func (s *SpeakerConfig) Recognizes() bool {
|
|
return s != nil && s.Enabled && strings.TrimSpace(s.ModelPath) != ""
|
|
}
|