Files
neural-hunt/internal/settings/settings.go
jbergner 005fd6ca51
Some checks failed
release-tag / release-image (push) Failing after 1m18s
RC-1
2026-08-10 05:48:55 +02:00

262 lines
9.2 KiB
Go

package settings
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"os"
"strconv"
"strings"
"sync"
"time"
)
type Runtime struct {
GuessMinIntervalSec int `json:"guess_min_interval_sec"`
ClientSubmitIntervalSec int `json:"client_submit_interval_sec"`
TaskRangeBits int `json:"task_range_bits"`
ActiveTaskCount int `json:"active_task_count"`
PresenceTTLSec int `json:"presence_ttl_sec"`
DefaultMaxNodes int `json:"default_max_nodes"`
PublicScorePrecision int `json:"public_score_precision"`
ArtifactPreset string `json:"artifact_preset"`
ArtifactProvider string `json:"artifact_provider"`
ArtifactModel string `json:"artifact_model"`
ArtifactPrompt string `json:"artifact_prompt"`
ArtifactNegativePrompt string `json:"artifact_negative_prompt"`
ArtifactWidth int `json:"artifact_width"`
ArtifactHeight int `json:"artifact_height"`
ArtifactSteps int `json:"artifact_steps"`
ArtifactQuality string `json:"artifact_quality"`
}
type Manager struct {
db *sql.DB
mu sync.RWMutex
v Runtime
}
func envInt(k string, def int) int {
if s := os.Getenv(k); s != "" {
if n, err := strconv.Atoi(s); err == nil {
return n
}
}
return def
}
func envString(k, def string) string {
if s := strings.TrimSpace(os.Getenv(k)); s != "" {
return s
}
return def
}
func Defaults() Runtime {
return Runtime{
GuessMinIntervalSec: envInt("DEFAULT_GUESS_MIN_INTERVAL_SEC", 10),
ClientSubmitIntervalSec: envInt("DEFAULT_CLIENT_SUBMIT_INTERVAL_SEC", 11),
TaskRangeBits: envInt("DEFAULT_TASK_RANGE_BITS", 28),
ActiveTaskCount: envInt("DEFAULT_ACTIVE_TASK_COUNT", 1),
PresenceTTLSec: envInt("DEFAULT_PRESENCE_TTL_SEC", 35),
DefaultMaxNodes: envInt("DEFAULT_MAX_NODES", 2000),
PublicScorePrecision: envInt("DEFAULT_PUBLIC_SCORE_PRECISION", 2),
ArtifactPreset: strings.ToLower(envString("ARTIFACT_PRESET", "raccoon_full_art_v1")),
ArtifactProvider: strings.ToLower(envString("ARTIFACT_PROVIDER", "openai")),
ArtifactModel: envString("ARTIFACT_MODEL", "gpt-image-2"),
ArtifactPrompt: envString("ARTIFACT_PROMPT", "Optional global creative override for legacy artifact providers. The raccoon_full_art_v1 preset uses its built-in structured master prompt and task-specific NFT instructions."),
ArtifactNegativePrompt: envString("ARTIFACT_NEGATIVE_PROMPT", "text, watermark, logo, signature, low quality, blurry, malformed"),
ArtifactWidth: envInt("ARTIFACT_WIDTH", 1024),
ArtifactHeight: envInt("ARTIFACT_HEIGHT", 1536),
ArtifactSteps: envInt("ARTIFACT_STEPS", 28),
ArtifactQuality: strings.ToLower(envString("ARTIFACT_QUALITY", "medium")),
}
}
func New(ctx context.Context, db *sql.DB) (*Manager, error) {
m := &Manager{db: db, v: Defaults()}
b, _ := json.Marshal(m.v)
now := time.Now().UTC().UnixMilli()
_, err := db.ExecContext(ctx, `INSERT INTO settings(key,value,updated_at) VALUES('runtime',?,?) ON CONFLICT(key) DO NOTHING`, string(b), now)
if err != nil {
return nil, err
}
if err := m.Refresh(ctx); err != nil {
return nil, err
}
if err := m.migrateRIFTMediumDefault(ctx); err != nil {
return nil, err
}
return m, nil
}
// migrateRIFTMediumDefault upgrades deployments created by the first RIFT
// release, where high quality was hard-coded as the collection default. The
// migration runs once only; after that an operator can still deliberately set
// high (or low) without it being overwritten on restart.
func (m *Manager) migrateRIFTMediumDefault(ctx context.Context) error {
const marker = "migration_rift_medium_quality_v1"
var n int
if err := m.db.QueryRowContext(ctx, `SELECT count(*) FROM settings WHERE key=?`, marker).Scan(&n); err != nil {
return err
}
if n > 0 {
return nil
}
// A deployment that explicitly configured ARTIFACT_QUALITY keeps that
// operator choice. The migration only changes the former implicit RIFT
// default.
if strings.TrimSpace(os.Getenv("ARTIFACT_QUALITY")) != "" {
_, err := m.db.ExecContext(ctx, `INSERT INTO settings(key,value,updated_at) VALUES(?,?,?) ON CONFLICT(key) DO NOTHING`, marker, "explicit-env-preserved", time.Now().UTC().UnixMilli())
return err
}
v := m.Get()
if strings.EqualFold(strings.TrimSpace(v.ArtifactPreset), "raccoon_full_art_v1") && strings.EqualFold(strings.TrimSpace(v.ArtifactQuality), "high") {
v.ArtifactQuality = "medium"
b, _ := json.Marshal(v)
now := time.Now().UTC().UnixMilli()
if _, err := m.db.ExecContext(ctx, `UPDATE settings SET value=?,updated_at=? WHERE key='runtime'`, string(b), now); err != nil {
return err
}
m.mu.Lock()
m.v = v
m.mu.Unlock()
}
_, err := m.db.ExecContext(ctx, `INSERT INTO settings(key,value,updated_at) VALUES(?,?,?) ON CONFLICT(key) DO NOTHING`, marker, "done", time.Now().UTC().UnixMilli())
return err
}
func (m *Manager) Get() Runtime {
m.mu.RLock()
defer m.mu.RUnlock()
return m.v
}
func (m *Manager) Refresh(ctx context.Context) error {
var raw string
if err := m.db.QueryRowContext(ctx, `SELECT value FROM settings WHERE key='runtime'`).Scan(&raw); err != nil {
return err
}
// Start from current defaults so databases created by older Neural Hunt
// versions automatically receive newly introduced settings.
var stored map[string]json.RawMessage
_ = json.Unmarshal([]byte(raw), &stored)
_, hadArtifactPreset := stored["artifact_preset"]
v := Defaults()
if err := json.Unmarshal([]byte(raw), &v); err != nil {
return err
}
// One-time upgrade for the bundled pre-collection database: older builds
// shipped the offline square SVG preset. The new collection is designed to
// work after only OPENAI_API_KEY + model are configured, so that exact legacy
// default is migrated to OpenAI portrait generation automatically. Explicit
// non-local provider choices are preserved.
if !hadArtifactPreset {
if strings.EqualFold(v.ArtifactProvider, "local") && v.ArtifactWidth == 1024 && v.ArtifactHeight == 1024 {
v.ArtifactPreset = "raccoon_full_art_v1"
v.ArtifactProvider = "openai"
v.ArtifactWidth = 1024
v.ArtifactHeight = 1536
v.ArtifactQuality = "medium"
} else {
// Existing deployments that deliberately chose another provider keep
// their old behavior until they explicitly opt into the collection preset.
v.ArtifactPreset = "legacy"
}
if b, err := json.Marshal(v); err == nil {
_, _ = m.db.ExecContext(ctx, `UPDATE settings SET value=?,updated_at=? WHERE key='runtime'`, string(b), time.Now().UTC().UnixMilli())
}
}
if err := Validate(v); err != nil {
return err
}
m.mu.Lock()
m.v = v
m.mu.Unlock()
return nil
}
func (m *Manager) Update(ctx context.Context, v Runtime) error {
if err := Validate(v); err != nil {
return err
}
b, _ := json.Marshal(v)
if _, err := m.db.ExecContext(ctx, `UPDATE settings SET value=?,updated_at=? WHERE key='runtime'`, string(b), time.Now().UTC().UnixMilli()); err != nil {
return err
}
m.mu.Lock()
m.v = v
m.mu.Unlock()
return nil
}
func Validate(v Runtime) error {
if v.GuessMinIntervalSec < 1 || v.GuessMinIntervalSec > 3600 {
return fmt.Errorf("guess_min_interval_sec must be 1..3600")
}
if v.ClientSubmitIntervalSec <= v.GuessMinIntervalSec || v.ClientSubmitIntervalSec > 7200 {
return fmt.Errorf("client_submit_interval_sec must be > guess_min_interval_sec and <= 7200")
}
if v.TaskRangeBits < 8 || v.TaskRangeBits > 128 {
return fmt.Errorf("task_range_bits must be 8..128")
}
if v.ActiveTaskCount < 1 || v.ActiveTaskCount > 64 {
return fmt.Errorf("active_task_count must be 1..64")
}
if v.PresenceTTLSec < 15 || v.PresenceTTLSec > 600 {
return fmt.Errorf("presence_ttl_sec must be 15..600")
}
if v.DefaultMaxNodes < 50 || v.DefaultMaxNodes > 100000 {
return fmt.Errorf("default_max_nodes must be 50..100000")
}
if v.PublicScorePrecision < 0 || v.PublicScorePrecision > 6 {
return fmt.Errorf("public_score_precision must be 0..6")
}
switch strings.ToLower(strings.TrimSpace(v.ArtifactPreset)) {
case "legacy", "raccoon_full_art_v1":
default:
return fmt.Errorf("artifact_preset must be legacy or raccoon_full_art_v1")
}
switch strings.ToLower(v.ArtifactProvider) {
case "local", "openai", "comfyui", "a1111", "auto":
default:
return fmt.Errorf("artifact_provider must be local, openai, comfyui, a1111 or auto")
}
if strings.TrimSpace(v.ArtifactModel) == "" {
return fmt.Errorf("artifact_model must not be empty")
}
if len(v.ArtifactPrompt) < 10 || len(v.ArtifactPrompt) > 12000 {
return fmt.Errorf("artifact_prompt length must be 10..12000")
}
if len(v.ArtifactNegativePrompt) > 8000 {
return fmt.Errorf("artifact_negative_prompt too long")
}
if v.ArtifactWidth < 256 || v.ArtifactWidth > 4096 || v.ArtifactHeight < 256 || v.ArtifactHeight > 4096 {
return fmt.Errorf("artifact dimensions must be 256..4096")
}
if v.ArtifactSteps < 1 || v.ArtifactSteps > 200 {
return fmt.Errorf("artifact_steps must be 1..200")
}
switch strings.ToLower(v.ArtifactQuality) {
case "low", "medium", "high", "auto":
default:
return fmt.Errorf("artifact_quality must be low, medium, high or auto")
}
return nil
}
func (m *Manager) Run(ctx context.Context) {
t := time.NewTicker(5 * time.Second)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
_ = m.Refresh(ctx)
}
}
}