You asked whether insights could run against a local model, and the answer changes the calculus. I had assessed the Anthropic API, which needs a credential and sends data off the machine, and embedding a model in the binary, which costs ~90MB and a dependency. An already-running local server has none of those: no credential, no per-call cost, nothing leaving the machine, and nothing beyond the standard library talking to localhost. My "no LLM" conclusion was right for the options I had in view and stated too broadly. Discovers Ollama, LM Studio, or llama.cpp by probing OLLAMA_HOST, FATHOM_LOCAL_MODEL_URL, then the three default ports, with a short timeout. Finding nothing is the ordinary case, not an error: there is no warning, no configuration required, and the tab behaves exactly as it did. Used for two things: - Grouping prompts by meaning rather than shared vocabulary. MinHash has a provable blind spot -- "diagnose the intermittent failure" and "fix the flaky test" mean the same thing and share no words -- and embeddings close it. This does affect what gets found, so the method used is shown on every clustered finding and MinHash is the fallback on any failure. - Naming a group, so a finding reads "Flaky test triage - asked 10 times" rather than "You asked for much the same thing 10 times". This was the one gap I identified as needing semantics and dismissed as not worth a dependency; at zero cost it is worth it. The line the model does not cross: it may write a phrase, never a number. Detection stays entirely heuristic, so occurrence counts, costs, and the evidence behind them are identical with or without it. A model that could invent an occurrence count would poison the only thing making this tab worth trusting. Tests assert the numbers survive labelling, that a failed or rambling label leaves the heuristic title in place, and that sequence findings are never sent for naming since their steps already name them. cleanLabel absorbs what small models actually do -- preambles, reasoning blocks, quotes, "Common theme:" prefixes -- and rejects anything sentence-shaped, because a missing label costs polish while a rambling one would be shown as fact. Verified end to end against a stand-in server: findings went from "You asked for much the same thing 10 times" to "Endpoint pagination work - asked 10 times", with the counts unchanged. Verified the toggle reverts detection to lexical rather than only changing the wording, and that killing the model server leaves the tab working with no warning.
254 lines
7.1 KiB
Go
254 lines
7.1 KiB
Go
// Package config loads fathom.ini.
|
|
//
|
|
// INI rather than YAML, matching Gitea's convention, and hand-parsed rather
|
|
// than pulled from a dependency: the whole grammar is sections, keys, and
|
|
// comments, and forty lines of parser is cheaper than a module in go.sum for a
|
|
// project whose entire pitch is that it has no dependencies to speak of.
|
|
package config
|
|
|
|
import (
|
|
"bufio"
|
|
"fmt"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type Config struct {
|
|
Server struct {
|
|
Addr string
|
|
}
|
|
Storage struct {
|
|
Dir string
|
|
FlushRows int
|
|
FlushInterval time.Duration
|
|
RetentionDays int
|
|
MaxBytes int64
|
|
}
|
|
Insights struct {
|
|
// Enabled turns the background analyzer and its tab on. It is a
|
|
// separate switch because the analysis is entirely optional: nothing
|
|
// else in Fathom depends on it, and turning it off removes a tab and a
|
|
// goroutine and changes nothing about ingest, storage, or queries.
|
|
Enabled bool
|
|
|
|
// WindowDays bounds how far back an analysis looks. Habits worth
|
|
// capturing are recent; a pattern from six weeks ago is history.
|
|
WindowDays int
|
|
|
|
// Interval is the floor on how often the analysis is recomputed. It is
|
|
// normally driven by ingest instead.
|
|
Interval time.Duration
|
|
|
|
// UseLocalModel allows Fathom to look for a model server already
|
|
// running on this machine and, if it finds one, use it to cluster
|
|
// prompts by meaning and to name clusters.
|
|
//
|
|
// It never leaves the machine, needs no credential, and costs nothing
|
|
// per call. Findings themselves stay heuristic either way: the model
|
|
// writes prose over facts already computed and is never asked to
|
|
// produce a number.
|
|
UseLocalModel bool
|
|
}
|
|
|
|
Privacy struct {
|
|
// KeepIdentityAttrs retains user.email and friends. Off by default:
|
|
// a single-user store gains nothing from them, and keeping them writes
|
|
// the operator's email into every shard on disk.
|
|
KeepIdentityAttrs bool
|
|
MaxBlobBytes int
|
|
}
|
|
}
|
|
|
|
func Default() *Config {
|
|
var c Config
|
|
c.Server.Addr = ":3000" // Gitea's default, so the mental model transfers
|
|
c.Storage.Dir = "./data"
|
|
c.Storage.FlushRows = 1000
|
|
c.Storage.FlushInterval = time.Second
|
|
c.Storage.RetentionDays = 30
|
|
c.Storage.MaxBytes = 0
|
|
c.Insights.Enabled = true
|
|
c.Insights.WindowDays = 7
|
|
c.Insights.Interval = 2 * time.Minute
|
|
c.Insights.UseLocalModel = true
|
|
c.Privacy.MaxBlobBytes = 1 << 20
|
|
return &c
|
|
}
|
|
|
|
// Load reads a config file, applies environment overrides, and returns the
|
|
// result. A missing file is not an error: the defaults are a working
|
|
// configuration, which is the point of a single-binary deployment.
|
|
func Load(path string) (*Config, error) {
|
|
c := Default()
|
|
|
|
if path != "" {
|
|
if err := c.loadFile(path); err != nil && !os.IsNotExist(err) {
|
|
return nil, err
|
|
}
|
|
}
|
|
c.applyEnv()
|
|
|
|
if c.Storage.Dir == "" {
|
|
return nil, fmt.Errorf("storage dir must not be empty")
|
|
}
|
|
return c, nil
|
|
}
|
|
|
|
func (c *Config) loadFile(path string) error {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
|
|
section := ""
|
|
sc := bufio.NewScanner(f)
|
|
line := 0
|
|
for sc.Scan() {
|
|
line++
|
|
s := strings.TrimSpace(sc.Text())
|
|
if s == "" || strings.HasPrefix(s, "#") || strings.HasPrefix(s, ";") {
|
|
continue
|
|
}
|
|
if strings.HasPrefix(s, "[") && strings.HasSuffix(s, "]") {
|
|
section = strings.ToLower(strings.TrimSpace(s[1 : len(s)-1]))
|
|
continue
|
|
}
|
|
key, val, ok := strings.Cut(s, "=")
|
|
if !ok {
|
|
return fmt.Errorf("%s:%d: expected key = value, got %q", path, line, s)
|
|
}
|
|
key = strings.ToLower(strings.TrimSpace(key))
|
|
val = strings.TrimSpace(val)
|
|
val = strings.Trim(val, `"'`)
|
|
if err := c.set(section, key, val); err != nil {
|
|
return fmt.Errorf("%s:%d: %w", path, line, err)
|
|
}
|
|
}
|
|
return sc.Err()
|
|
}
|
|
|
|
func (c *Config) set(section, key, val string) error {
|
|
switch section + "." + key {
|
|
case "server.addr":
|
|
c.Server.Addr = val
|
|
case "storage.dir":
|
|
c.Storage.Dir = val
|
|
case "storage.flush_rows":
|
|
n, err := strconv.Atoi(val)
|
|
if err != nil {
|
|
return fmt.Errorf("flush_rows: %w", err)
|
|
}
|
|
c.Storage.FlushRows = n
|
|
case "storage.flush_interval":
|
|
d, err := time.ParseDuration(val)
|
|
if err != nil {
|
|
return fmt.Errorf("flush_interval: %w", err)
|
|
}
|
|
c.Storage.FlushInterval = d
|
|
case "storage.retention_days":
|
|
n, err := strconv.Atoi(val)
|
|
if err != nil {
|
|
return fmt.Errorf("retention_days: %w", err)
|
|
}
|
|
c.Storage.RetentionDays = n
|
|
case "storage.max_bytes":
|
|
n, err := parseBytes(val)
|
|
if err != nil {
|
|
return fmt.Errorf("max_bytes: %w", err)
|
|
}
|
|
c.Storage.MaxBytes = n
|
|
case "insights.enabled":
|
|
c.Insights.Enabled = isTrue(val)
|
|
case "insights.window_days":
|
|
n, err := strconv.Atoi(val)
|
|
if err != nil {
|
|
return fmt.Errorf("window_days: %w", err)
|
|
}
|
|
c.Insights.WindowDays = n
|
|
case "insights.use_local_model":
|
|
c.Insights.UseLocalModel = isTrue(val)
|
|
case "insights.interval":
|
|
d, err := time.ParseDuration(val)
|
|
if err != nil {
|
|
return fmt.Errorf("interval: %w", err)
|
|
}
|
|
c.Insights.Interval = d
|
|
case "privacy.keep_identity_attrs":
|
|
c.Privacy.KeepIdentityAttrs = isTrue(val)
|
|
case "privacy.max_blob_bytes":
|
|
n, err := parseBytes(val)
|
|
if err != nil {
|
|
return fmt.Errorf("max_blob_bytes: %w", err)
|
|
}
|
|
c.Privacy.MaxBlobBytes = int(n)
|
|
default:
|
|
// Unknown keys are reported rather than ignored: a silently-ignored
|
|
// typo in a config file is a bad afternoon.
|
|
return fmt.Errorf("unknown setting [%s] %s", section, key)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *Config) applyEnv() {
|
|
if v := os.Getenv("FATHOM_ADDR"); v != "" {
|
|
c.Server.Addr = v
|
|
}
|
|
if v := os.Getenv("FATHOM_DATA_DIR"); v != "" {
|
|
c.Storage.Dir = v
|
|
}
|
|
if v := os.Getenv("FATHOM_RETENTION_DAYS"); v != "" {
|
|
if n, err := strconv.Atoi(v); err == nil {
|
|
c.Storage.RetentionDays = n
|
|
}
|
|
}
|
|
if v := os.Getenv("FATHOM_MAX_BYTES"); v != "" {
|
|
if n, err := parseBytes(v); err == nil {
|
|
c.Storage.MaxBytes = n
|
|
}
|
|
}
|
|
if v := os.Getenv("FATHOM_KEEP_IDENTITY_ATTRS"); v != "" {
|
|
c.Privacy.KeepIdentityAttrs = isTrue(v)
|
|
}
|
|
if v := os.Getenv("FATHOM_INSIGHTS"); v != "" {
|
|
c.Insights.Enabled = isTrue(v)
|
|
}
|
|
if v := os.Getenv("FATHOM_LOCAL_MODEL"); v != "" {
|
|
c.Insights.UseLocalModel = isTrue(v)
|
|
}
|
|
}
|
|
|
|
func isTrue(v string) bool {
|
|
switch strings.ToLower(strings.TrimSpace(v)) {
|
|
case "1", "true", "yes", "on":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// parseBytes accepts plain numbers and the usual suffixes, so a size limit can
|
|
// be written as 10GB rather than as eleven digits.
|
|
func parseBytes(s string) (int64, error) {
|
|
s = strings.TrimSpace(strings.ToUpper(s))
|
|
mult := int64(1)
|
|
switch {
|
|
case strings.HasSuffix(s, "KB"), strings.HasSuffix(s, "K"):
|
|
mult, s = 1<<10, strings.TrimRight(strings.TrimSuffix(s, "KB"), "K")
|
|
case strings.HasSuffix(s, "MB"), strings.HasSuffix(s, "M"):
|
|
mult, s = 1<<20, strings.TrimRight(strings.TrimSuffix(s, "MB"), "M")
|
|
case strings.HasSuffix(s, "GB"), strings.HasSuffix(s, "G"):
|
|
mult, s = 1<<30, strings.TrimRight(strings.TrimSuffix(s, "GB"), "G")
|
|
case strings.HasSuffix(s, "TB"), strings.HasSuffix(s, "T"):
|
|
mult, s = 1<<40, strings.TrimRight(strings.TrimSuffix(s, "TB"), "T")
|
|
case strings.HasSuffix(s, "B"):
|
|
s = strings.TrimSuffix(s, "B")
|
|
}
|
|
n, err := strconv.ParseFloat(strings.TrimSpace(s), 64)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return int64(n * float64(mult)), nil
|
|
}
|