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.
This commit is contained in:
CarterPerez-dev 2026-07-06 21:26:52 -04:00
parent 0a86bb8593
commit d57bcc15b6
24 changed files with 1598 additions and 18 deletions

View File

@ -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)"
}

View File

@ -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)
}
}

View File

@ -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 {

View File

@ -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)
}

View File

@ -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())
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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)
}

View File

@ -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
}
}

View File

@ -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)
}
}
}

View File

@ -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)
)

View File

@ -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()
}

View File

@ -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))
}
}

View File

@ -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);

View File

@ -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) {

View File

@ -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")
}

View File

@ -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"),

View File

@ -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)
}

View File

@ -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
}

View File

@ -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")
}
}

View File

@ -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 ""
}