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
}
}
+356
View File
@@ -0,0 +1,356 @@
package ai
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"git.pengzhan.dev/haopengzhan/rsspipe/internal/models"
)
type Config struct {
Enabled bool `json:"enabled"`
Provider string `json:"provider"`
BaseURL string `json:"base_url"`
APIKey string `json:"api_key"`
Model string `json:"model"`
Temperature float64 `json:"temperature"`
MaxTokens int `json:"max_tokens"`
}
type PromptTemplate struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Prompt string `json:"prompt"`
IsDefault bool `json:"is_default,omitempty"`
}
type TestResult struct {
Success bool `json:"success"`
Message string `json:"message"`
LatencyMs int64 `json:"latency_ms"`
Model string `json:"model"`
Duration time.Duration `json:"duration"`
}
type AgentDecision struct {
Reasoning string `json:"reasoning"`
Actions []models.RuleAction `json:"actions"`
}
type Client struct {
httpClient *http.Client
}
func NewClient(timeout time.Duration) *Client {
if timeout <= 0 {
timeout = 45 * time.Second
}
return &Client{
httpClient: &http.Client{
Timeout: timeout,
},
}
}
// DefaultPromptTemplates returns built-in prompt templates.
func DefaultPromptTemplates() []PromptTemplate {
return []PromptTemplate{
{
ID: "exec_summary",
Name: "3-Bullet Executive Brief",
Description: "3 concise bullet points followed by a single key takeaway.",
Prompt: "You are an expert technical editor. Summarize the following article in exactly 3 concise bullet points, followed by a bold 'Key Takeaway:' sentence.\n\nTitle: {{title}}\nFeed: {{feed_title}}\nContent:\n{{content}}",
IsDefault: true,
},
{
ID: "tech_deep_dive",
Name: "Technical Deep Dive",
Description: "Architecture, key technical concepts, and implementation highlights.",
Prompt: "Analyze the following technical post. Highlight:\n1. Core Architecture & Concepts\n2. Key Innovations / Techniques\n3. Practical Implications for Engineers\n\nTitle: {{title}}\nContent:\n{{content}}",
IsDefault: false,
},
{
ID: "news_general",
Name: "General News & Context",
Description: "Accessible summary with necessary background context for general readers.",
Prompt: "Summarize this news article for a general audience. Explain what happened, why it matters, and provide any helpful context in 2-3 short paragraphs.\n\nTitle: {{title}}\nContent:\n{{content}}",
IsDefault: false,
},
}
}
type chatMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
type chatCompletionRequest struct {
Model string `json:"model"`
Messages []chatMessage `json:"messages"`
Temperature float64 `json:"temperature,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
}
type chatCompletionResponse struct {
Choices []struct {
Message struct {
Role string `json:"role"`
Content string `json:"content"`
} `json:"message"`
FinishReason string `json:"finish_reason"`
} `json:"choices"`
Error *struct {
Message string `json:"message"`
Type string `json:"type"`
} `json:"error,omitempty"`
}
func (c *Client) callChatCompletion(ctx context.Context, cfg Config, req chatCompletionRequest) (string, error) {
baseURL := strings.TrimRight(cfg.BaseURL, "/")
if baseURL == "" {
baseURL = "https://api.openai.com/v1"
}
endpoint := baseURL + "/chat/completions"
bodyBytes, err := json.Marshal(req)
if err != nil {
return "", err
}
httpReq, err := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewReader(bodyBytes))
if err != nil {
return "", err
}
httpReq.Header.Set("Content-Type", "application/json")
if cfg.APIKey != "" {
httpReq.Header.Set("Authorization", "Bearer "+cfg.APIKey)
}
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return "", fmt.Errorf("HTTP request failed: %w", err)
}
defer func() {
_ = resp.Body.Close()
}()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read response body: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(respBody))
}
var chatResp chatCompletionResponse
if err := json.Unmarshal(respBody, &chatResp); err != nil {
return "", fmt.Errorf("failed to decode response JSON: %w", err)
}
if chatResp.Error != nil && chatResp.Error.Message != "" {
return "", fmt.Errorf("LLM error: %s", chatResp.Error.Message)
}
if len(chatResp.Choices) == 0 {
return "", fmt.Errorf("LLM returned no choices")
}
return chatResp.Choices[0].Message.Content, nil
}
// TestConnection pings the LLM with a strictly capped request (max_tokens: 15) to verify credentials.
func (c *Client) TestConnection(ctx context.Context, cfg Config) (*TestResult, error) {
start := time.Now()
req := chatCompletionRequest{
Model: cfg.Model,
Messages: []chatMessage{
{Role: "user", Content: "Ping test. Reply with 'OK' in exactly two words."},
},
Temperature: 0.1,
MaxTokens: 15, // Strictly capped to prevent token exhaustion
}
reply, err := c.callChatCompletion(ctx, cfg, req)
duration := time.Since(start)
if err != nil {
return &TestResult{
Success: false,
Message: err.Error(),
LatencyMs: duration.Milliseconds(),
Model: cfg.Model,
Duration: duration,
}, err
}
return &TestResult{
Success: true,
Message: strings.TrimSpace(reply),
LatencyMs: duration.Milliseconds(),
Model: cfg.Model,
Duration: duration,
}, nil
}
// GenerateEnrichment interpolates the prompt template and queries the LLM to generate variant content.
func (c *Client) GenerateEnrichment(ctx context.Context, cfg Config, promptTemplate string, entry models.FeedEntry) (string, error) {
if promptTemplate == "" {
promptTemplate = DefaultPromptTemplates()[0].Prompt
}
content := entry.Content
if entry.RawContent != "" && len(entry.RawContent) > len(content) {
content = entry.RawContent
}
if entry.Variants != nil {
if v, ok := entry.Variants["readability_full_text"]; ok && len(v.TextValue) > len(content) {
content = v.TextValue
}
}
// Truncate content to avoid exceeding token windows (e.g. max ~24,000 characters)
if len(content) > 24000 {
content = content[:24000] + "... [truncated]"
}
interpolated := promptTemplate
interpolated = strings.ReplaceAll(interpolated, "{{title}}", entry.Title)
interpolated = strings.ReplaceAll(interpolated, "{{content}}", content)
interpolated = strings.ReplaceAll(interpolated, "{{feed_title}}", entry.FeedTitle)
interpolated = strings.ReplaceAll(interpolated, "{{author}}", entry.Author)
interpolated = strings.ReplaceAll(interpolated, "{{url}}", entry.URL)
interpolated = strings.ReplaceAll(interpolated, "{{published_at}}", entry.PublishedAt.Format("2006-01-02 15:04:05"))
maxTokens := cfg.MaxTokens
if maxTokens <= 0 {
maxTokens = 1000
}
temp := cfg.Temperature
if temp <= 0 {
temp = 0.3
}
req := chatCompletionRequest{
Model: cfg.Model,
Messages: []chatMessage{
{Role: "user", Content: interpolated},
},
Temperature: temp,
MaxTokens: maxTokens,
}
return c.callChatCompletion(ctx, cfg, req)
}
// GenerateAgentDecision asks the LLM to evaluate the article and propose actions from allowedActions only.
func (c *Client) GenerateAgentDecision(ctx context.Context, cfg Config, policyPrompt string, allowedActions []string, entry models.FeedEntry) (*AgentDecision, error) {
allowedListStr := strings.Join(allowedActions, ", ")
content := entry.Content
if entry.RawContent != "" && len(entry.RawContent) > len(content) {
content = entry.RawContent
}
if entry.Variants != nil {
if v, ok := entry.Variants["readability_full_text"]; ok && len(v.TextValue) > len(content) {
content = v.TextValue
}
}
if len(content) > 16000 {
content = content[:16000] + "... [truncated]"
}
systemInstruction := fmt.Sprintf(`You are an autonomous RSS triage agent.
Your goal is to evaluate the provided article and propose actions strictly from the Allowed Action Pool.
Allowed Action Pool:
%s
Rules:
1. You may ONLY propose actions that exist in the Allowed Action Pool.
2. Output ONLY a valid JSON object matching this schema:
{
"reasoning": "brief explanation of your decision",
"actions": [
{"type": "action_type", "params": {"key": "value"}}
]
}
If no action is warranted, return {"reasoning": "...", "actions": []}.
Do not wrap in markdown backticks or other text. Return raw JSON only.`, allowedListStr)
userContent := fmt.Sprintf("Evaluation Policy:\n%s\n\nArticle Title: %s\nFeed: %s\nContent:\n%s", policyPrompt, entry.Title, entry.FeedTitle, content)
req := chatCompletionRequest{
Model: cfg.Model,
Messages: []chatMessage{
{Role: "system", Content: systemInstruction},
{Role: "user", Content: userContent},
},
Temperature: 0.1,
MaxTokens: 800,
}
rawJSON, err := c.callChatCompletion(ctx, cfg, req)
if err != nil {
return nil, err
}
// Clean code fence formatting if returned
rawJSON = strings.TrimSpace(rawJSON)
rawJSON = strings.TrimPrefix(rawJSON, "```json")
rawJSON = strings.TrimPrefix(rawJSON, "```")
rawJSON = strings.TrimSuffix(rawJSON, "```")
rawJSON = strings.TrimSpace(rawJSON)
var parsed struct {
Reasoning string `json:"reasoning"`
Actions []struct {
Type string `json:"type"`
Params map[string]interface{} `json:"params,omitempty"`
} `json:"actions"`
}
if err := json.Unmarshal([]byte(rawJSON), &parsed); err != nil {
return nil, fmt.Errorf("failed to parse AI decision JSON: %w (raw: %s)", err, rawJSON)
}
// Strict Guardrail: Validate all actions against allowedActions
allowedSet := make(map[string]bool)
for _, a := range allowedActions {
allowedSet[strings.ToLower(strings.TrimSpace(a))] = true
}
var validatedActions []models.RuleAction
for _, act := range parsed.Actions {
actTypeLower := strings.ToLower(strings.TrimSpace(act.Type))
// Check basic action type or composite key (e.g. set_variant:tags:xxx)
isAllowed := allowedSet[actTypeLower]
if !isAllowed && actTypeLower == "set_variant" && act.Params != nil {
vKey := fmt.Sprintf("%v", act.Params["variant_key"])
vVal := fmt.Sprintf("%v", act.Params["value"])
compositeKey := strings.ToLower(fmt.Sprintf("set_variant:%s:%s", vKey, vVal))
isAllowed = allowedSet[compositeKey] || allowedSet["set_variant"]
}
if isAllowed {
validatedActions = append(validatedActions, models.RuleAction{
Type: act.Type,
Params: act.Params,
})
}
}
return &AgentDecision{
Reasoning: parsed.Reasoning,
Actions: validatedActions,
}, nil
}
+212
View File
@@ -0,0 +1,212 @@
package ai
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"testing"
"time"
"git.pengzhan.dev/haopengzhan/rsspipe/internal/models"
)
type roundTripFunc func(req *http.Request) *http.Response
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req), nil
}
func TestTestConnection(t *testing.T) {
client := &Client{
httpClient: &http.Client{
Transport: roundTripFunc(func(req *http.Request) *http.Response {
if req.Header.Get("Authorization") != "Bearer test-key" {
return &http.Response{
StatusCode: http.StatusUnauthorized,
Body: io.NopCloser(bytes.NewBufferString(`{"error": {"message": "unauthorized"}}`)),
Header: make(http.Header),
}
}
resp := chatCompletionResponse{
Choices: []struct {
Message struct {
Role string `json:"role"`
Content string `json:"content"`
} `json:"message"`
FinishReason string `json:"finish_reason"`
}{
{
Message: struct {
Role string `json:"role"`
Content string `json:"content"`
}{
Role: "assistant",
Content: "OK connection",
},
FinishReason: "stop",
},
},
}
data, _ := json.Marshal(resp)
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewReader(data)),
Header: make(http.Header),
}
}),
},
}
cfg := Config{
Enabled: true,
Provider: "custom",
BaseURL: "https://mock.api",
APIKey: "test-key",
Model: "test-model",
}
res, err := client.TestConnection(context.Background(), cfg)
if err != nil {
t.Fatalf("TestConnection failed: %v", err)
}
if !res.Success {
t.Fatalf("Expected success true, got false")
}
if res.Message != "OK connection" {
t.Fatalf("Expected 'OK connection', got %s", res.Message)
}
}
func TestGenerateEnrichment(t *testing.T) {
client := &Client{
httpClient: &http.Client{
Transport: roundTripFunc(func(req *http.Request) *http.Response {
resp := chatCompletionResponse{
Choices: []struct {
Message struct {
Role string `json:"role"`
Content string `json:"content"`
} `json:"message"`
FinishReason string `json:"finish_reason"`
}{
{
Message: struct {
Role string `json:"role"`
Content string `json:"content"`
}{
Role: "assistant",
Content: "- Point 1\n- Point 2\n- Point 3\n\n**Key Takeaway**: Great post.",
},
FinishReason: "stop",
},
},
}
data, _ := json.Marshal(resp)
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewReader(data)),
Header: make(http.Header),
}
}),
},
}
cfg := Config{
Enabled: true,
BaseURL: "https://mock.api",
Model: "gpt-4o-mini",
MaxTokens: 500,
}
entry := models.FeedEntry{
ID: "e-1",
Title: "Kubernetes & GPU Optimization",
FeedTitle: "Cloud Native Blog",
Content: "Detailed technical content about GPU sharing in Kubernetes.",
PublishedAt: time.Now(),
}
summary, err := client.GenerateEnrichment(context.Background(), cfg, "Summarize: {{title}}\n{{content}}", entry)
if err != nil {
t.Fatalf("GenerateEnrichment failed: %v", err)
}
if summary == "" {
t.Fatalf("Expected summary string, got empty")
}
}
func TestGenerateAgentDecision(t *testing.T) {
client := &Client{
httpClient: &http.Client{
Transport: roundTripFunc(func(req *http.Request) *http.Response {
resp := chatCompletionResponse{
Choices: []struct {
Message struct {
Role string `json:"role"`
Content string `json:"content"`
} `json:"message"`
FinishReason string `json:"finish_reason"`
}{
{
Message: struct {
Role string `json:"role"`
Content string `json:"content"`
}{
Role: "assistant",
Content: `{
"reasoning": "High priority kubernetes security patch",
"actions": [
{"type": "mark_starred"},
{"type": "set_variant", "params": {"variant_key": "tags", "value": "security-alert"}},
{"type": "malicious_unallowed_action"}
]
}`,
},
FinishReason: "stop",
},
},
}
data, _ := json.Marshal(resp)
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewReader(data)),
Header: make(http.Header),
}
}),
},
}
cfg := Config{
Enabled: true,
BaseURL: "https://mock.api",
Model: "gpt-4o-mini",
}
entry := models.FeedEntry{
ID: "e-1",
Title: "Critical CVE in Kubelet",
FeedTitle: "Kubernetes Security",
Content: "Remote code execution flaw patched in v1.36.",
}
allowed := []string{"mark_starred", "set_variant:tags:security-alert"}
decision, err := client.GenerateAgentDecision(context.Background(), cfg, "Evaluate urgency", allowed, entry)
if err != nil {
t.Fatalf("GenerateAgentDecision failed: %v", err)
}
if decision.Reasoning == "" {
t.Fatalf("Expected reasoning, got empty")
}
// Verify strict validation: malicious_unallowed_action was stripped
if len(decision.Actions) != 2 {
t.Fatalf("Expected 2 validated actions, got %d", len(decision.Actions))
}
if decision.Actions[0].Type != "mark_starred" {
t.Fatalf("Expected mark_starred, got %s", decision.Actions[0].Type)
}
}
+206 -10
View File
@@ -9,8 +9,10 @@ import (
"net/http"
"strconv"
"strings"
"sync"
"time"
"git.pengzhan.dev/haopengzhan/rsspipe/internal/ai"
"git.pengzhan.dev/haopengzhan/rsspipe/internal/auth"
"git.pengzhan.dev/haopengzhan/rsspipe/internal/crawler"
"git.pengzhan.dev/haopengzhan/rsspipe/internal/db"
@@ -23,20 +25,25 @@ import (
)
type Handler struct {
db *db.DB
auth *auth.AuthService
crawler *crawler.Crawler
parser *parser.Parser
pool *worker.Pool
db *db.DB
auth *auth.AuthService
crawler *crawler.Crawler
parser *parser.Parser
pool *worker.Pool
aiClient *ai.Client
testRateMu sync.Mutex
testPings []time.Time
}
func NewHandler(database *db.DB, authService *auth.AuthService, cr *crawler.Crawler, pool *worker.Pool) *Handler {
return &Handler{
db: database,
auth: authService,
crawler: cr,
parser: parser.NewParser(),
pool: pool,
db: database,
auth: authService,
crawler: cr,
parser: parser.NewParser(),
pool: pool,
aiClient: ai.NewClient(30 * time.Second),
testPings: []time.Time{},
}
}
@@ -771,3 +778,192 @@ func (h *Handler) ApplyRule(w http.ResponseWriter, r *http.Request) {
"queued_jobs": queuedJobs,
})
}
// AI Settings Handlers
type aiSettingsResponse struct {
Enabled bool `json:"enabled"`
Provider string `json:"provider"`
BaseURL string `json:"base_url"`
APIKey string `json:"api_key"`
Model string `json:"model"`
Temperature float64 `json:"temperature"`
MaxTokens int `json:"max_tokens"`
Prompts []ai.PromptTemplate `json:"prompts"`
}
func maskAPIKey(key string) string {
if len(key) == 0 {
return ""
}
if len(key) <= 6 {
return "••••••"
}
return key[:3] + "••••••••" + key[len(key)-4:]
}
func (h *Handler) GetAISettings(w http.ResponseWriter, r *http.Request) {
settings, err := h.db.GetSettingsMap()
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to load settings: "+err.Error())
return
}
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"
}
temp, _ := strconv.ParseFloat(settings["ai_temperature"], 64)
if temp <= 0 {
temp = 0.3
}
maxTokens, _ := strconv.Atoi(settings["ai_max_tokens"])
if maxTokens <= 0 {
maxTokens = 1000
}
var prompts []ai.PromptTemplate
if promptsJSON := settings["ai_prompts"]; promptsJSON != "" {
_ = json.Unmarshal([]byte(promptsJSON), &prompts)
}
if len(prompts) == 0 {
prompts = ai.DefaultPromptTemplates()
}
h.writeJSON(w, http.StatusOK, aiSettingsResponse{
Enabled: enabled,
Provider: provider,
BaseURL: baseURL,
APIKey: maskAPIKey(settings["ai_api_key"]),
Model: model,
Temperature: temp,
MaxTokens: maxTokens,
Prompts: prompts,
})
}
func (h *Handler) UpdateAISettings(w http.ResponseWriter, r *http.Request) {
var req struct {
Enabled *bool `json:"enabled"`
Provider string `json:"provider"`
BaseURL string `json:"base_url"`
APIKey string `json:"api_key"`
Model string `json:"model"`
Temperature float64 `json:"temperature"`
MaxTokens int `json:"max_tokens"`
Prompts []ai.PromptTemplate `json:"prompts"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid JSON request")
return
}
if req.Enabled != nil {
val := "false"
if *req.Enabled {
val = "true"
}
_ = h.db.SetSetting("ai_enabled", val)
}
if req.Provider != "" {
_ = h.db.SetSetting("ai_provider", req.Provider)
}
if req.BaseURL != "" {
_ = h.db.SetSetting("ai_base_url", req.BaseURL)
}
if req.Model != "" {
_ = h.db.SetSetting("ai_model", req.Model)
}
if req.Temperature > 0 {
_ = h.db.SetSetting("ai_temperature", fmt.Sprintf("%.2f", req.Temperature))
}
if req.MaxTokens > 0 {
_ = h.db.SetSetting("ai_max_tokens", strconv.Itoa(req.MaxTokens))
}
// Update API key only if not masked with bullets
if req.APIKey != "" && !strings.Contains(req.APIKey, "••••") {
_ = h.db.SetSetting("ai_api_key", strings.TrimSpace(req.APIKey))
}
if len(req.Prompts) > 0 {
promptsBytes, _ := json.Marshal(req.Prompts)
_ = h.db.SetSetting("ai_prompts", string(promptsBytes))
}
h.GetAISettings(w, r)
}
// TestAIConnection checks LLM connectivity with strict rate limiting & token caps
func (h *Handler) TestAIConnection(w http.ResponseWriter, r *http.Request) {
// Rate limiting: Max 5 test pings per minute to prevent token exhaustion
h.testRateMu.Lock()
now := time.Now()
var recentPings []time.Time
for _, t := range h.testPings {
if now.Sub(t) < time.Minute {
recentPings = append(recentPings, t)
}
}
if len(recentPings) >= 5 {
h.testRateMu.Unlock()
h.writeError(w, http.StatusTooManyRequests, "Rate limit exceeded. Maximum 5 connection tests per minute.")
return
}
h.testPings = append(recentPings, now)
h.testRateMu.Unlock()
var req struct {
Provider string `json:"provider"`
BaseURL string `json:"base_url"`
APIKey string `json:"api_key"`
Model string `json:"model"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
settings, _ := h.db.GetSettingsMap()
apiKey := req.APIKey
if apiKey == "" || strings.Contains(apiKey, "••••") {
apiKey = settings["ai_api_key"]
}
baseURL := req.BaseURL
if baseURL == "" {
baseURL = settings["ai_base_url"]
}
if baseURL == "" {
baseURL = "https://api.openai.com/v1"
}
model := req.Model
if model == "" {
model = settings["ai_model"]
}
if model == "" {
model = "gpt-4o-mini"
}
cfg := ai.Config{
Enabled: true,
Provider: req.Provider,
BaseURL: baseURL,
APIKey: apiKey,
Model: model,
}
res, err := h.aiClient.TestConnection(r.Context(), cfg)
if err != nil {
h.writeJSON(w, http.StatusOK, res)
return
}
h.writeJSON(w, http.StatusOK, res)
}
+5
View File
@@ -77,6 +77,11 @@ func NewRouter(h *Handler, authService *auth.AuthService, staticDir string) http
r.Delete("/rules/{id}", h.DeleteRule)
r.Post("/rules/{id}/apply", h.ApplyRule)
// AI Settings
r.Get("/settings/ai", h.GetAISettings)
r.Put("/settings/ai", h.UpdateAISettings)
r.Post("/settings/ai/test", h.TestAIConnection)
// OPML
r.Post("/opml/import", h.ImportOPML)
r.Get("/opml/export", h.ExportOPML)
+49
View File
@@ -139,6 +139,12 @@ func (d *DB) migrate() error {
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
CREATE TABLE IF NOT EXISTS system_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at DATETIME NOT NULL
);
`
if _, err := d.Exec(schema); err != nil {
return err
@@ -1067,3 +1073,46 @@ func (d *DB) GetStats() (*models.Stats, error) {
_ = d.QueryRow("SELECT COUNT(*) FROM rules").Scan(&s.TotalRules)
return &s, nil
}
// System Settings CRUD
func (d *DB) GetSetting(key string) (string, error) {
var val string
err := d.QueryRow("SELECT value FROM system_settings WHERE key = ?", key).Scan(&val)
if err != nil {
if err == sql.ErrNoRows {
return "", nil
}
return "", err
}
return val, nil
}
func (d *DB) SetSetting(key, value string) error {
now := time.Now().UTC()
_, err := d.Exec(`
INSERT INTO system_settings (key, value, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
`, key, value, now)
return err
}
func (d *DB) GetSettingsMap() (map[string]string, error) {
rows, err := d.Query("SELECT key, value FROM system_settings")
if err != nil {
return nil, err
}
defer func() {
_ = rows.Close()
}()
res := make(map[string]string)
for rows.Next() {
var k, v string
if err := rows.Scan(&k, &v); err != nil {
return nil, err
}
res[k] = v
}
return res, nil
}