feat: implement AI integration with backend configuration and rule-based enrichment actions
CI Quality Checks & Tests / Gitleaks Secret Detection (push) Failing after 47s
CI Quality Checks & Tests / Frontend Typecheck & Build (push) Successful in 1m19s
CI Quality Checks & Tests / Go Lint & Unit Tests (push) Failing after 3h11m47s

This commit is contained in:
2026-08-26 22:42:23 -07:00
parent 9bc3423009
commit dfbf646332
19 changed files with 2312 additions and 114 deletions
+98
View File
@@ -0,0 +1,98 @@
package ai
import (
"context"
"time"
"git.pengzhan.dev/haopengzhan/rsspipe/internal/ai"
"git.pengzhan.dev/haopengzhan/rsspipe/internal/db"
"git.pengzhan.dev/haopengzhan/rsspipe/internal/models"
"git.pengzhan.dev/haopengzhan/rsspipe/internal/worker"
"go.uber.org/zap"
)
// LoadConfig fetches current AI provider configuration from system settings.
func LoadConfig(database *db.DB) ai.Config {
settings, _ := database.GetSettingsMap()
enabled := settings["ai_enabled"] == "true" || settings["ai_enabled"] == "1"
provider := settings["ai_provider"]
if provider == "" {
provider = "openai"
}
baseURL := settings["ai_base_url"]
if baseURL == "" {
baseURL = "https://api.openai.com/v1"
}
model := settings["ai_model"]
if model == "" {
model = "gpt-4o-mini"
}
return ai.Config{
Enabled: enabled,
Provider: provider,
BaseURL: baseURL,
APIKey: settings["ai_api_key"],
Model: model,
}
}
// NewEnrichmentHandler creates a worker action handler for AI variant enrichment.
func NewEnrichmentHandler(database *db.DB, client *ai.Client, log *zap.Logger) worker.ActionHandler {
return func(ctx context.Context, entry models.FeedEntry, params map[string]interface{}) (*worker.ActionResult, error) {
cfg := LoadConfig(database)
if cfg.APIKey == "" && cfg.Provider != "ollama" {
log.Warn("AI action skipped: API key is not configured in Settings > AI & Integrations",
zap.String("entry_id", entry.ID),
zap.String("provider", cfg.Provider),
)
return nil, nil
}
prompt, _ := params["prompt"].(string)
if prompt == "" {
prompt, _ = params["custom_prompt"].(string)
}
vKey, _ := params["variant_key"].(string)
if vKey == "" {
vKey = "ai_summary"
}
cType, _ := params["content_type"].(string)
if cType == "" {
cType = "text"
}
resultText, err := client.GenerateEnrichment(ctx, cfg, prompt, entry)
if err != nil {
log.Error("AI enrichment generation failed",
zap.String("entry_id", entry.ID),
zap.String("model", cfg.Model),
zap.Error(err),
)
return nil, err
}
variant := models.EntryVariant{
EntryID: entry.ID,
VariantKey: vKey,
ContentType: cType,
TextValue: resultText,
Metadata: map[string]interface{}{
"model": cfg.Model,
"provider": cfg.Provider,
"prompt_used": prompt,
},
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
log.Info("AI enrichment completed",
zap.String("entry_id", entry.ID),
zap.String("variant_key", vKey),
zap.String("model", cfg.Model),
)
return &worker.ActionResult{
ResultVariant: &variant,
}, nil
}
}
+88
View File
@@ -0,0 +1,88 @@
package ai
import (
"context"
"fmt"
"time"
"git.pengzhan.dev/haopengzhan/rsspipe/internal/ai"
"git.pengzhan.dev/haopengzhan/rsspipe/internal/db"
"git.pengzhan.dev/haopengzhan/rsspipe/internal/models"
"git.pengzhan.dev/haopengzhan/rsspipe/internal/worker"
"go.uber.org/zap"
)
// NewTriageHandler creates a worker action handler for AI autonomous triage decisions.
func NewTriageHandler(database *db.DB, client *ai.Client, pool *worker.Pool, log *zap.Logger) worker.ActionHandler {
return func(ctx context.Context, entry models.FeedEntry, params map[string]interface{}) (*worker.ActionResult, error) {
cfg := LoadConfig(database)
if cfg.APIKey == "" && cfg.Provider != "ollama" {
log.Warn("AI triage action skipped: API key is not configured in Settings > AI & Integrations",
zap.String("entry_id", entry.ID),
)
return nil, nil
}
policyPrompt, _ := params["prompt"].(string)
var allowedActions []string
if rawAllowed, ok := params["allowed_actions"].([]interface{}); ok {
for _, a := range rawAllowed {
allowedActions = append(allowedActions, fmt.Sprintf("%v", a))
}
} else if strList, ok := params["allowed_actions"].([]string); ok {
allowedActions = strList
}
if len(allowedActions) == 0 {
allowedActions = []string{"mark_read", "mark_starred", "set_variant"}
}
decision, err := client.GenerateAgentDecision(ctx, cfg, policyPrompt, allowedActions, entry)
if err != nil {
log.Error("AI triage decision failed",
zap.String("entry_id", entry.ID),
zap.Error(err),
)
return nil, err
}
log.Info("AI Triage agent executed successfully",
zap.String("entry_id", entry.ID),
zap.String("reasoning", decision.Reasoning),
zap.Int("actions_approved", len(decision.Actions)),
)
res := &worker.ActionResult{}
for _, act := range decision.Actions {
switch act.Type {
case "mark_read":
trueVal := true
res.MarkRead = &trueVal
case "mark_starred":
trueVal := true
res.MarkStarred = &trueVal
case "set_variant":
vKey, _ := act.Params["variant_key"].(string)
if vKey == "" {
vKey, _ = act.Params["key"].(string)
}
vVal, _ := act.Params["value"].(string)
if vKey != "" {
v := models.EntryVariant{
EntryID: entry.ID,
VariantKey: vKey,
ContentType: "text",
TextValue: vVal,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
res.ResultVariant = &v
}
default:
pool.Dispatch(ctx, act, entry, 0)
}
}
return res, nil
}
}
+36
View File
@@ -0,0 +1,36 @@
package actions
import (
"time"
"git.pengzhan.dev/haopengzhan/rsspipe/internal/actions/ai"
"git.pengzhan.dev/haopengzhan/rsspipe/internal/actions/readability"
"git.pengzhan.dev/haopengzhan/rsspipe/internal/actions/status"
"git.pengzhan.dev/haopengzhan/rsspipe/internal/actions/variants"
aiClientPkg "git.pengzhan.dev/haopengzhan/rsspipe/internal/ai"
"git.pengzhan.dev/haopengzhan/rsspipe/internal/db"
"git.pengzhan.dev/haopengzhan/rsspipe/internal/worker"
"go.uber.org/zap"
)
// RegisterDefaultHandlers registers all core action handlers with the worker pool.
func RegisterDefaultHandlers(pool *worker.Pool, database *db.DB, log *zap.Logger) {
// 1. Readability Full-Text Scraper
scraper := readability.NewScraper(15 * time.Second)
pool.RegisterHandler("readability_scrape", scraper.Handler())
// 2. Status Handlers (mark_read, mark_starred)
pool.RegisterHandler("mark_read", status.NewMarkReadHandler())
pool.RegisterHandler("mark_starred", status.NewMarkStarredHandler())
// 3. Variant Handlers (set_variant with tag merging)
pool.RegisterHandler("set_variant", variants.NewSetVariantHandler(database))
// 4. AI Subsystem Handlers
aiClient := aiClientPkg.NewClient(45 * time.Second)
enrichHandler := ai.NewEnrichmentHandler(database, aiClient, log)
pool.RegisterHandler("ai_enrich", enrichHandler)
pool.RegisterHandler("ai_summary", enrichHandler) // Backward compatibility alias
pool.RegisterHandler("ai_agent_action", ai.NewTriageHandler(database, aiClient, pool, log))
}
+28
View File
@@ -0,0 +1,28 @@
package status
import (
"context"
"git.pengzhan.dev/haopengzhan/rsspipe/internal/models"
"git.pengzhan.dev/haopengzhan/rsspipe/internal/worker"
)
// NewMarkReadHandler marks an entry as read.
func NewMarkReadHandler() worker.ActionHandler {
return func(ctx context.Context, entry models.FeedEntry, params map[string]interface{}) (*worker.ActionResult, error) {
trueVal := true
return &worker.ActionResult{
MarkRead: &trueVal,
}, nil
}
}
// NewMarkStarredHandler marks an entry as starred.
func NewMarkStarredHandler() worker.ActionHandler {
return func(ctx context.Context, entry models.FeedEntry, params map[string]interface{}) (*worker.ActionResult, error) {
trueVal := true
return &worker.ActionResult{
MarkStarred: &trueVal,
}, nil
}
}
+60
View File
@@ -0,0 +1,60 @@
package variants
import (
"context"
"strings"
"time"
"git.pengzhan.dev/haopengzhan/rsspipe/internal/db"
"git.pengzhan.dev/haopengzhan/rsspipe/internal/models"
"git.pengzhan.dev/haopengzhan/rsspipe/internal/worker"
)
// NewSetVariantHandler creates a worker action handler for attaching extensible variant metadata.
func NewSetVariantHandler(database *db.DB) worker.ActionHandler {
return func(ctx context.Context, entry models.FeedEntry, params map[string]interface{}) (*worker.ActionResult, error) {
key, _ := params["variant_key"].(string)
if key == "" {
key, _ = params["key"].(string)
}
if key == "" {
return nil, nil
}
val, _ := params["value"].(string)
contentType, _ := params["content_type"].(string)
if contentType == "" {
contentType = "text"
}
// If key == "tags", merge with existing tags so multiple rules append tags
if key == "tags" {
existingVariant, err := database.GetVariant(entry.ID, "tags")
if err == nil && existingVariant != nil && existingVariant.TextValue != "" {
existingTags := strings.Split(existingVariant.TextValue, ",")
newTags := strings.Split(val, ",")
tagSet := make(map[string]bool)
var merged []string
for _, t := range append(existingTags, newTags...) {
trimmed := strings.TrimSpace(t)
if trimmed != "" && !tagSet[strings.ToLower(trimmed)] {
tagSet[strings.ToLower(trimmed)] = true
merged = append(merged, trimmed)
}
}
val = strings.Join(merged, ", ")
}
}
variant := models.EntryVariant{
EntryID: entry.ID,
VariantKey: key,
ContentType: contentType,
TextValue: val,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
return &worker.ActionResult{
ResultVariant: &variant,
}, nil
}
}