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:
parent
0a86bb8593
commit
d57bcc15b6
|
|
@ -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)"
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -21,7 +21,6 @@ func init() {
|
||||||
short string
|
short string
|
||||||
milestone 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"},
|
{"watch", "Run as a daemon, re-ingesting on an interval", "milestone M7"},
|
||||||
}
|
}
|
||||||
for _, s := range stubs {
|
for _, s := range stubs {
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,13 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/CarterPerez-dev/nadezhda/internal/ai"
|
||||||
"github.com/CarterPerez-dev/nadezhda/internal/rank"
|
"github.com/CarterPerez-dev/nadezhda/internal/rank"
|
||||||
"github.com/CarterPerez-dev/nadezhda/internal/store"
|
"github.com/CarterPerez-dev/nadezhda/internal/store"
|
||||||
"github.com/CarterPerez-dev/nadezhda/internal/tui"
|
"github.com/CarterPerez-dev/nadezhda/internal/tui"
|
||||||
|
|
@ -68,8 +70,48 @@ func runTUI(cmd *cobra.Command, args []string) error {
|
||||||
detail[v.ID] = full
|
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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
)
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -8,6 +8,8 @@ import (
|
||||||
"net/url"
|
"net/url"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
|
||||||
|
"github.com/CarterPerez-dev/nadezhda/internal/setup"
|
||||||
)
|
)
|
||||||
|
|
||||||
func openURL(target string) error {
|
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)
|
return fmt.Errorf("refusing to open non-http url: %q", target)
|
||||||
}
|
}
|
||||||
name, args := openerCommand(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) {
|
func openerCommand(target string) (string, []string) {
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,17 @@ func (m Model) renderDetailBody() string {
|
||||||
b.WriteString(m.renderCVE(v, w, indent))
|
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")
|
return strings.TrimRight(b.String(), "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ type keyMap struct {
|
||||||
Bottom key.Binding
|
Bottom key.Binding
|
||||||
Open key.Binding
|
Open key.Binding
|
||||||
Browser key.Binding
|
Browser key.Binding
|
||||||
|
Ideate key.Binding
|
||||||
Back key.Binding
|
Back key.Binding
|
||||||
Quit key.Binding
|
Quit key.Binding
|
||||||
}
|
}
|
||||||
|
|
@ -42,6 +43,10 @@ func defaultKeyMap() keyMap {
|
||||||
key.WithKeys("o"),
|
key.WithKeys("o"),
|
||||||
key.WithHelp("o", "open"),
|
key.WithHelp("o", "open"),
|
||||||
),
|
),
|
||||||
|
Ideate: key.NewBinding(
|
||||||
|
key.WithKeys("i"),
|
||||||
|
key.WithHelp("i", "ideate"),
|
||||||
|
),
|
||||||
Back: key.NewBinding(
|
Back: key.NewBinding(
|
||||||
key.WithKeys("esc", "backspace"),
|
key.WithKeys("esc", "backspace"),
|
||||||
key.WithHelp("esc", "back"),
|
key.WithHelp("esc", "back"),
|
||||||
|
|
|
||||||
|
|
@ -91,9 +91,7 @@ func (m Model) listBody(capacity int) string {
|
||||||
func (m Model) emptyBody(capacity int) string {
|
func (m Model) emptyBody(capacity int) string {
|
||||||
t := m.theme
|
t := m.theme
|
||||||
msg := t.Muted.Render("no stories in the store yet — run ") +
|
msg := t.Muted.Render("no stories in the store yet — run ") +
|
||||||
t.KeyGlyph.Render("nadezhda scrape") +
|
t.KeyGlyph.Render("nadezhda scrape")
|
||||||
t.Muted.Render(" then ") +
|
|
||||||
t.KeyGlyph.Render("nadezhda enrich")
|
|
||||||
return lipgloss.Place(m.width, capacity, lipgloss.Center, lipgloss.Center, msg)
|
return lipgloss.Place(m.width, capacity, lipgloss.Center, lipgloss.Center, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import (
|
||||||
"github.com/charmbracelet/bubbles/viewport"
|
"github.com/charmbracelet/bubbles/viewport"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
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/rank"
|
||||||
"github.com/CarterPerez-dev/nadezhda/internal/store"
|
"github.com/CarterPerez-dev/nadezhda/internal/store"
|
||||||
)
|
)
|
||||||
|
|
@ -39,6 +40,7 @@ var raveSpinner = spinner.Spinner{
|
||||||
type Data struct {
|
type Data struct {
|
||||||
Scored []rank.Scored
|
Scored []rank.Scored
|
||||||
CVEDetail map[string]store.CVE
|
CVEDetail map[string]store.CVE
|
||||||
|
Notes map[int64]ai.IdeationResult
|
||||||
}
|
}
|
||||||
|
|
||||||
type Loader func() (Data, error)
|
type Loader func() (Data, error)
|
||||||
|
|
@ -52,6 +54,15 @@ type openedMsg struct {
|
||||||
err error
|
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 {
|
type Model struct {
|
||||||
state viewState
|
state viewState
|
||||||
loader Loader
|
loader Loader
|
||||||
|
|
@ -73,9 +84,13 @@ type Model struct {
|
||||||
opener func(string) error
|
opener func(string) error
|
||||||
status string
|
status string
|
||||||
statusErr bool
|
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()
|
th := NewTheme()
|
||||||
sp := spinner.New(spinner.WithSpinner(raveSpinner), spinner.WithStyle(th.Spinner))
|
sp := spinner.New(spinner.WithSpinner(raveSpinner), spinner.WithStyle(th.Spinner))
|
||||||
m := Model{
|
m := Model{
|
||||||
|
|
@ -90,6 +105,8 @@ func New(loader Loader, now time.Time) Model {
|
||||||
height: defaultHeight,
|
height: defaultHeight,
|
||||||
cveDetail: map[string]store.CVE{},
|
cveDetail: map[string]store.CVE{},
|
||||||
opener: openURL,
|
opener: openURL,
|
||||||
|
ideator: ideator,
|
||||||
|
notes: map[int64]ai.IdeationResult{},
|
||||||
}
|
}
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
@ -117,6 +134,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
case dataMsg:
|
case dataMsg:
|
||||||
m.scored = msg.data.Scored
|
m.scored = msg.data.Scored
|
||||||
m.cveDetail = msg.data.CVEDetail
|
m.cveDetail = msg.data.CVEDetail
|
||||||
|
if msg.data.Notes != nil {
|
||||||
|
m.notes = msg.data.Notes
|
||||||
|
}
|
||||||
m.state = stateList
|
m.state = stateList
|
||||||
return m, nil
|
return m, nil
|
||||||
case errMsg:
|
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
|
m.status, m.statusErr = "opened in browser", false
|
||||||
}
|
}
|
||||||
return m, nil
|
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:
|
case spinner.TickMsg:
|
||||||
if m.state != stateLoading {
|
if m.state != stateLoading && !m.generating {
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
var cmd tea.Cmd
|
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) {
|
if key.Matches(msg, m.keys.Browser) && (m.state == stateList || m.state == stateDetail) {
|
||||||
return m, m.openSelected()
|
return m, m.openSelected()
|
||||||
}
|
}
|
||||||
|
if key.Matches(msg, m.keys.Ideate) && m.state == stateDetail {
|
||||||
|
return m.startIdeate()
|
||||||
|
}
|
||||||
switch m.state {
|
switch m.state {
|
||||||
case stateList:
|
case stateList:
|
||||||
return m.handleListKey(msg)
|
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) {
|
func (m Model) handleListKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
switch {
|
switch {
|
||||||
case key.Matches(msg, m.keys.Up):
|
case key.Matches(msg, m.keys.Up):
|
||||||
|
|
@ -244,8 +305,8 @@ func (m Model) selected() rank.Scored {
|
||||||
return m.scored[m.cursor]
|
return m.scored[m.cursor]
|
||||||
}
|
}
|
||||||
|
|
||||||
func Run(loader Loader) error {
|
func Run(loader Loader, ideator Ideator) error {
|
||||||
m := New(loader, time.Now())
|
m := New(loader, ideator, time.Now())
|
||||||
_, err := tea.NewProgram(m, tea.WithAltScreen()).Run()
|
_, err := tea.NewProgram(m, tea.WithAltScreen()).Run()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import (
|
||||||
"github.com/charmbracelet/bubbles/spinner"
|
"github.com/charmbracelet/bubbles/spinner"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
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/rank"
|
||||||
"github.com/CarterPerez-dev/nadezhda/internal/store"
|
"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 {
|
func loadedModel(t *testing.T) Model {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
return step(t, New(nil, testNow()), dataMsg{sampleData()})
|
return step(t, New(nil, nil, testNow()), dataMsg{sampleData()})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestInitialStateIsLoading(t *testing.T) {
|
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)
|
t.Fatalf("initial state = %v, want stateLoading", m.state)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -101,7 +102,7 @@ func TestDataMsgTransitionsToList(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestErrMsgTransitionsToError(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 {
|
if m.state != stateError {
|
||||||
t.Fatalf("state = %v, want stateError", m.state)
|
t.Fatalf("state = %v, want stateError", m.state)
|
||||||
}
|
}
|
||||||
|
|
@ -188,7 +189,7 @@ func TestSpinnerTickIgnoredOutsideLoading(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestViewsRenderNonEmpty(t *testing.T) {
|
func TestViewsRenderNonEmpty(t *testing.T) {
|
||||||
loading := New(nil, testNow())
|
loading := New(nil, nil, testNow())
|
||||||
if strings.TrimSpace(loading.View()) == "" {
|
if strings.TrimSpace(loading.View()) == "" {
|
||||||
t.Error("loading view is empty")
|
t.Error("loading view is empty")
|
||||||
}
|
}
|
||||||
|
|
@ -211,14 +212,14 @@ func TestViewsRenderNonEmpty(t *testing.T) {
|
||||||
t.Error("detail view missing severity label")
|
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()) == "" {
|
if strings.TrimSpace(errv.View()) == "" {
|
||||||
t.Error("error view is empty")
|
t.Error("error view is empty")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEmptyStoreRendersHint(t *testing.T) {
|
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 {
|
if m.state != stateList {
|
||||||
t.Fatalf("state = %v, want stateList", m.state)
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -135,7 +135,7 @@ func (m Model) detailView() string {
|
||||||
t.Muted.Render(fmt.Sprintf("story %d of %d", m.cursor+1, len(m.scored)))
|
t.Muted.Render(fmt.Sprintf("story %d of %d", m.cursor+1, len(m.scored)))
|
||||||
head := m.spread(left, t.Meta.Render(scroll))
|
head := m.spread(left, t.Meta.Render(scroll))
|
||||||
foot := m.spread(
|
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(),
|
m.statusText(),
|
||||||
)
|
)
|
||||||
return lipgloss.JoinVertical(lipgloss.Left,
|
return lipgloss.JoinVertical(lipgloss.Left,
|
||||||
|
|
@ -148,6 +148,9 @@ func (m Model) detailView() string {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) statusText() string {
|
func (m Model) statusText() string {
|
||||||
|
if m.generating {
|
||||||
|
return m.spinner.View() + " " + m.theme.fg(colorCyan).Render(m.status)
|
||||||
|
}
|
||||||
if m.status == "" {
|
if m.status == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue