From d57bcc15b6fd4773af0272da564081b83aa46314 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Mon, 6 Jul 2026 21:26:52 -0400 Subject: [PATCH] feat(nadezhda): M6 AI ideation layer (ideate + TUI i-key) Add an opt-in ideation layer that turns ranked story clusters into content angles. A Provider interface backs four models: qwen (default, local Ollama), OpenAI, and Gemini share one OpenAI-compatible client; an Anthropic Messages client is written against raw net/http (no vendor SDK, uniform + dep-light). Results persist in ai_notes with a UNIQUE(cluster_id, provider) index (migration 0003), so re-ideation upserts in place. The ideate command skips any (cluster, provider) that already has a note unless --force is passed; benign classifier refusals get their own counter and never fail the run. In the TUI, i ideates the selected cluster inline and renders an AI IDEAS section in the dossier; it is a no-op unless AI is enabled and only acts in the detail view. --- .../cmd/nadezhda/ideate.go | 181 ++++++++++++++++++ .../cmd/nadezhda/ideate_test.go | 133 +++++++++++++ .../cmd/nadezhda/stubs.go | 1 - .../security-news-scraper/cmd/nadezhda/tui.go | 46 ++++- .../internal/ai/anthropic.go | 84 ++++++++ .../internal/ai/client_test.go | 125 ++++++++++++ .../internal/ai/factory_test.go | 83 ++++++++ .../security-news-scraper/internal/ai/http.go | 65 +++++++ .../security-news-scraper/internal/ai/mock.go | 25 +++ .../internal/ai/mock_test.go | 36 ++++ .../internal/ai/openai.go | 78 ++++++++ .../internal/ai/prompt.go | 128 +++++++++++++ .../internal/ai/prompt_test.go | 87 +++++++++ .../internal/ai/provider.go | 126 ++++++++++++ .../internal/store/ai_notes.go | 92 +++++++++ .../internal/store/ai_notes_test.go | 127 ++++++++++++ .../0003_ai_notes_provider_unique.sql | 4 + .../internal/tui/browser.go | 6 +- .../internal/tui/detail.go | 11 ++ .../internal/tui/keys.go | 5 + .../internal/tui/list.go | 4 +- .../internal/tui/model.go | 69 ++++++- .../internal/tui/model_test.go | 95 ++++++++- .../internal/tui/view.go | 5 +- 24 files changed, 1598 insertions(+), 18 deletions(-) create mode 100644 PROJECTS/intermediate/security-news-scraper/cmd/nadezhda/ideate.go create mode 100644 PROJECTS/intermediate/security-news-scraper/cmd/nadezhda/ideate_test.go create mode 100644 PROJECTS/intermediate/security-news-scraper/internal/ai/anthropic.go create mode 100644 PROJECTS/intermediate/security-news-scraper/internal/ai/client_test.go create mode 100644 PROJECTS/intermediate/security-news-scraper/internal/ai/factory_test.go create mode 100644 PROJECTS/intermediate/security-news-scraper/internal/ai/http.go create mode 100644 PROJECTS/intermediate/security-news-scraper/internal/ai/mock.go create mode 100644 PROJECTS/intermediate/security-news-scraper/internal/ai/mock_test.go create mode 100644 PROJECTS/intermediate/security-news-scraper/internal/ai/openai.go create mode 100644 PROJECTS/intermediate/security-news-scraper/internal/ai/prompt.go create mode 100644 PROJECTS/intermediate/security-news-scraper/internal/ai/prompt_test.go create mode 100644 PROJECTS/intermediate/security-news-scraper/internal/ai/provider.go create mode 100644 PROJECTS/intermediate/security-news-scraper/internal/store/ai_notes.go create mode 100644 PROJECTS/intermediate/security-news-scraper/internal/store/ai_notes_test.go create mode 100644 PROJECTS/intermediate/security-news-scraper/internal/store/migrations/0003_ai_notes_provider_unique.sql diff --git a/PROJECTS/intermediate/security-news-scraper/cmd/nadezhda/ideate.go b/PROJECTS/intermediate/security-news-scraper/cmd/nadezhda/ideate.go new file mode 100644 index 00000000..3bd6d329 --- /dev/null +++ b/PROJECTS/intermediate/security-news-scraper/cmd/nadezhda/ideate.go @@ -0,0 +1,181 @@ +// ©AngelaMos | 2026 +// ideate.go + +package main + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "time" + + "github.com/spf13/cobra" + + "github.com/CarterPerez-dev/nadezhda/internal/ai" + "github.com/CarterPerez-dev/nadezhda/internal/rank" + "github.com/CarterPerez-dev/nadezhda/internal/setup" + "github.com/CarterPerez-dev/nadezhda/internal/store" +) + +const defaultIdeateTop = 10 + +var ( + ideateTop int + ideateSince string + ideateProvider string + ideateForce bool +) + +var ideateCmd = &cobra.Command{ + Use: "ideate", + Short: "Generate content angles from ranked clusters via an AI provider (opt-in)", + RunE: runIdeate, +} + +func init() { + ideateCmd.Flags().IntVar(&ideateTop, "top", defaultIdeateTop, "ideate the top N ranked clusters") + ideateCmd.Flags().StringVar(&ideateSince, "since", "", "only clusters active within this window (e.g. 24h, 168h)") + ideateCmd.Flags().StringVar(&ideateProvider, "provider", "", "override the configured provider: qwen|openai|anthropic|gemini") + ideateCmd.Flags().BoolVar(&ideateForce, "force", false, "re-ideate clusters that already have a note for this provider") + rootCmd.AddCommand(ideateCmd) +} + +func runIdeate(cmd *cobra.Command, args []string) error { + cfg, err := loadConfig() + if err != nil { + return err + } + + aiCfg := cfg.AI + if ideateProvider != "" { + aiCfg.Provider = ideateProvider + aiCfg.Enabled = true + } + if !aiCfg.Enabled { + if !isInteractive(cmd) { + return fmt.Errorf("AI is not set up — run `nadezhda ai` to configure a provider") + } + fmt.Fprintln(cmd.OutOrStdout(), "AI is not set up yet — let's fix that.") + if err := setup.Run(cmd.InOrStdin(), cmd.OutOrStdout()); err != nil { + return err + } + cfg, err = loadConfig() + if err != nil { + return err + } + aiCfg = cfg.AI + if !aiCfg.Enabled { + return fmt.Errorf("AI still not configured after setup") + } + if aiCfg.Provider == ai.ProviderQwen && !setup.OllamaReachable(aiCfg.Qwen.BaseURL) { + fmt.Fprintln(cmd.OutOrStdout(), "Ollama isn't reachable yet — finish the steps above, then run: nadezhda ideate") + return nil + } + } + provider, err := ai.Factory(aiCfg) + if err != nil { + return err + } + + now := time.Now() + var since int64 + if ideateSince != "" { + d, err := time.ParseDuration(ideateSince) + if err != nil { + return fmt.Errorf("invalid --since %q: %w", ideateSince, err) + } + since = now.Add(-d).Unix() + } + + st, err := store.Open(cfg.DBPath) + if err != nil { + return err + } + defer st.Close() + + clusters, err := st.DigestClusters(since) + if err != nil { + return err + } + scored := rank.Rank(clusters, cfg.Rank, cfg.Watchlist, now) + if ideateTop > 0 && ideateTop < len(scored) { + scored = scored[:ideateTop] + } + + out := cmd.OutOrStdout() + ctx := cmd.Context() + var generated, skipped, refused, failed int + + for _, s := range scored { + cid := s.Cluster.ClusterID + if !ideateForce { + exists, err := st.AINoteExists(cid, provider.Name()) + if err != nil { + return err + } + if exists { + skipped++ + fmt.Fprintf(out, "skip cluster %d (already ideated by %s; use --force)\n", cid, provider.Name()) + continue + } + } + + res, err := provider.Generate(ctx, ai.RequestFromCluster(s.Cluster)) + if err != nil { + if errors.Is(err, ai.ErrRefused) { + refused++ + fmt.Fprintf(out, "refused cluster %d (provider declined); skipping\n", cid) + } else { + failed++ + fmt.Fprintf(out, "warn cluster %d: %v\n", cid, err) + } + continue + } + + angles, err := json.Marshal(res.Angles) + if err != nil { + return err + } + note := store.AINote{ + ClusterID: cid, + Provider: provider.Name(), + Summary: res.Summary, + Why: res.Why, + AnglesJSON: string(angles), + Format: res.Format, + CreatedAt: time.Now().Unix(), + } + if err := st.InsertAINote(note); err != nil { + return err + } + generated++ + printIdeation(out, s, res) + } + + fmt.Fprintf(out, "\nideated %d, skipped %d, refused %d, failed %d (provider: %s)\n", generated, skipped, refused, failed, provider.Name()) + if generated == 0 && failed > 0 { + return fmt.Errorf("all %d ideation attempts failed", failed) + } + return nil +} + +func printIdeation(out io.Writer, s rank.Scored, res ai.IdeationResult) { + fmt.Fprintf(out, "\n=== cluster %d score %.2f [%s] ===\n", s.Cluster.ClusterID, s.Score, res.Format) + fmt.Fprintf(out, "%s\n\n", clusterHeadline(s.Cluster)) + fmt.Fprintf(out, "summary: %s\n\n", res.Summary) + fmt.Fprintf(out, "why: %s\n\n", res.Why) + fmt.Fprintln(out, "angles:") + for i, a := range res.Angles { + fmt.Fprintf(out, " %d. %s\n", i+1, a) + } +} + +func clusterHeadline(c store.DigestCluster) string { + for _, a := range c.Articles { + if a.Title != "" { + return a.Title + } + } + return "(untitled cluster)" +} diff --git a/PROJECTS/intermediate/security-news-scraper/cmd/nadezhda/ideate_test.go b/PROJECTS/intermediate/security-news-scraper/cmd/nadezhda/ideate_test.go new file mode 100644 index 00000000..e860e078 --- /dev/null +++ b/PROJECTS/intermediate/security-news-scraper/cmd/nadezhda/ideate_test.go @@ -0,0 +1,133 @@ +// ©AngelaMos | 2026 +// ideate_test.go + +package main + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/CarterPerez-dev/nadezhda/internal/setup" + "github.com/CarterPerez-dev/nadezhda/internal/store" +) + +func seedCluster(t *testing.T, st *store.Store) { + t.Helper() + stmts := []string{ + `INSERT INTO sources (id, name, url, type, weight, enabled) VALUES (1, 'krebs', 'https://krebsonsecurity.com', 'rss', 1.0, 1)`, + `INSERT INTO articles (id, source_id, canonical_url, content_hash, title, published_at) VALUES (1, 1, 'https://krebsonsecurity.com/a', 'h1', 'Critical RCE exploited in the wild', 1000)`, + `INSERT INTO clusters (id, cluster_key, first_seen, last_seen, size) VALUES (1, 'k1', 900, 1000, 1)`, + `INSERT INTO cluster_members (cluster_id, article_id) VALUES (1, 1)`, + } + for _, s := range stmts { + if _, err := st.DB().Exec(s); err != nil { + t.Fatalf("seed %q: %v", s, err) + } + } +} + +func TestIdeateCommandEndToEnd(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv(setup.EnvProvider, "") + t.Setenv(setup.EnvQwenURL, "") + dir := t.TempDir() + dbPath := filepath.Join(dir, "test.db") + st, err := store.Open(dbPath) + if err != nil { + t.Fatalf("open store: %v", err) + } + seedCluster(t, st) + st.Close() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := json.Marshal(map[string]any{ + "choices": []map[string]any{{"message": map[string]any{ + "content": `{"summary":"a critical rce","why":"widely exploited","angles":["hook one","hook two","hook three"],"format":"newsletter"}`, + }}}, + }) + _, _ = w.Write(body) + })) + defer srv.Close() + + cfgPath := filepath.Join(dir, "config.yaml") + cfgYAML := "ai:\n enabled: true\n provider: qwen\n qwen:\n base_url: " + srv.URL + "\n model: qwen2.5:7b\n" + if err := os.WriteFile(cfgPath, []byte(cfgYAML), 0o644); err != nil { + t.Fatal(err) + } + + prevCfg, prevDB, prevTop, prevProv, prevForce, prevSince := flagConfig, flagDB, ideateTop, ideateProvider, ideateForce, ideateSince + defer func() { + flagConfig, flagDB, ideateTop, ideateProvider, ideateForce, ideateSince = prevCfg, prevDB, prevTop, prevProv, prevForce, prevSince + }() + flagConfig, flagDB, ideateTop, ideateProvider, ideateForce, ideateSince = cfgPath, dbPath, 1, "", false, "" + + cmd := &cobra.Command{} + cmd.SetContext(context.Background()) + var buf bytes.Buffer + cmd.SetOut(&buf) + + if err := runIdeate(cmd, nil); err != nil { + t.Fatalf("runIdeate: %v\noutput:\n%s", err, buf.String()) + } + out := buf.String() + for _, want := range []string{"ideated 1", "a critical rce", "hook one", "newsletter"} { + if !strings.Contains(out, want) { + t.Errorf("ideate output missing %q\n---\n%s", want, out) + } + } + + st2, err := store.Open(dbPath) + if err != nil { + t.Fatal(err) + } + ok, err := st2.AINoteExists(1, "qwen") + st2.Close() + if err != nil || !ok { + t.Fatalf("note not persisted: ok=%v err=%v", ok, err) + } + + buf.Reset() + if err := runIdeate(cmd, nil); err != nil { + t.Fatalf("second runIdeate: %v", err) + } + if !strings.Contains(buf.String(), "skip cluster 1") { + t.Errorf("second run should skip existing note:\n%s", buf.String()) + } + + buf.Reset() + ideateForce = true + if err := runIdeate(cmd, nil); err != nil { + t.Fatalf("force runIdeate: %v", err) + } + if !strings.Contains(buf.String(), "ideated 1") { + t.Errorf("--force should regenerate, not skip:\n%s", buf.String()) + } +} + +func TestIdeateDisabledErrors(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv(setup.EnvProvider, "") + t.Setenv(setup.EnvQwenURL, "") + prevCfg, prevProv := flagConfig, ideateProvider + defer func() { flagConfig, ideateProvider = prevCfg, prevProv }() + flagConfig, ideateProvider = "", "" + + cmd := &cobra.Command{} + cmd.SetContext(context.Background()) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetIn(strings.NewReader("")) + + err := runIdeate(cmd, nil) + if err == nil || !strings.Contains(err.Error(), "not set up") { + t.Fatalf("expected a 'not set up' error, got %v", err) + } +} diff --git a/PROJECTS/intermediate/security-news-scraper/cmd/nadezhda/stubs.go b/PROJECTS/intermediate/security-news-scraper/cmd/nadezhda/stubs.go index 5dbe4177..9a0cee9e 100644 --- a/PROJECTS/intermediate/security-news-scraper/cmd/nadezhda/stubs.go +++ b/PROJECTS/intermediate/security-news-scraper/cmd/nadezhda/stubs.go @@ -21,7 +21,6 @@ func init() { short string milestone string }{ - {"ideate", "Generate content angles from ranked clusters via an AI provider", "milestone M6"}, {"watch", "Run as a daemon, re-ingesting on an interval", "milestone M7"}, } for _, s := range stubs { diff --git a/PROJECTS/intermediate/security-news-scraper/cmd/nadezhda/tui.go b/PROJECTS/intermediate/security-news-scraper/cmd/nadezhda/tui.go index 093b87ac..5135e0bd 100644 --- a/PROJECTS/intermediate/security-news-scraper/cmd/nadezhda/tui.go +++ b/PROJECTS/intermediate/security-news-scraper/cmd/nadezhda/tui.go @@ -4,11 +4,13 @@ package main import ( + "encoding/json" "fmt" "time" "github.com/spf13/cobra" + "github.com/CarterPerez-dev/nadezhda/internal/ai" "github.com/CarterPerez-dev/nadezhda/internal/rank" "github.com/CarterPerez-dev/nadezhda/internal/store" "github.com/CarterPerez-dev/nadezhda/internal/tui" @@ -68,8 +70,48 @@ func runTUI(cmd *cobra.Command, args []string) error { detail[v.ID] = full } } - return tui.Data{Scored: scored, CVEDetail: detail}, nil + notes := map[int64]ai.IdeationResult{} + if persisted, err := st.LatestAINotes(); err == nil { + for cid, n := range persisted { + var angles []string + _ = json.Unmarshal([]byte(n.AnglesJSON), &angles) + notes[cid] = ai.IdeationResult{Summary: n.Summary, Why: n.Why, Angles: angles, Format: n.Format} + } + } + return tui.Data{Scored: scored, CVEDetail: detail, Notes: notes}, nil } - return tui.Run(loader) + var ideator tui.Ideator + if cfg.AI.Enabled { + provider, err := ai.Factory(cfg.AI) + if err != nil { + return err + } + ctx := cmd.Context() + ideator = func(c store.DigestCluster) (ai.IdeationResult, error) { + res, err := provider.Generate(ctx, ai.RequestFromCluster(c)) + if err != nil { + return ai.IdeationResult{}, err + } + angles, err := json.Marshal(res.Angles) + if err != nil { + return ai.IdeationResult{}, err + } + note := store.AINote{ + ClusterID: c.ClusterID, + Provider: provider.Name(), + Summary: res.Summary, + Why: res.Why, + AnglesJSON: string(angles), + Format: res.Format, + CreatedAt: time.Now().Unix(), + } + if err := st.InsertAINote(note); err != nil { + return ai.IdeationResult{}, fmt.Errorf("save note: %w", err) + } + return res, nil + } + } + + return tui.Run(loader, ideator) } diff --git a/PROJECTS/intermediate/security-news-scraper/internal/ai/anthropic.go b/PROJECTS/intermediate/security-news-scraper/internal/ai/anthropic.go new file mode 100644 index 00000000..411e1250 --- /dev/null +++ b/PROJECTS/intermediate/security-news-scraper/internal/ai/anthropic.go @@ -0,0 +1,84 @@ +// ©AngelaMos | 2026 +// anthropic.go + +package ai + +import ( + "context" + "net/http" + "strings" +) + +const ( + anthropicMessagesPath = "/messages" + anthropicVersion = "2023-06-01" + headerAPIKey = "x-api-key" + headerAnthropicVer = "anthropic-version" + stopReasonRefusal = "refusal" + contentTypeText = "text" +) + +type anthropicClient struct { + http *http.Client + baseURL string + model string + apiKey string +} + +func newAnthropic(client *http.Client, baseURL, model, apiKey string) *anthropicClient { + return &anthropicClient{ + http: client, + baseURL: strings.TrimRight(baseURL, "/"), + model: model, + apiKey: apiKey, + } +} + +func (c *anthropicClient) Name() string { return ProviderAnthropic } + +type anthropicMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +type anthropicRequest struct { + Model string `json:"model"` + MaxTokens int `json:"max_tokens"` + System string `json:"system"` + Messages []anthropicMessage `json:"messages"` +} + +type anthropicResponse struct { + StopReason string `json:"stop_reason"` + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"content"` +} + +func (c *anthropicClient) Generate(ctx context.Context, req IdeationRequest) (IdeationResult, error) { + system, user := buildPrompt(req) + body := anthropicRequest{ + Model: c.model, + MaxTokens: defaultMaxTokens, + System: system, + Messages: []anthropicMessage{{Role: roleUser, Content: user}}, + } + header := http.Header{} + header.Set(headerAPIKey, c.apiKey) + header.Set(headerAnthropicVer, anthropicVersion) + var resp anthropicResponse + if err := postJSON(ctx, c.http, c.baseURL+anthropicMessagesPath, header, body, &resp); err != nil { + return IdeationResult{}, err + } + if resp.StopReason == stopReasonRefusal { + return IdeationResult{}, ErrRefused + } + var text strings.Builder + for _, block := range resp.Content { + if block.Type == contentTypeText { + text.WriteString(block.Text) + } + } + return parseResult(text.String()) +} diff --git a/PROJECTS/intermediate/security-news-scraper/internal/ai/client_test.go b/PROJECTS/intermediate/security-news-scraper/internal/ai/client_test.go new file mode 100644 index 00000000..0769a7e3 --- /dev/null +++ b/PROJECTS/intermediate/security-news-scraper/internal/ai/client_test.go @@ -0,0 +1,125 @@ +// ©AngelaMos | 2026 +// client_test.go + +package ai + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" +) + +const ideationJSON = `{"summary":"s","why":"w","angles":["a","b","c"],"format":"video"}` + +func TestOpenAICompatGenerate(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "Bearer k" { + t.Errorf("Authorization = %q, want Bearer k", got) + } + body, _ := json.Marshal(map[string]any{ + "choices": []map[string]any{{"message": map[string]any{"role": "assistant", "content": ideationJSON}}}, + }) + _, _ = w.Write(body) + })) + defer srv.Close() + + c := newOpenAICompat("openai", srv.Client(), srv.URL, "gpt-4o-mini", "k") + res, err := c.Generate(context.Background(), IdeationRequest{Titles: []string{"t"}}) + if err != nil { + t.Fatalf("Generate: %v", err) + } + if res.Summary != "s" || res.Format != FormatVideo || len(res.Angles) != 3 { + t.Errorf("got %+v", res) + } + if c.Name() != "openai" { + t.Errorf("Name = %q", c.Name()) + } +} + +func TestOpenAICompatNoKeyNoAuth(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "" { + t.Errorf("Authorization = %q, want empty (qwen has no key)", got) + } + body, _ := json.Marshal(map[string]any{ + "choices": []map[string]any{{"message": map[string]any{"content": ideationJSON}}}, + }) + _, _ = w.Write(body) + })) + defer srv.Close() + + c := newOpenAICompat("qwen", srv.Client(), srv.URL, "qwen2.5:7b", "") + if _, err := c.Generate(context.Background(), IdeationRequest{Titles: []string{"t"}}); err != nil { + t.Fatalf("Generate: %v", err) + } +} + +func TestOpenAICompatNoChoices(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"choices":[]}`)) + })) + defer srv.Close() + + c := newOpenAICompat("openai", srv.Client(), srv.URL, "m", "k") + if _, err := c.Generate(context.Background(), IdeationRequest{}); err == nil { + t.Error("expected error on empty choices") + } +} + +func TestOpenAICompatStatusError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"bad key"}`)) + })) + defer srv.Close() + + c := newOpenAICompat("openai", srv.Client(), srv.URL, "m", "k") + if _, err := c.Generate(context.Background(), IdeationRequest{}); err == nil { + t.Error("expected error on 401") + } +} + +func TestAnthropicGenerate(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("x-api-key"); got != "k" { + t.Errorf("x-api-key = %q, want k", got) + } + if got := r.Header.Get("anthropic-version"); got != "2023-06-01" { + t.Errorf("anthropic-version = %q, want 2023-06-01", got) + } + body, _ := json.Marshal(map[string]any{ + "stop_reason": "end_turn", + "content": []map[string]any{{"type": "text", "text": ideationJSON}}, + }) + _, _ = w.Write(body) + })) + defer srv.Close() + + c := newAnthropic(srv.Client(), srv.URL, "claude-sonnet-4-6", "k") + res, err := c.Generate(context.Background(), IdeationRequest{Titles: []string{"t"}}) + if err != nil { + t.Fatalf("Generate: %v", err) + } + if res.Summary != "s" || res.Format != FormatVideo { + t.Errorf("got %+v", res) + } + if c.Name() != ProviderAnthropic { + t.Errorf("Name = %q", c.Name()) + } +} + +func TestAnthropicRefusal(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"stop_reason":"refusal","content":[]}`)) + })) + defer srv.Close() + + c := newAnthropic(srv.Client(), srv.URL, "claude-sonnet-4-6", "k") + _, err := c.Generate(context.Background(), IdeationRequest{}) + if !errors.Is(err, ErrRefused) { + t.Errorf("err = %v, want ErrRefused", err) + } +} diff --git a/PROJECTS/intermediate/security-news-scraper/internal/ai/factory_test.go b/PROJECTS/intermediate/security-news-scraper/internal/ai/factory_test.go new file mode 100644 index 00000000..c2ad740a --- /dev/null +++ b/PROJECTS/intermediate/security-news-scraper/internal/ai/factory_test.go @@ -0,0 +1,83 @@ +// ©AngelaMos | 2026 +// factory_test.go + +package ai + +import ( + "testing" + + "github.com/CarterPerez-dev/nadezhda/internal/config" + "github.com/CarterPerez-dev/nadezhda/internal/store" +) + +func TestFactoryProviders(t *testing.T) { + base := config.Default().AI + + t.Setenv(envOpenAIKey, "") + t.Setenv(envGeminiKey, "") + t.Setenv(envAnthropicKey, "") + + base.Provider = ProviderQwen + p, err := Factory(base) + if err != nil || p.Name() != ProviderQwen { + t.Fatalf("qwen: p=%v err=%v", p, err) + } + + base.Provider = ProviderOpenAI + if _, err := Factory(base); err == nil { + t.Error("openai without key should error") + } + t.Setenv(envOpenAIKey, "k") + if p, err := Factory(base); err != nil || p.Name() != ProviderOpenAI { + t.Errorf("openai: p=%v err=%v", p, err) + } + + base.Provider = ProviderGemini + if _, err := Factory(base); err == nil { + t.Error("gemini without key should error") + } + t.Setenv(envGeminiKey, "k") + if p, err := Factory(base); err != nil || p.Name() != ProviderGemini { + t.Errorf("gemini: p=%v err=%v", p, err) + } + + base.Provider = ProviderAnthropic + if _, err := Factory(base); err == nil { + t.Error("anthropic without key should error") + } + t.Setenv(envAnthropicKey, "k") + if p, err := Factory(base); err != nil || p.Name() != ProviderAnthropic { + t.Errorf("anthropic: p=%v err=%v", p, err) + } + + base.Provider = "nope" + if _, err := Factory(base); err == nil { + t.Error("unknown provider should error") + } +} + +func TestRequestFromCluster(t *testing.T) { + cvss := 9.8 + epss := 0.5 + c := store.DigestCluster{ + Size: 3, + FirstSeen: 0, + LastSeen: 6 * secondsPerHour, + Articles: []store.DigestArticle{ + {Title: "A", SourceName: "krebs"}, + {Title: "A", SourceName: "krebs"}, + {Title: "B", SourceName: "register"}, + }, + CVEs: []store.DigestCVE{{ID: "CVE-2025-1", CVSSScore: &cvss, EPSS: &epss, IsKEV: true}}, + } + req := RequestFromCluster(c) + if len(req.Titles) != 2 || len(req.Sources) != 2 { + t.Errorf("dedup failed: titles=%v sources=%v", req.Titles, req.Sources) + } + if req.SpanHours != 6 || req.ClusterSize != 3 { + t.Errorf("span=%d size=%d", req.SpanHours, req.ClusterSize) + } + if len(req.CVEs) != 1 || !req.CVEs[0].KEV || req.CVEs[0].CVSS == nil { + t.Errorf("cve mapping: %+v", req.CVEs) + } +} diff --git a/PROJECTS/intermediate/security-news-scraper/internal/ai/http.go b/PROJECTS/intermediate/security-news-scraper/internal/ai/http.go new file mode 100644 index 00000000..9b83d02d --- /dev/null +++ b/PROJECTS/intermediate/security-news-scraper/internal/ai/http.go @@ -0,0 +1,65 @@ +// ©AngelaMos | 2026 +// http.go + +package ai + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" +) + +const ( + headerContentType = "Content-Type" + mimeJSON = "application/json" + errBodyLimit = 300 +) + +type statusError struct { + code int + url string + body string +} + +func (e *statusError) Error() string { + body := e.body + if len(body) > errBodyLimit { + body = strings.ToValidUTF8(body[:errBodyLimit], "") + "..." + } + return fmt.Sprintf("ai: POST %s: status %d: %s", e.url, e.code, body) +} + +func postJSON(ctx context.Context, client *http.Client, url string, header http.Header, reqBody, out any) error { + raw, err := json.Marshal(reqBody) + if err != nil { + return fmt.Errorf("ai: marshal request: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(raw)) + if err != nil { + return fmt.Errorf("ai: build request %s: %w", url, err) + } + req.Header.Set(headerContentType, mimeJSON) + for key, values := range header { + req.Header[key] = values + } + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("ai: POST %s: %w", url, err) + } + defer resp.Body.Close() + data, err := io.ReadAll(io.LimitReader(resp.Body, maxJSONBytes)) + if err != nil { + return fmt.Errorf("ai: read %s: %w", url, err) + } + if resp.StatusCode != http.StatusOK { + return &statusError{code: resp.StatusCode, url: url, body: string(data)} + } + if err := json.Unmarshal(data, out); err != nil { + return fmt.Errorf("ai: decode %s: %w", url, err) + } + return nil +} diff --git a/PROJECTS/intermediate/security-news-scraper/internal/ai/mock.go b/PROJECTS/intermediate/security-news-scraper/internal/ai/mock.go new file mode 100644 index 00000000..65f2ca8c --- /dev/null +++ b/PROJECTS/intermediate/security-news-scraper/internal/ai/mock.go @@ -0,0 +1,25 @@ +// ©AngelaMos | 2026 +// mock.go + +package ai + +import "context" + +type MockProvider struct { + ProviderName string + Result IdeationResult + Err error + Calls int +} + +func (m *MockProvider) Name() string { + if m.ProviderName == "" { + return "mock" + } + return m.ProviderName +} + +func (m *MockProvider) Generate(ctx context.Context, req IdeationRequest) (IdeationResult, error) { + m.Calls++ + return m.Result, m.Err +} diff --git a/PROJECTS/intermediate/security-news-scraper/internal/ai/mock_test.go b/PROJECTS/intermediate/security-news-scraper/internal/ai/mock_test.go new file mode 100644 index 00000000..4a54329f --- /dev/null +++ b/PROJECTS/intermediate/security-news-scraper/internal/ai/mock_test.go @@ -0,0 +1,36 @@ +// ©AngelaMos | 2026 +// mock_test.go + +package ai + +import ( + "context" + "testing" +) + +func TestMockProvider(t *testing.T) { + want := IdeationResult{Summary: "s", Angles: []string{"a"}, Format: FormatBlog} + m := &MockProvider{ProviderName: "mock", Result: want} + if m.Name() != "mock" { + t.Errorf("Name = %q, want mock", m.Name()) + } + got, err := m.Generate(context.Background(), IdeationRequest{}) + if err != nil { + t.Fatalf("Generate: %v", err) + } + if got.Summary != "s" || len(got.Angles) != 1 { + t.Errorf("result = %+v", got) + } + if m.Calls != 1 { + t.Errorf("Calls = %d, want 1", m.Calls) + } + + sentinel := context.Canceled + me := &MockProvider{Err: sentinel} + if me.Name() != "mock" { + t.Errorf("default Name = %q, want mock", me.Name()) + } + if _, err := me.Generate(context.Background(), IdeationRequest{}); err != sentinel { + t.Errorf("err = %v, want the injected sentinel", err) + } +} diff --git a/PROJECTS/intermediate/security-news-scraper/internal/ai/openai.go b/PROJECTS/intermediate/security-news-scraper/internal/ai/openai.go new file mode 100644 index 00000000..30fe0f8a --- /dev/null +++ b/PROJECTS/intermediate/security-news-scraper/internal/ai/openai.go @@ -0,0 +1,78 @@ +// ©AngelaMos | 2026 +// openai.go + +package ai + +import ( + "context" + "fmt" + "net/http" + "strings" +) + +const ( + openAIChatPath = "/chat/completions" + roleSystem = "system" + roleUser = "user" + headerAuth = "Authorization" + bearerPrefix = "Bearer " +) + +type openAICompat struct { + name string + http *http.Client + baseURL string + model string + apiKey string +} + +func newOpenAICompat(name string, client *http.Client, baseURL, model, apiKey string) *openAICompat { + return &openAICompat{ + name: name, + http: client, + baseURL: strings.TrimRight(baseURL, "/"), + model: model, + apiKey: apiKey, + } +} + +func (c *openAICompat) Name() string { return c.name } + +type openAIMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +type openAIRequest struct { + Model string `json:"model"` + Messages []openAIMessage `json:"messages"` +} + +type openAIResponse struct { + Choices []struct { + Message openAIMessage `json:"message"` + } `json:"choices"` +} + +func (c *openAICompat) Generate(ctx context.Context, req IdeationRequest) (IdeationResult, error) { + system, user := buildPrompt(req) + body := openAIRequest{ + Model: c.model, + Messages: []openAIMessage{ + {Role: roleSystem, Content: system}, + {Role: roleUser, Content: user}, + }, + } + header := http.Header{} + if c.apiKey != "" { + header.Set(headerAuth, bearerPrefix+c.apiKey) + } + var resp openAIResponse + if err := postJSON(ctx, c.http, c.baseURL+openAIChatPath, header, body, &resp); err != nil { + return IdeationResult{}, err + } + if len(resp.Choices) == 0 { + return IdeationResult{}, fmt.Errorf("ai: %s returned no choices", c.name) + } + return parseResult(resp.Choices[0].Message.Content) +} diff --git a/PROJECTS/intermediate/security-news-scraper/internal/ai/prompt.go b/PROJECTS/intermediate/security-news-scraper/internal/ai/prompt.go new file mode 100644 index 00000000..768d9f0c --- /dev/null +++ b/PROJECTS/intermediate/security-news-scraper/internal/ai/prompt.go @@ -0,0 +1,128 @@ +// ©AngelaMos | 2026 +// prompt.go + +package ai + +import ( + "encoding/json" + "fmt" + "strings" +) + +const ( + systemPrompt = `You are a senior security-content strategist helping a creator turn cybersecurity news into content. You are given a cluster of related news articles (one story covered by one or more outlets), optionally with referenced CVEs and their exploit signals. Produce content ideation for the story. + +Respond with a SINGLE JSON object and NOTHING else. No prose, no markdown, no code fences. The object has exactly these keys: + "summary": a 2-3 sentence plain-language summary of the story. + "why": one paragraph on why it matters to a security audience (impact, who is affected, exploitation). + "angles": an array of 3 to 5 distinct content angles or hooks a creator could lead with. + "format": the single best-fit format, one of: "blog", "newsletter", "video". + +Ground every claim in the provided material. Do not invent CVEs, vendors, or facts not present in the input.` + + jsonObjectOpen byte = '{' + jsonObjectClose byte = '}' +) + +func buildPrompt(req IdeationRequest) (string, string) { + var b strings.Builder + fmt.Fprintf(&b, "Story cluster: %d article(s) across %d outlet(s), spanning ~%dh.\n\n", req.ClusterSize, len(req.Sources), req.SpanHours) + if len(req.Sources) > 0 { + fmt.Fprintf(&b, "Outlets: %s\n\n", strings.Join(req.Sources, ", ")) + } + b.WriteString("Headlines:\n") + for _, t := range req.Titles { + fmt.Fprintf(&b, "- %s\n", t) + } + if len(req.CVEs) > 0 { + b.WriteString("\nReferenced CVEs:\n") + for _, c := range req.CVEs { + b.WriteString("- " + c.ID) + if c.CVSS != nil { + fmt.Fprintf(&b, " CVSS %.1f", *c.CVSS) + } + if c.KEV { + b.WriteString(" [KEV: known exploited]") + } + if c.EPSS != nil { + fmt.Fprintf(&b, " EPSS %.2f", *c.EPSS) + } + b.WriteString("\n") + } + } + return systemPrompt, b.String() +} + +func parseResult(text string) (IdeationResult, error) { + for _, obj := range jsonObjectCandidates(text) { + var res IdeationResult + if err := json.Unmarshal([]byte(obj), &res); err != nil { + continue + } + res.Format = normalizeFormat(res.Format) + if strings.TrimSpace(res.Summary) != "" && len(res.Angles) > 0 { + return res, nil + } + } + return IdeationResult{}, fmt.Errorf("ai: no usable JSON ideation object in model output") +} + +func jsonObjectCandidates(text string) []string { + var out []string + for i := 0; i < len(text); { + if text[i] != jsonObjectOpen { + i++ + continue + } + end := balancedObjectEnd(text, i) + if end < 0 { + break + } + out = append(out, text[i:end+1]) + i = end + 1 + } + return out +} + +func balancedObjectEnd(text string, start int) int { + depth := 0 + inStr := false + esc := false + for i := start; i < len(text); i++ { + ch := text[i] + if inStr { + switch { + case esc: + esc = false + case ch == '\\': + esc = true + case ch == '"': + inStr = false + } + continue + } + switch ch { + case '"': + inStr = true + case jsonObjectOpen: + depth++ + case jsonObjectClose: + depth-- + if depth == 0 { + return i + } + } + } + return -1 +} + +func normalizeFormat(f string) string { + switch strings.ToLower(strings.TrimSpace(f)) { + case FormatNewsletter: + return FormatNewsletter + case FormatVideo: + return FormatVideo + default: + return FormatBlog + } +} diff --git a/PROJECTS/intermediate/security-news-scraper/internal/ai/prompt_test.go b/PROJECTS/intermediate/security-news-scraper/internal/ai/prompt_test.go new file mode 100644 index 00000000..2468a542 --- /dev/null +++ b/PROJECTS/intermediate/security-news-scraper/internal/ai/prompt_test.go @@ -0,0 +1,87 @@ +// ©AngelaMos | 2026 +// prompt_test.go + +package ai + +import ( + "strings" + "testing" +) + +func TestBuildPromptIncludesContext(t *testing.T) { + cvss := 9.8 + epss := 0.97 + _, user := buildPrompt(IdeationRequest{ + Titles: []string{"Massive breach at ACME"}, + Sources: []string{"Krebs", "The Register"}, + CVEs: []CVEContext{{ID: "CVE-2025-5777", CVSS: &cvss, KEV: true, EPSS: &epss}}, + ClusterSize: 2, + SpanHours: 6, + }) + for _, want := range []string{"Massive breach at ACME", "Krebs", "The Register", "CVE-2025-5777", "CVSS 9.8", "KEV", "EPSS 0.97"} { + if !strings.Contains(user, want) { + t.Errorf("user prompt missing %q\n---\n%s", want, user) + } + } +} + +func TestParseResultValid(t *testing.T) { + res, err := parseResult(`{"summary":"s","why":"w","angles":["a","b","c"],"format":"video"}`) + if err != nil { + t.Fatalf("parseResult: %v", err) + } + if res.Summary != "s" || res.Why != "w" || len(res.Angles) != 3 || res.Format != FormatVideo { + t.Errorf("got %+v", res) + } +} + +func TestParseResultTolerantWrapper(t *testing.T) { + raw := "Here is the JSON:\n```json\n{\"summary\":\"s\",\"why\":\"w\",\"angles\":[\"a\"],\"format\":\"blog\"}\n```\nHope that helps." + res, err := parseResult(raw) + if err != nil { + t.Fatalf("parseResult: %v", err) + } + if res.Summary != "s" || len(res.Angles) != 1 { + t.Errorf("got %+v", res) + } +} + +func TestParseResultIgnoresPreJSONBraces(t *testing.T) { + raw := `Let me plan {step: outline} then answer: {"summary":"real","why":"w","angles":["a","b"],"format":"blog"} done.` + res, err := parseResult(raw) + if err != nil { + t.Fatalf("parseResult: %v", err) + } + if res.Summary != "real" || len(res.Angles) != 2 { + t.Errorf("got %+v", res) + } +} + +func TestParseResultErrors(t *testing.T) { + cases := []string{ + "no json here", + `{"why":"w","angles":["a"],"format":"blog"}`, + `{"summary":"s","why":"w","angles":[],"format":"blog"}`, + `{"summary":"s",`, + } + for _, c := range cases { + if _, err := parseResult(c); err == nil { + t.Errorf("parseResult(%q) = nil error, want error", c) + } + } +} + +func TestNormalizeFormat(t *testing.T) { + cases := map[string]string{ + "newsletter": FormatNewsletter, + "VIDEO": FormatVideo, + "blog": FormatBlog, + "nonsense": FormatBlog, + "": FormatBlog, + } + for in, want := range cases { + if got := normalizeFormat(in); got != want { + t.Errorf("normalizeFormat(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/PROJECTS/intermediate/security-news-scraper/internal/ai/provider.go b/PROJECTS/intermediate/security-news-scraper/internal/ai/provider.go new file mode 100644 index 00000000..5b82484c --- /dev/null +++ b/PROJECTS/intermediate/security-news-scraper/internal/ai/provider.go @@ -0,0 +1,126 @@ +// ©AngelaMos | 2026 +// provider.go + +package ai + +import ( + "context" + "errors" + "fmt" + "net/http" + "os" + "time" + + "github.com/CarterPerez-dev/nadezhda/internal/config" + "github.com/CarterPerez-dev/nadezhda/internal/store" +) + +const ( + ProviderQwen = "qwen" + ProviderOpenAI = "openai" + ProviderGemini = "gemini" + ProviderAnthropic = "anthropic" + + FormatBlog = "blog" + FormatNewsletter = "newsletter" + FormatVideo = "video" + + envOpenAIKey = "OPENAI_API_KEY" + envGeminiKey = "GEMINI_API_KEY" + envAnthropicKey = "ANTHROPIC_API_KEY" + + requestTimeout = 120 * time.Second + defaultMaxTokens = 2048 + maxJSONBytes = 4 << 20 + secondsPerHour = 3600 +) + +var ErrRefused = errors.New("ai: provider declined to generate for this item") + +type CVEContext struct { + ID string + CVSS *float64 + KEV bool + EPSS *float64 +} + +type IdeationRequest struct { + Titles []string + Sources []string + CVEs []CVEContext + ClusterSize int + SpanHours int +} + +type IdeationResult struct { + Summary string `json:"summary"` + Why string `json:"why"` + Angles []string `json:"angles"` + Format string `json:"format"` +} + +type Provider interface { + Name() string + Generate(ctx context.Context, req IdeationRequest) (IdeationResult, error) +} + +func noRedirect(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse +} + +func Factory(cfg config.AI) (Provider, error) { + client := &http.Client{Timeout: requestTimeout, CheckRedirect: noRedirect} + switch cfg.Provider { + case ProviderQwen: + return newOpenAICompat(ProviderQwen, client, cfg.Qwen.BaseURL, cfg.Qwen.Model, ""), nil + case ProviderOpenAI: + key := os.Getenv(envOpenAIKey) + if key == "" { + return nil, fmt.Errorf("ai: provider %q requires %s in the environment", ProviderOpenAI, envOpenAIKey) + } + return newOpenAICompat(ProviderOpenAI, client, cfg.OpenAI.BaseURL, cfg.OpenAI.Model, key), nil + case ProviderGemini: + key := os.Getenv(envGeminiKey) + if key == "" { + return nil, fmt.Errorf("ai: provider %q requires %s in the environment", ProviderGemini, envGeminiKey) + } + return newOpenAICompat(ProviderGemini, client, cfg.Gemini.BaseURL, cfg.Gemini.Model, key), nil + case ProviderAnthropic: + key := os.Getenv(envAnthropicKey) + if key == "" { + return nil, fmt.Errorf("ai: provider %q requires %s in the environment", ProviderAnthropic, envAnthropicKey) + } + return newAnthropic(client, cfg.Anthropic.BaseURL, cfg.Anthropic.Model, key), nil + default: + return nil, fmt.Errorf("ai: unknown provider %q", cfg.Provider) + } +} + +func RequestFromCluster(c store.DigestCluster) IdeationRequest { + req := IdeationRequest{ClusterSize: c.Size} + if c.LastSeen > c.FirstSeen { + req.SpanHours = int((c.LastSeen - c.FirstSeen) / secondsPerHour) + } + seenTitle := make(map[string]bool) + seenSource := make(map[string]bool) + for _, a := range c.Articles { + if a.Title != "" && !seenTitle[a.Title] { + seenTitle[a.Title] = true + req.Titles = append(req.Titles, a.Title) + } + if a.SourceName != "" && !seenSource[a.SourceName] { + seenSource[a.SourceName] = true + req.Sources = append(req.Sources, a.SourceName) + } + } + for _, v := range c.CVEs { + req.CVEs = append(req.CVEs, CVEContext{ID: v.ID, CVSS: v.CVSSScore, KEV: v.IsKEV, EPSS: v.EPSS}) + } + return req +} + +var ( + _ Provider = (*openAICompat)(nil) + _ Provider = (*anthropicClient)(nil) + _ Provider = (*MockProvider)(nil) +) diff --git a/PROJECTS/intermediate/security-news-scraper/internal/store/ai_notes.go b/PROJECTS/intermediate/security-news-scraper/internal/store/ai_notes.go new file mode 100644 index 00000000..229eedcd --- /dev/null +++ b/PROJECTS/intermediate/security-news-scraper/internal/store/ai_notes.go @@ -0,0 +1,92 @@ +// ©AngelaMos | 2026 +// ai_notes.go + +package store + +import ( + "database/sql" + "errors" + "fmt" +) + +type AINote struct { + ID int64 + ClusterID int64 + Provider string + Summary string + Why string + AnglesJSON string + Format string + CreatedAt int64 +} + +func (s *Store) InsertAINote(n AINote) error { + _, err := s.db.Exec(` + INSERT INTO ai_notes (cluster_id, provider, summary, why, angles_json, format, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(cluster_id, provider) DO UPDATE SET + summary = excluded.summary, + why = excluded.why, + angles_json = excluded.angles_json, + format = excluded.format, + created_at = excluded.created_at`, + n.ClusterID, n.Provider, n.Summary, n.Why, n.AnglesJSON, n.Format, n.CreatedAt, + ) + if err != nil { + return fmt.Errorf("insert ai_note cluster=%d provider=%q: %w", n.ClusterID, n.Provider, err) + } + return nil +} + +func (s *Store) AINoteExists(clusterID int64, provider string) (bool, error) { + var one int + err := s.db.QueryRow( + `SELECT 1 FROM ai_notes WHERE cluster_id = ? AND provider = ? LIMIT 1`, + clusterID, provider, + ).Scan(&one) + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("ai_note exists cluster=%d provider=%q: %w", clusterID, provider, err) + } + return true, nil +} + +func (s *Store) AINotesForCluster(clusterID int64) ([]AINote, error) { + rows, err := s.db.Query(` + SELECT id, cluster_id, provider, summary, why, angles_json, format, created_at + FROM ai_notes WHERE cluster_id = ? ORDER BY provider`, clusterID) + if err != nil { + return nil, fmt.Errorf("ai_notes for cluster %d: %w", clusterID, err) + } + defer rows.Close() + var out []AINote + for rows.Next() { + var n AINote + if err := rows.Scan(&n.ID, &n.ClusterID, &n.Provider, &n.Summary, &n.Why, &n.AnglesJSON, &n.Format, &n.CreatedAt); err != nil { + return nil, fmt.Errorf("ai_notes for cluster %d: scan: %w", clusterID, err) + } + out = append(out, n) + } + return out, rows.Err() +} + +func (s *Store) LatestAINotes() (map[int64]AINote, error) { + rows, err := s.db.Query(` + SELECT id, cluster_id, provider, summary, why, angles_json, format, created_at + FROM ai_notes ORDER BY cluster_id, created_at`) + if err != nil { + return nil, fmt.Errorf("latest ai_notes: %w", err) + } + defer rows.Close() + out := make(map[int64]AINote) + for rows.Next() { + var n AINote + if err := rows.Scan(&n.ID, &n.ClusterID, &n.Provider, &n.Summary, &n.Why, &n.AnglesJSON, &n.Format, &n.CreatedAt); err != nil { + return nil, fmt.Errorf("latest ai_notes: scan: %w", err) + } + out[n.ClusterID] = n + } + return out, rows.Err() +} diff --git a/PROJECTS/intermediate/security-news-scraper/internal/store/ai_notes_test.go b/PROJECTS/intermediate/security-news-scraper/internal/store/ai_notes_test.go new file mode 100644 index 00000000..50a213d0 --- /dev/null +++ b/PROJECTS/intermediate/security-news-scraper/internal/store/ai_notes_test.go @@ -0,0 +1,127 @@ +// ©AngelaMos | 2026 +// ai_notes_test.go + +package store + +import "testing" + +func insertTestCluster(t *testing.T, s *Store, id int64, key string) { + t.Helper() + _, err := s.DB().Exec( + `INSERT INTO clusters (id, cluster_key, first_seen, last_seen, size) VALUES (?, ?, 0, 0, 1)`, + id, key, + ) + if err != nil { + t.Fatalf("insert cluster: %v", err) + } +} + +func TestAINoteRoundTrip(t *testing.T) { + s := openTemp(t) + insertTestCluster(t, s, 1, "k1") + + note := AINote{ + ClusterID: 1, Provider: "qwen", + Summary: "s", Why: "w", AnglesJSON: `["a","b"]`, Format: "blog", CreatedAt: 100, + } + if err := s.InsertAINote(note); err != nil { + t.Fatalf("InsertAINote: %v", err) + } + + ok, err := s.AINoteExists(1, "qwen") + if err != nil || !ok { + t.Fatalf("AINoteExists(1,qwen) = %v, %v; want true", ok, err) + } + if ok, _ := s.AINoteExists(1, "openai"); ok { + t.Error("AINoteExists(1,openai) = true, want false") + } + if ok, _ := s.AINoteExists(2, "qwen"); ok { + t.Error("AINoteExists(2,qwen) = true, want false") + } + + notes, err := s.AINotesForCluster(1) + if err != nil || len(notes) != 1 { + t.Fatalf("AINotesForCluster = %v, %v; want 1 note", notes, err) + } + if notes[0].Summary != "s" || notes[0].Format != "blog" || notes[0].AnglesJSON != `["a","b"]` { + t.Errorf("note = %+v", notes[0]) + } +} + +func TestAINoteUpsertOverwrites(t *testing.T) { + s := openTemp(t) + insertTestCluster(t, s, 1, "k1") + + if err := s.InsertAINote(AINote{ClusterID: 1, Provider: "qwen", Summary: "first", AnglesJSON: "[]", Format: "blog", CreatedAt: 1}); err != nil { + t.Fatal(err) + } + if err := s.InsertAINote(AINote{ClusterID: 1, Provider: "qwen", Summary: "second", AnglesJSON: "[]", Format: "video", CreatedAt: 2}); err != nil { + t.Fatal(err) + } + notes, err := s.AINotesForCluster(1) + if err != nil { + t.Fatal(err) + } + if len(notes) != 1 { + t.Fatalf("got %d notes, want 1 (upsert should overwrite)", len(notes)) + } + if notes[0].Summary != "second" || notes[0].Format != "video" || notes[0].CreatedAt != 2 { + t.Errorf("upsert did not overwrite: %+v", notes[0]) + } +} + +func TestLatestAINotesNewestPerCluster(t *testing.T) { + s := openTemp(t) + insertTestCluster(t, s, 1, "k1") + insertTestCluster(t, s, 2, "k2") + + must := func(err error) { + t.Helper() + if err != nil { + t.Fatal(err) + } + } + must(s.InsertAINote(AINote{ClusterID: 1, Provider: "qwen", Summary: "old", AnglesJSON: "[]", Format: "blog", CreatedAt: 10})) + must(s.InsertAINote(AINote{ClusterID: 1, Provider: "anthropic", Summary: "new", AnglesJSON: "[]", Format: "video", CreatedAt: 20})) + must(s.InsertAINote(AINote{ClusterID: 2, Provider: "qwen", Summary: "two", AnglesJSON: "[]", Format: "blog", CreatedAt: 5})) + + notes, err := s.LatestAINotes() + if err != nil { + t.Fatal(err) + } + if len(notes) != 2 { + t.Fatalf("got %d clusters, want 2", len(notes)) + } + if notes[1].Summary != "new" { + t.Errorf("cluster 1 latest = %q, want new (highest created_at)", notes[1].Summary) + } + if notes[2].Summary != "two" { + t.Errorf("cluster 2 = %q, want two", notes[2].Summary) + } +} + +func TestAINoteForeignKeyRejectsOrphan(t *testing.T) { + s := openTemp(t) + err := s.InsertAINote(AINote{ClusterID: 99, Provider: "qwen", AnglesJSON: "[]", Format: "blog", CreatedAt: 1}) + if err == nil { + t.Error("insert for a nonexistent cluster should be rejected by the foreign key") + } +} + +func TestAINoteCascadesOnClusterDelete(t *testing.T) { + s := openTemp(t) + insertTestCluster(t, s, 1, "k1") + if err := s.InsertAINote(AINote{ClusterID: 1, Provider: "qwen", AnglesJSON: "[]", Format: "blog", CreatedAt: 1}); err != nil { + t.Fatal(err) + } + if _, err := s.DB().Exec(`DELETE FROM clusters WHERE id = 1`); err != nil { + t.Fatal(err) + } + notes, err := s.AINotesForCluster(1) + if err != nil { + t.Fatal(err) + } + if len(notes) != 0 { + t.Errorf("notes should cascade on cluster delete, %d remain", len(notes)) + } +} diff --git a/PROJECTS/intermediate/security-news-scraper/internal/store/migrations/0003_ai_notes_provider_unique.sql b/PROJECTS/intermediate/security-news-scraper/internal/store/migrations/0003_ai_notes_provider_unique.sql new file mode 100644 index 00000000..900803ba --- /dev/null +++ b/PROJECTS/intermediate/security-news-scraper/internal/store/migrations/0003_ai_notes_provider_unique.sql @@ -0,0 +1,4 @@ +-- ©AngelaMos | 2026 +-- 0003_ai_notes_provider_unique.sql + +CREATE UNIQUE INDEX idx_ai_notes_cluster_provider ON ai_notes(cluster_id, provider); diff --git a/PROJECTS/intermediate/security-news-scraper/internal/tui/browser.go b/PROJECTS/intermediate/security-news-scraper/internal/tui/browser.go index 5de543cf..b54ef90f 100644 --- a/PROJECTS/intermediate/security-news-scraper/internal/tui/browser.go +++ b/PROJECTS/intermediate/security-news-scraper/internal/tui/browser.go @@ -8,6 +8,8 @@ import ( "net/url" "os/exec" "runtime" + + "github.com/CarterPerez-dev/nadezhda/internal/setup" ) func openURL(target string) error { @@ -16,7 +18,9 @@ func openURL(target string) error { return fmt.Errorf("refusing to open non-http url: %q", target) } name, args := openerCommand(target) - return exec.Command(name, args...).Start() + cmd := exec.Command(name, args...) + cmd.Env = setup.NonSecretEnviron() + return cmd.Start() } func openerCommand(target string) (string, []string) { diff --git a/PROJECTS/intermediate/security-news-scraper/internal/tui/detail.go b/PROJECTS/intermediate/security-news-scraper/internal/tui/detail.go index 854bd490..cb659fcd 100644 --- a/PROJECTS/intermediate/security-news-scraper/internal/tui/detail.go +++ b/PROJECTS/intermediate/security-news-scraper/internal/tui/detail.go @@ -48,6 +48,17 @@ func (m Model) renderDetailBody() string { b.WriteString(m.renderCVE(v, w, indent)) } } + + if note, ok := m.notes[c.ClusterID]; ok { + b.WriteString(m.sectionHeader("AI IDEAS", w)) + b.WriteString("\n") + b.WriteString(t.fg(colorMagenta).Bold(true).Render(strings.ToUpper(note.Format)) + "\n\n") + b.WriteString(m.wrapIndent(note.Summary, w, t.Text) + "\n\n") + b.WriteString(m.wrapIndent(note.Why, w, t.Muted) + "\n\n") + for i, a := range note.Angles { + b.WriteString(m.wrapIndent(fmt.Sprintf("%d. %s", i+1, a), w, t.Text) + "\n") + } + } return strings.TrimRight(b.String(), "\n") } diff --git a/PROJECTS/intermediate/security-news-scraper/internal/tui/keys.go b/PROJECTS/intermediate/security-news-scraper/internal/tui/keys.go index 11995599..a6f55fc1 100644 --- a/PROJECTS/intermediate/security-news-scraper/internal/tui/keys.go +++ b/PROJECTS/intermediate/security-news-scraper/internal/tui/keys.go @@ -12,6 +12,7 @@ type keyMap struct { Bottom key.Binding Open key.Binding Browser key.Binding + Ideate key.Binding Back key.Binding Quit key.Binding } @@ -42,6 +43,10 @@ func defaultKeyMap() keyMap { key.WithKeys("o"), key.WithHelp("o", "open"), ), + Ideate: key.NewBinding( + key.WithKeys("i"), + key.WithHelp("i", "ideate"), + ), Back: key.NewBinding( key.WithKeys("esc", "backspace"), key.WithHelp("esc", "back"), diff --git a/PROJECTS/intermediate/security-news-scraper/internal/tui/list.go b/PROJECTS/intermediate/security-news-scraper/internal/tui/list.go index 56474138..ba9dc160 100644 --- a/PROJECTS/intermediate/security-news-scraper/internal/tui/list.go +++ b/PROJECTS/intermediate/security-news-scraper/internal/tui/list.go @@ -91,9 +91,7 @@ func (m Model) listBody(capacity int) string { func (m Model) emptyBody(capacity int) string { t := m.theme msg := t.Muted.Render("no stories in the store yet — run ") + - t.KeyGlyph.Render("nadezhda scrape") + - t.Muted.Render(" then ") + - t.KeyGlyph.Render("nadezhda enrich") + t.KeyGlyph.Render("nadezhda scrape") return lipgloss.Place(m.width, capacity, lipgloss.Center, lipgloss.Center, msg) } diff --git a/PROJECTS/intermediate/security-news-scraper/internal/tui/model.go b/PROJECTS/intermediate/security-news-scraper/internal/tui/model.go index 0f1884ec..e74cdac6 100644 --- a/PROJECTS/intermediate/security-news-scraper/internal/tui/model.go +++ b/PROJECTS/intermediate/security-news-scraper/internal/tui/model.go @@ -11,6 +11,7 @@ import ( "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" + "github.com/CarterPerez-dev/nadezhda/internal/ai" "github.com/CarterPerez-dev/nadezhda/internal/rank" "github.com/CarterPerez-dev/nadezhda/internal/store" ) @@ -39,6 +40,7 @@ var raveSpinner = spinner.Spinner{ type Data struct { Scored []rank.Scored CVEDetail map[string]store.CVE + Notes map[int64]ai.IdeationResult } type Loader func() (Data, error) @@ -52,6 +54,15 @@ type openedMsg struct { err error } +type Ideator func(store.DigestCluster) (ai.IdeationResult, error) + +type ideatedMsg struct { + clusterID int64 + result ai.IdeationResult +} + +type ideateErrMsg struct{ err error } + type Model struct { state viewState loader Loader @@ -73,9 +84,13 @@ type Model struct { opener func(string) error status string statusErr bool + + ideator Ideator + generating bool + notes map[int64]ai.IdeationResult } -func New(loader Loader, now time.Time) Model { +func New(loader Loader, ideator Ideator, now time.Time) Model { th := NewTheme() sp := spinner.New(spinner.WithSpinner(raveSpinner), spinner.WithStyle(th.Spinner)) m := Model{ @@ -90,6 +105,8 @@ func New(loader Loader, now time.Time) Model { height: defaultHeight, cveDetail: map[string]store.CVE{}, opener: openURL, + ideator: ideator, + notes: map[int64]ai.IdeationResult{}, } return m } @@ -117,6 +134,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case dataMsg: m.scored = msg.data.Scored m.cveDetail = msg.data.CVEDetail + if msg.data.Notes != nil { + m.notes = msg.data.Notes + } m.state = stateList return m, nil case errMsg: @@ -130,8 +150,21 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.status, m.statusErr = "opened in browser", false } return m, nil + case ideatedMsg: + m.generating = false + m.notes[msg.clusterID] = msg.result + m.status, m.statusErr = "ideas ready", false + if m.state == stateDetail { + m.viewport.SetContent(m.renderDetailBody()) + m.viewport.GotoBottom() + } + return m, nil + case ideateErrMsg: + m.generating = false + m.status, m.statusErr = "ideate failed: "+msg.err.Error(), true + return m, nil case spinner.TickMsg: - if m.state != stateLoading { + if m.state != stateLoading && !m.generating { return m, nil } var cmd tea.Cmd @@ -156,6 +189,9 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if key.Matches(msg, m.keys.Browser) && (m.state == stateList || m.state == stateDetail) { return m, m.openSelected() } + if key.Matches(msg, m.keys.Ideate) && m.state == stateDetail { + return m.startIdeate() + } switch m.state { case stateList: return m.handleListKey(msg) @@ -177,6 +213,31 @@ func (m Model) openSelected() tea.Cmd { } } +func (m Model) startIdeate() (tea.Model, tea.Cmd) { + if m.ideator == nil { + m.status, m.statusErr = "AI not set up — run: nadezhda ai", true + return m, nil + } + if m.generating || len(m.scored) == 0 { + return m, nil + } + m.generating = true + m.status, m.statusErr = "ideating "+headlineOf(m.selected().Cluster), false + return m, tea.Batch(m.ideateSelected(), m.spinner.Tick) +} + +func (m Model) ideateSelected() tea.Cmd { + ideator := m.ideator + cluster := m.selected().Cluster + return func() tea.Msg { + res, err := ideator(cluster) + if err != nil { + return ideateErrMsg{err} + } + return ideatedMsg{clusterID: cluster.ClusterID, result: res} + } +} + func (m Model) handleListKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch { case key.Matches(msg, m.keys.Up): @@ -244,8 +305,8 @@ func (m Model) selected() rank.Scored { return m.scored[m.cursor] } -func Run(loader Loader) error { - m := New(loader, time.Now()) +func Run(loader Loader, ideator Ideator) error { + m := New(loader, ideator, time.Now()) _, err := tea.NewProgram(m, tea.WithAltScreen()).Run() return err } diff --git a/PROJECTS/intermediate/security-news-scraper/internal/tui/model_test.go b/PROJECTS/intermediate/security-news-scraper/internal/tui/model_test.go index e8c8fd27..204e7a51 100644 --- a/PROJECTS/intermediate/security-news-scraper/internal/tui/model_test.go +++ b/PROJECTS/intermediate/security-news-scraper/internal/tui/model_test.go @@ -13,6 +13,7 @@ import ( "github.com/charmbracelet/bubbles/spinner" tea "github.com/charmbracelet/bubbletea" + "github.com/CarterPerez-dev/nadezhda/internal/ai" "github.com/CarterPerez-dev/nadezhda/internal/rank" "github.com/CarterPerez-dev/nadezhda/internal/store" ) @@ -81,11 +82,11 @@ func step(t *testing.T, m Model, msg tea.Msg) Model { func loadedModel(t *testing.T) Model { t.Helper() - return step(t, New(nil, testNow()), dataMsg{sampleData()}) + return step(t, New(nil, nil, testNow()), dataMsg{sampleData()}) } func TestInitialStateIsLoading(t *testing.T) { - if m := New(nil, testNow()); m.state != stateLoading { + if m := New(nil, nil, testNow()); m.state != stateLoading { t.Fatalf("initial state = %v, want stateLoading", m.state) } } @@ -101,7 +102,7 @@ func TestDataMsgTransitionsToList(t *testing.T) { } func TestErrMsgTransitionsToError(t *testing.T) { - m := step(t, New(nil, testNow()), errMsg{errors.New("wire down")}) + m := step(t, New(nil, nil, testNow()), errMsg{errors.New("wire down")}) if m.state != stateError { t.Fatalf("state = %v, want stateError", m.state) } @@ -188,7 +189,7 @@ func TestSpinnerTickIgnoredOutsideLoading(t *testing.T) { } func TestViewsRenderNonEmpty(t *testing.T) { - loading := New(nil, testNow()) + loading := New(nil, nil, testNow()) if strings.TrimSpace(loading.View()) == "" { t.Error("loading view is empty") } @@ -211,14 +212,14 @@ func TestViewsRenderNonEmpty(t *testing.T) { t.Error("detail view missing severity label") } - errv := step(t, New(nil, testNow()), errMsg{errors.New("boom")}) + errv := step(t, New(nil, nil, testNow()), errMsg{errors.New("boom")}) if strings.TrimSpace(errv.View()) == "" { t.Error("error view is empty") } } func TestEmptyStoreRendersHint(t *testing.T) { - m := step(t, New(nil, testNow()), dataMsg{Data{}}) + m := step(t, New(nil, nil, testNow()), dataMsg{Data{}}) if m.state != stateList { t.Fatalf("state = %v, want stateList", m.state) } @@ -320,3 +321,85 @@ func TestOpenURLRejectsNonHTTP(t *testing.T) { } } } + +func TestIdeateDisabledShowsHint(t *testing.T) { + m := loadedModel(t) + m = step(t, m, tea.KeyMsg{Type: tea.KeyEnter}) + m = step(t, m, runeKey('i')) + if !m.statusErr || !strings.Contains(m.status, "nadezhda ai") { + t.Errorf("i with nil ideator: status=%q err=%v, want a setup hint", m.status, m.statusErr) + } + if m.generating { + t.Error("generating should stay false when ideator is nil") + } +} + +func TestIdeateIgnoredInListView(t *testing.T) { + m := loadedModel(t) + m.ideator = func(c store.DigestCluster) (ai.IdeationResult, error) { + return ai.IdeationResult{Summary: "s", Angles: []string{"a"}, Format: "blog"}, nil + } + tm, cmd := m.Update(runeKey('i')) + m = toModel(t, tm) + if m.generating || cmd != nil { + t.Errorf("i in the list must be a no-op: generating=%v cmd=%v", m.generating, cmd) + } +} + +func TestIdeateFlowStoresAndRenders(t *testing.T) { + m := loadedModel(t) + m.ideator = func(c store.DigestCluster) (ai.IdeationResult, error) { + return ai.IdeationResult{Summary: "s", Why: "w", Angles: []string{"angle-one", "angle-two"}, Format: "video"}, nil + } + m = step(t, m, tea.KeyMsg{Type: tea.KeyEnter}) + + tm, cmd := m.Update(runeKey('i')) + m = toModel(t, tm) + if !m.generating || cmd == nil { + t.Fatalf("after i: generating=%v cmd=%v", m.generating, cmd) + } + + msg := m.ideateSelected()() + im, ok := msg.(ideatedMsg) + if !ok { + t.Fatalf("ideateSelected produced %T, want ideatedMsg", msg) + } + if im.clusterID != 1 || im.result.Summary != "s" { + t.Fatalf("ideatedMsg = %+v", im) + } + + m = step(t, m, im) + if m.generating { + t.Error("generating still true after ideatedMsg") + } + if m.notes[1].Summary != "s" || len(m.notes[1].Angles) != 2 { + t.Errorf("note not stored: %+v", m.notes[1]) + } + + m = step(t, m, tea.KeyMsg{Type: tea.KeyEnter}) + dv := m.View() + if !strings.Contains(dv, "AI IDEAS") || !strings.Contains(dv, "angle-one") { + t.Error("detail view missing the ideation section") + } +} + +func TestIdeateErrorSetsStatus(t *testing.T) { + m := loadedModel(t) + m.ideator = func(c store.DigestCluster) (ai.IdeationResult, error) { + return ai.IdeationResult{}, errors.New("boom") + } + m = step(t, m, tea.KeyMsg{Type: tea.KeyEnter}) + m = step(t, m, runeKey('i')) + msg := m.ideateSelected()() + em, ok := msg.(ideateErrMsg) + if !ok { + t.Fatalf("ideateSelected produced %T, want ideateErrMsg", msg) + } + m = step(t, m, em) + if m.generating { + t.Error("generating still true after ideateErrMsg") + } + if !m.statusErr { + t.Error("statusErr not set after ideate failure") + } +} diff --git a/PROJECTS/intermediate/security-news-scraper/internal/tui/view.go b/PROJECTS/intermediate/security-news-scraper/internal/tui/view.go index 15a3278f..c418faf5 100644 --- a/PROJECTS/intermediate/security-news-scraper/internal/tui/view.go +++ b/PROJECTS/intermediate/security-news-scraper/internal/tui/view.go @@ -135,7 +135,7 @@ func (m Model) detailView() string { t.Muted.Render(fmt.Sprintf("story %d of %d", m.cursor+1, len(m.scored))) head := m.spread(left, t.Meta.Render(scroll)) foot := m.spread( - m.keyHints(m.keys.Back, m.keys.Browser, m.keys.Down, m.keys.Up, m.keys.Quit), + m.keyHints(m.keys.Back, m.keys.Browser, m.keys.Ideate, m.keys.Down, m.keys.Up, m.keys.Quit), m.statusText(), ) return lipgloss.JoinVertical(lipgloss.Left, @@ -148,6 +148,9 @@ func (m Model) detailView() string { } func (m Model) statusText() string { + if m.generating { + return m.spinner.View() + " " + m.theme.fg(colorCyan).Render(m.status) + } if m.status == "" { return "" }