feat: implement YAML-based configuration, .env support, and customizable prompt templates for AI analysis

This commit is contained in:
2026-08-22 10:17:38 -07:00
parent 7ec91f384f
commit 0de312edbd
30 changed files with 895 additions and 211 deletions
+116 -12
View File
@@ -5,18 +5,72 @@ import (
"strings"
"github.com/spf13/pflag"
"gopkg.in/yaml.v3"
)
type AppConfigFile struct {
Server struct {
Port string `yaml:"port"`
LogLevel string `yaml:"log_level"`
} `yaml:"server"`
AITrigger struct {
AnalysisThreshold float64 `yaml:"analysis_threshold"`
} `yaml:"ai_trigger"`
Prompts struct {
SystemPromptPath string `yaml:"system_prompt_path"`
UserPromptPath string `yaml:"user_prompt_path"`
} `yaml:"prompts"`
}
type Config struct {
Port string
DBPath string
TargetConfig string
FilterConfig string
LogLevel string
LLMProvider string // "gemini", "deepseek", "mock"
GeminiAPIKey string
DeepSeekAPIKey string
DeepSeekBaseURL string
Port string
DBPath string
TargetConfig string
FilterConfig string
AppConfig string
LogLevel string
LLMProvider string // "gemini", "deepseek", "mock"
GeminiAPIKey string
DeepSeekAPIKey string
DeepSeekBaseURL string
AnalysisThreshold float64
SystemPromptPath string
UserPromptPath string
SystemPrompt string
UserPrompt string
}
func init() {
LoadEnvFile(".env")
}
// LoadEnvFile parses a simple KEY=VALUE .env file if it exists without overriding existing env vars
func LoadEnvFile(filenames ...string) {
if len(filenames) == 0 {
filenames = []string{".env"}
}
for _, filename := range filenames {
data, err := os.ReadFile(filename)
if err != nil {
continue
}
lines := strings.Split(string(data), "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
parts := strings.SplitN(line, "=", 2)
if len(parts) == 2 {
key := strings.TrimSpace(parts[0])
val := strings.TrimSpace(parts[1])
val = strings.Trim(val, `"'`)
if os.Getenv(key) == "" {
os.Setenv(key, val)
}
}
}
}
}
func RegisterFlags(flags *pflag.FlagSet) *Config {
@@ -26,21 +80,71 @@ func RegisterFlags(flags *pflag.FlagSet) *Config {
flags.StringVarP(&cfg.DBPath, "db", "d", getEnvOrDefault("DB_PATH", "data/db/hireme.db"), "Path to database file")
flags.StringVarP(&cfg.TargetConfig, "target-config", "c", getEnvOrDefault("TARGET_CONFIG", "data/config/targets.yaml"), "Path to targets YAML file")
flags.StringVarP(&cfg.FilterConfig, "filter-config", "f", getEnvOrDefault("FILTER_CONFIG", "data/config/filters.yaml"), "Path to filters YAML file")
flags.StringVar(&cfg.AppConfig, "app-config", getEnvOrDefault("APP_CONFIG", "data/config/config.yaml"), "Path to app YAML config file")
flags.StringVar(&cfg.LogLevel, "log-level", getEnvOrDefault("LOG_LEVEL", "info"), "Logging level (trace, debug, info, error)")
flags.StringVarP(&cfg.LLMProvider, "llm-provider", "l", getEnvOrDefault("LLM_PROVIDER", ""), "Active LLM Provider (gemini, deepseek, mock)")
flags.StringVar(&cfg.GeminiAPIKey, "gemini-api-key", os.Getenv("GEMINI_API_KEY"), "Gemini API Key")
flags.StringVar(&cfg.DeepSeekAPIKey, "deepseek-api-key", os.Getenv("DEEPSEEK_API_KEY"), "DeepSeek API Key")
flags.StringVar(&cfg.DeepSeekBaseURL, "deepseek-base-url", getEnvOrDefault("DEEPSEEK_BASE_URL", "https://api.deepseek.com"), "DeepSeek Base URL")
flags.Float64Var(&cfg.AnalysisThreshold, "analysis-threshold", 25.0, "Threshold score from filter to trigger LLM analysis")
return cfg
}
func (c *Config) Normalize() {
// Re-check env vars in case .env was loaded after initial flag binding
if c.DeepSeekAPIKey == "" {
c.DeepSeekAPIKey = os.Getenv("DEEPSEEK_API_KEY")
}
if c.GeminiAPIKey == "" {
c.GeminiAPIKey = os.Getenv("GEMINI_API_KEY")
}
// Load data/config/config.yaml if present
if c.AppConfig != "" {
if data, err := os.ReadFile(c.AppConfig); err == nil {
var appCfg AppConfigFile
if err := yaml.Unmarshal(data, &appCfg); err == nil {
if appCfg.Server.Port != "" && c.Port == "8080" {
c.Port = appCfg.Server.Port
}
if appCfg.Server.LogLevel != "" && c.LogLevel == "info" {
c.LogLevel = appCfg.Server.LogLevel
}
if appCfg.AITrigger.AnalysisThreshold > 0 {
c.AnalysisThreshold = appCfg.AITrigger.AnalysisThreshold
}
if appCfg.Prompts.SystemPromptPath != "" {
c.SystemPromptPath = appCfg.Prompts.SystemPromptPath
}
if appCfg.Prompts.UserPromptPath != "" {
c.UserPromptPath = appCfg.Prompts.UserPromptPath
}
}
}
}
if c.SystemPromptPath == "" {
c.SystemPromptPath = "data/config/prompts/system_prompt.txt"
}
if c.UserPromptPath == "" {
c.UserPromptPath = "data/config/prompts/user_prompt.txt"
}
// Load prompt contents from files
if promptData, err := os.ReadFile(c.SystemPromptPath); err == nil {
c.SystemPrompt = strings.TrimSpace(string(promptData))
}
if userPromptData, err := os.ReadFile(c.UserPromptPath); err == nil {
c.UserPrompt = strings.TrimSpace(string(userPromptData))
}
// Determine LLM provider
if c.LLMProvider == "" {
if c.GeminiAPIKey != "" {
c.LLMProvider = "gemini"
} else if c.DeepSeekAPIKey != "" {
if c.DeepSeekAPIKey != "" {
c.LLMProvider = "deepseek"
} else if c.GeminiAPIKey != "" {
c.LLMProvider = "gemini"
} else {
c.LLMProvider = "mock"
}
+46
View File
@@ -0,0 +1,46 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestConfigLoadEnvAndAppConfig(t *testing.T) {
tempDir := t.TempDir()
envPath := filepath.Join(tempDir, ".env")
configYAMLPath := filepath.Join(tempDir, "config.yaml")
_ = os.WriteFile(envPath, []byte("TEST_SECRET_KEY=12345\nDEEPSEEK_API_KEY=test-sk-key\n"), 0644)
_ = os.WriteFile(configYAMLPath, []byte(`
server:
port: "9090"
log_level: "debug"
ai_trigger:
analysis_threshold: 35.0
prompts:
system_prompt_path: "sys.txt"
`), 0644)
LoadEnvFile(envPath)
if os.Getenv("TEST_SECRET_KEY") != "12345" {
t.Errorf("expected TEST_SECRET_KEY to be 12345, got %q", os.Getenv("TEST_SECRET_KEY"))
}
cfg := &Config{
Port: "8080",
LogLevel: "info",
AppConfig: configYAMLPath,
}
cfg.Normalize()
if cfg.Port != "9090" {
t.Errorf("expected port 9090 from config.yaml, got %s", cfg.Port)
}
if cfg.AnalysisThreshold != 35.0 {
t.Errorf("expected analysis threshold 35.0, got %f", cfg.AnalysisThreshold)
}
if cfg.LLMProvider != "deepseek" {
t.Errorf("expected LLMProvider to be deepseek, got %s", cfg.LLMProvider)
}
}
+5 -2
View File
@@ -9,8 +9,9 @@ import (
)
type FilterConfig struct {
HardKeywords []string `yaml:"hard_keywords"`
ScoringWeights []filter.KeywordWeight `yaml:"scoring_weights"`
HardKeywords []string `yaml:"hard_keywords"`
AnalysisThreshold float64 `yaml:"analysis_threshold"`
ScoringWeights []filter.KeywordWeight `yaml:"scoring_weights"`
}
func LoadFilterConfig(path string) (*FilterConfig, error) {
@@ -20,6 +21,8 @@ func LoadFilterConfig(path string) (*FilterConfig, error) {
}
var cfg FilterConfig
// Default threshold if not specified
cfg.AnalysisThreshold = 25.0
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("failed to unmarshal filter config: %w", err)
}