Rip out Ollama: direct_route only, no LLM layer

Pete moves to a remote host without Ollama access. Every source must
declare a direct_route channel; the classifier, explainer, semantic
dedup, !explain summaries, feed_hint, and the recent_headlines /
classification_log tables are gone. Deterministic dedup (canonical URL,
headline_norm, per-channel cooldown) remains.
This commit is contained in:
prosolis
2026-05-24 22:07:13 -07:00
parent fbd4b84eaf
commit e88483526d
23 changed files with 146 additions and 1675 deletions

View File

@@ -14,7 +14,6 @@ var envBracketRe = regexp.MustCompile(`\$\{([^}]+)\}`)
type Config struct {
Matrix MatrixConfig `yaml:"matrix"`
Ollama OllamaConfig `yaml:"ollama"`
Posting PostingConfig `yaml:"posting"`
Storage StorageConfig `yaml:"storage"`
Web WebConfig `yaml:"web"`
@@ -40,12 +39,6 @@ type MatrixConfig struct {
Channels map[string]string `yaml:"channels"`
}
type OllamaConfig struct {
BaseURL string `yaml:"base_url"`
Model string `yaml:"model"`
TimeoutSeconds int `yaml:"timeout_seconds"`
}
type PostingConfig struct {
MinIntervalSeconds int `yaml:"min_interval_seconds"`
BurstCapCount int `yaml:"burst_cap_count"`
@@ -58,27 +51,25 @@ type PostingConfig struct {
}
// RoundRobinConfig switches Pete from immediate-on-classify posting to a
// paced rotation: one story per IntervalHours, picking the next source in
// config order that has a postable story (skip-and-advance, newest-first).
// paced rotation: one story per IntervalHours, picking the next channel in
// rotation order that has a postable story (skip-and-advance, newest-first).
type RoundRobinConfig struct {
Enabled bool `yaml:"enabled"`
IntervalHours int `yaml:"interval_hours"`
}
type StorageConfig struct {
DBPath string `yaml:"db_path"`
RecentWindowHours int `yaml:"recent_window_hours"`
ClassificationLogDays int `yaml:"classification_log_days"`
DBPath string `yaml:"db_path"`
RecentWindowHours int `yaml:"recent_window_hours"`
}
type SourceConfig struct {
Name string `yaml:"name"`
FeedURL string `yaml:"feed_url"`
Tier int `yaml:"tier"`
PollIntervalMinutes int `yaml:"poll_interval_minutes"`
FeedHint string `yaml:"feed_hint"`
DirectRoute *string `yaml:"direct_route"`
Enabled bool `yaml:"enabled"`
Name string `yaml:"name"`
FeedURL string `yaml:"feed_url"`
Tier int `yaml:"tier"`
PollIntervalMinutes int `yaml:"poll_interval_minutes"`
DirectRoute string `yaml:"direct_route"`
Enabled bool `yaml:"enabled"`
}
func Load(path string) (*Config, error) {
@@ -125,12 +116,6 @@ func (c *Config) validate() error {
if len(c.Matrix.Channels) == 0 {
return fmt.Errorf("matrix.channels must have at least one entry")
}
if c.Ollama.BaseURL == "" {
return fmt.Errorf("ollama.base_url is required")
}
if c.Ollama.Model == "" {
return fmt.Errorf("ollama.model is required")
}
if c.Storage.DBPath == "" {
return fmt.Errorf("storage.db_path is required")
}
@@ -148,6 +133,15 @@ func (c *Config) validate() error {
if s.PollIntervalMinutes <= 0 {
return fmt.Errorf("sources[%d].poll_interval_minutes must be > 0", i)
}
if !s.Enabled {
continue
}
if s.DirectRoute == "" {
return fmt.Errorf("sources[%d] (%s): direct_route is required for enabled sources", i, s.Name)
}
if _, ok := c.Matrix.Channels[s.DirectRoute]; !ok {
return fmt.Errorf("sources[%d] (%s): direct_route %q is not a configured matrix.channels key", i, s.Name, s.DirectRoute)
}
}
return nil
@@ -163,9 +157,6 @@ func (c *Config) applyDefaults() {
if c.Matrix.PickleKey == "" {
c.Matrix.PickleKey = "pete_pickle_key"
}
if c.Ollama.TimeoutSeconds == 0 {
c.Ollama.TimeoutSeconds = 30
}
if c.Posting.MinIntervalSeconds == 0 {
c.Posting.MinIntervalSeconds = 300
}
@@ -184,9 +175,6 @@ func (c *Config) applyDefaults() {
if c.Storage.RecentWindowHours == 0 {
c.Storage.RecentWindowHours = 24
}
if c.Storage.ClassificationLogDays == 0 {
c.Storage.ClassificationLogDays = 7
}
if c.Web.ListenAddr == "" {
c.Web.ListenAddr = ":8080"
}

View File

@@ -19,16 +19,14 @@ matrix:
func TestLoadValid(t *testing.T) {
yaml := validMatrix + `
ollama:
base_url: "http://localhost:11434"
model: "gemma4:27b"
timeout_seconds: 60
storage:
db_path: "/tmp/test.db"
sources:
- name: "Test Source"
feed_url: "https://example.com/rss"
tier: 1
poll_interval_minutes: 20
direct_route: "tech"
enabled: true
`
cfg := loadFromString(t, yaml)
@@ -39,15 +37,15 @@ sources:
if cfg.Matrix.UserID != "@pete:example.org" {
t.Errorf("user_id = %q", cfg.Matrix.UserID)
}
if cfg.Ollama.TimeoutSeconds != 60 {
t.Errorf("timeout = %d, want 60", cfg.Ollama.TimeoutSeconds)
}
if len(cfg.Sources) != 1 {
t.Fatalf("sources = %d, want 1", len(cfg.Sources))
}
if cfg.Sources[0].Name != "Test Source" {
t.Errorf("source name = %q", cfg.Sources[0].Name)
}
if cfg.Sources[0].DirectRoute != "tech" {
t.Errorf("direct_route = %q, want tech", cfg.Sources[0].DirectRoute)
}
}
func TestEnvVarExpansion(t *testing.T) {
@@ -60,9 +58,6 @@ matrix:
password: "${TEST_PETE_PASS}"
channels:
tech: "!tech:example.org"
ollama:
base_url: "http://localhost:11434"
model: "gemma4:27b"
storage:
db_path: "/tmp/test.db"
sources: []
@@ -76,15 +71,14 @@ sources: []
func TestDefaults(t *testing.T) {
yaml := validMatrix + `
ollama:
base_url: "http://localhost:11434"
model: "gemma4:27b"
storage:
db_path: "/tmp/test.db"
sources:
- name: "S"
feed_url: "https://example.com/rss"
tier: 1
poll_interval_minutes: 20
direct_route: "tech"
enabled: true
`
cfg := loadFromString(t, yaml)
@@ -95,9 +89,6 @@ sources:
if cfg.Matrix.DisplayName != "Pete" {
t.Errorf("display_name default = %q, want Pete", cfg.Matrix.DisplayName)
}
if cfg.Ollama.TimeoutSeconds != 30 {
t.Errorf("timeout default = %d, want 30", cfg.Ollama.TimeoutSeconds)
}
if cfg.Posting.MinIntervalSeconds != 300 {
t.Errorf("min_interval default = %d, want 300", cfg.Posting.MinIntervalSeconds)
}
@@ -110,27 +101,6 @@ sources:
if cfg.Storage.RecentWindowHours != 24 {
t.Errorf("recent_window default = %d, want 24", cfg.Storage.RecentWindowHours)
}
if cfg.Storage.ClassificationLogDays != 7 {
t.Errorf("classification_log default = %d, want 7", cfg.Storage.ClassificationLogDays)
}
if cfg.Sources[0].PollIntervalMinutes != 20 {
t.Errorf("poll_interval default = %d, want 20", cfg.Sources[0].PollIntervalMinutes)
}
}
// shortMatrix returns a minimal valid matrix block for validation error tests.
func shortMatrix(overrides string) string {
base := `
matrix:
homeserver: "https://h"
user_id: "@p:h"
password: "pw"
channels: {tech: "!t:e"}
`
if overrides != "" {
return overrides
}
return base
}
func TestValidationErrors(t *testing.T) {
@@ -143,7 +113,6 @@ matrix:
user_id: "@p:h"
password: "pw"
channels: {tech: "!t:e"}
ollama: {base_url: "http://l", model: "m"}
storage: {db_path: "/tmp/t.db"}
`},
{"missing user_id", `
@@ -151,7 +120,6 @@ matrix:
homeserver: "https://h"
password: "pw"
channels: {tech: "!t:e"}
ollama: {base_url: "http://l", model: "m"}
storage: {db_path: "/tmp/t.db"}
`},
{"missing password", `
@@ -159,7 +127,6 @@ matrix:
homeserver: "https://h"
user_id: "@p:h"
channels: {tech: "!t:e"}
ollama: {base_url: "http://l", model: "m"}
storage: {db_path: "/tmp/t.db"}
`},
{"no channels", `
@@ -167,25 +134,6 @@ matrix:
homeserver: "https://h"
user_id: "@p:h"
password: "pw"
ollama: {base_url: "http://l", model: "m"}
storage: {db_path: "/tmp/t.db"}
`},
{"missing ollama base_url", `
matrix:
homeserver: "https://h"
user_id: "@p:h"
password: "pw"
channels: {tech: "!t:e"}
ollama: {model: "m"}
storage: {db_path: "/tmp/t.db"}
`},
{"missing ollama model", `
matrix:
homeserver: "https://h"
user_id: "@p:h"
password: "pw"
channels: {tech: "!t:e"}
ollama: {base_url: "http://l"}
storage: {db_path: "/tmp/t.db"}
`},
{"missing db_path", `
@@ -194,7 +142,6 @@ matrix:
user_id: "@p:h"
password: "pw"
channels: {tech: "!t:e"}
ollama: {base_url: "http://l", model: "m"}
storage: {}
`},
{"invalid tier", `
@@ -203,12 +150,14 @@ matrix:
user_id: "@p:h"
password: "pw"
channels: {tech: "!t:e"}
ollama: {base_url: "http://l", model: "m"}
storage: {db_path: "/tmp/t.db"}
sources:
- name: "S"
feed_url: "https://e.com/rss"
tier: 5
poll_interval_minutes: 20
direct_route: "tech"
enabled: true
`},
{"missing source name", `
matrix:
@@ -216,11 +165,42 @@ matrix:
user_id: "@p:h"
password: "pw"
channels: {tech: "!t:e"}
ollama: {base_url: "http://l", model: "m"}
storage: {db_path: "/tmp/t.db"}
sources:
- feed_url: "https://e.com/rss"
tier: 1
poll_interval_minutes: 20
direct_route: "tech"
enabled: true
`},
{"enabled source missing direct_route", `
matrix:
homeserver: "https://h"
user_id: "@p:h"
password: "pw"
channels: {tech: "!t:e"}
storage: {db_path: "/tmp/t.db"}
sources:
- name: "S"
feed_url: "https://e.com/rss"
tier: 1
poll_interval_minutes: 20
enabled: true
`},
{"direct_route not in channels", `
matrix:
homeserver: "https://h"
user_id: "@p:h"
password: "pw"
channels: {tech: "!t:e"}
storage: {db_path: "/tmp/t.db"}
sources:
- name: "S"
feed_url: "https://e.com/rss"
tier: 1
poll_interval_minutes: 20
direct_route: "gaming"
enabled: true
`},
}
@@ -237,37 +217,25 @@ sources:
}
}
func TestDirectRouteNullable(t *testing.T) {
func TestDisabledSourceSkipsDirectRouteCheck(t *testing.T) {
// A disabled source without direct_route must not fail validation.
yaml := `
matrix:
homeserver: "https://h"
user_id: "@p:h"
password: "pw"
channels: {tech: "!t:e"}
ollama: {base_url: "http://l", model: "m"}
storage: {db_path: "/tmp/t.db"}
sources:
- name: "Direct"
- name: "Off"
feed_url: "https://e.com/rss"
tier: 1
direct_route: "politics"
enabled: true
- name: "LLM"
feed_url: "https://e.com/rss2"
tier: 1
direct_route: null
enabled: true
poll_interval_minutes: 20
enabled: false
`
cfg := loadFromString(t, yaml)
if cfg.Sources[0].DirectRoute == nil {
t.Fatal("direct source should have non-nil DirectRoute")
}
if *cfg.Sources[0].DirectRoute != "politics" {
t.Errorf("direct_route = %q, want politics", *cfg.Sources[0].DirectRoute)
}
if cfg.Sources[1].DirectRoute != nil {
t.Errorf("null source should have nil DirectRoute, got %v", cfg.Sources[1].DirectRoute)
if cfg.Sources[0].DirectRoute != "" {
t.Errorf("expected empty direct_route, got %q", cfg.Sources[0].DirectRoute)
}
}