Skip to content

Commit a66b16a

Browse files
committed
update krknai prompt and markdown-to-HTML conversion
1 parent ed82e06 commit a66b16a

File tree

9 files changed

+329
-94
lines changed

9 files changed

+329
-94
lines changed

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ require (
110110
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
111111
github.com/golang/glog v1.2.4 // indirect
112112
github.com/golang/protobuf v1.5.4 // indirect
113+
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab // indirect
113114
github.com/google/gnostic-models v0.6.9 // indirect
114115
github.com/google/go-cmp v0.7.0 // indirect
115116
github.com/google/go-querystring v1.1.0 // indirect

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,8 @@ github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUv
151151
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
152152
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
153153
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
154+
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab h1:VYNivV7P8IRHUam2swVUNkhIdp0LRRFKe4hXNnoZKTc=
155+
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
154156
github.com/google/gnostic-models v0.6.9 h1:MU/8wDLif2qCXZmzncUQ/BOfxWfthHi63KqpoNbWqVw=
155157
github.com/google/gnostic-models v0.6.9/go.mod h1:CiWsm0s6BSQd1hRn8/QmxqB6BesYcbSZxsz9b0KuDBw=
156158
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=

internal/analysisengine/engine.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ type ClusterInfo struct {
2929
Region string
3030
CloudProvider string
3131
Version string
32+
Type string // e.g. "rosa", "osd", "aro"
33+
Hypershift bool
3234
}
3335

3436
// Config holds configuration for the analysis engine.

internal/prompts/prompts.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,12 @@ func (ps *PromptStore) loadTemplates(filesystem fs.FS) error {
7171
})
7272
}
7373

74+
// RegisterTemplates loads additional templates from the given filesystem,
75+
// overwriting any existing templates with the same ID.
76+
func (ps *PromptStore) RegisterTemplates(templatesFS fs.FS) error {
77+
return ps.loadTemplates(templatesFS)
78+
}
79+
7480
func (ps *PromptStore) GetTemplate(id string) (*PromptTemplate, error) {
7581
template, exists := ps.templates[id]
7682
if !exists {

internal/prompts/prompts_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ func TestNewPromptStore(t *testing.T) {
1212
require.NoError(t, err)
1313
require.NotNil(t, store)
1414
assert.Greater(t, len(store.templates), 0, "Should have loaded some templates")
15+
16+
_, err = store.GetTemplate("default")
17+
assert.NoError(t, err, "default template should be loaded")
1518
}
1619

1720
func TestGetTemplate(t *testing.T) {

pkg/krknai/analysisengine/engine.go

Lines changed: 43 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ import (
99
"path/filepath"
1010
"time"
1111

12+
"github.com/gomarkdown/markdown"
13+
mdhtml "github.com/gomarkdown/markdown/html"
14+
"github.com/gomarkdown/markdown/parser"
1215
"github.com/openshift/osde2e/internal/analysisengine"
1316
"github.com/openshift/osde2e/internal/llm"
1417
"github.com/openshift/osde2e/internal/llm/tools"
@@ -18,21 +21,23 @@ import (
1821
"gopkg.in/yaml.v3"
1922
)
2023

21-
//go:embed prompts/krknai.yaml
22-
var krknaiTemplatesFS embed.FS
24+
//go:embed prompts/*
25+
var krknPrompts embed.FS
2326

2427
const (
2528
analysisDirName = "llm-analysis"
2629
summaryFileName = "summary.yaml"
2730

28-
// krknAIPromptTemplate is the prompt template ID for krkn-ai analysis.
2931
krknAIPromptTemplate = "krknai"
32+
htmlTemplatePath = "prompts/report.html"
3033
)
3134

3235
// Config holds configuration for the krkn-ai analysis engine.
3336
type Config struct {
3437
analysisengine.BaseConfig
35-
TopScenariosCount int // Number of top scenarios to include (default: 10)
38+
TopScenariosCount int // Number of top scenarios to include (default: 10)
39+
ReportFormat string // "json" (default), "markdown", or "html"
40+
ClusterInfo *analysisengine.ClusterInfo // Cluster metadata from CLI flags
3641
}
3742

3843
// Engine analyzes krkn-ai chaos test results using LLM.
@@ -60,14 +65,17 @@ func New(ctx context.Context, config *Config) (*Engine, error) {
6065
agg.WithTopScenariosCount(config.TopScenariosCount)
6166
}
6267

63-
templatesFS, err := fs.Sub(krknaiTemplatesFS, "prompts")
68+
promptStore, err := prompts.NewPromptStore(prompts.DefaultTemplates())
6469
if err != nil {
65-
return nil, fmt.Errorf("failed to access embedded prompts: %w", err)
70+
return nil, fmt.Errorf("failed to initialize prompt store: %w", err)
6671
}
6772

68-
promptStore, err := prompts.NewPromptStore(templatesFS)
73+
localFS, err := fs.Sub(krknPrompts, "prompts")
6974
if err != nil {
70-
return nil, fmt.Errorf("failed to initialize prompt store: %w", err)
75+
return nil, fmt.Errorf("failed to load krkn-ai prompt templates: %w", err)
76+
}
77+
if err := promptStore.RegisterTemplates(localFS); err != nil {
78+
return nil, fmt.Errorf("failed to register krkn-ai prompt templates: %w", err)
7179
}
7280

7381
client, err := llm.NewGeminiClient(ctx, config.APIKey)
@@ -108,6 +116,9 @@ func (e *Engine) Run(ctx context.Context) (*analysisengine.Result, error) {
108116
"LogArtifacts": data.LogArtifacts,
109117
"ConfigSummary": data.ConfigSummary,
110118
}
119+
if e.config.ClusterInfo != nil {
120+
vars["ClusterInfo"] = e.config.ClusterInfo
121+
}
111122

112123
// Render prompt using prompt store
113124
userPrompt, llmConfig, err := e.promptStore.RenderPrompt(krknAIPromptTemplate, vars)
@@ -134,10 +145,19 @@ func (e *Engine) Run(ctx context.Context) (*analysisengine.Result, error) {
134145
return nil, fmt.Errorf("LLM analysis failed: %w", err)
135146
}
136147

148+
content := result.Content
149+
if e.config.ReportFormat == "html" {
150+
var err error
151+
content, err = markdownToHTML(content)
152+
if err != nil {
153+
return nil, fmt.Errorf("failed to convert markdown to HTML: %w", err)
154+
}
155+
}
156+
137157
// Build analysis result
138158
analysisResult := &analysisengine.Result{
139159
Status: "completed",
140-
Content: result.Content,
160+
Content: content,
141161
Prompt: userPrompt,
142162
Metadata: map[string]any{
143163
"analysis_type": "krknai",
@@ -159,7 +179,7 @@ func (e *Engine) Run(ctx context.Context) (*analysisengine.Result, error) {
159179
}
160180

161181
// Write summary to results directory
162-
if err := e.writeSummary(analysisResult, data); err != nil {
182+
if err := e.writeSummary(analysisResult, data, e.config.ClusterInfo); err != nil {
163183
return nil, fmt.Errorf("failed to write analysis summary: %w", err)
164184
}
165185

@@ -172,7 +192,7 @@ func (e *Engine) Run(ctx context.Context) (*analysisengine.Result, error) {
172192
}
173193

174194
// writeSummary writes the analysis result to a YAML summary file.
175-
func (e *Engine) writeSummary(result *analysisengine.Result, data *krknAggregator.KrknAIData) error {
195+
func (e *Engine) writeSummary(result *analysisengine.Result, data *krknAggregator.KrknAIData, clusterInfo *analysisengine.ClusterInfo) error {
176196
analysisDir := filepath.Join(e.config.ArtifactsDir, analysisDirName)
177197
if err := os.MkdirAll(analysisDir, 0o755); err != nil {
178198
return fmt.Errorf("failed to create analysis directory: %w", err)
@@ -181,6 +201,7 @@ func (e *Engine) writeSummary(result *analysisengine.Result, data *krknAggregato
181201
summary := map[string]any{
182202
"timestamp": time.Now().Format(time.RFC3339),
183203
"analysis_type": "krknai",
204+
"cluster_info": clusterInfo,
184205
"run_summary": map[string]any{
185206
"total_scenarios": data.Summary.TotalScenarioCount,
186207
"successful_scenarios": data.Summary.SuccessfulScenarioCount,
@@ -212,6 +233,17 @@ func (e *Engine) writeSummary(result *analysisengine.Result, data *krknAggregato
212233
return nil
213234
}
214235

236+
func markdownToHTML(content string) (string, error) {
237+
htmlTemplate, err := krknPrompts.ReadFile(htmlTemplatePath)
238+
if err != nil {
239+
return "", fmt.Errorf("failed to read HTML template: %w", err)
240+
}
241+
p := parser.NewWithExtensions(parser.CommonExtensions | parser.AutoHeadingIDs)
242+
renderer := mdhtml.NewRenderer(mdhtml.RendererOptions{Flags: mdhtml.CommonFlags | mdhtml.HrefTargetBlank})
243+
body := markdown.ToHTML([]byte(content), p, renderer)
244+
return fmt.Sprintf(string(htmlTemplate), string(body)), nil
245+
}
246+
215247
// sendNotifications sends analysis results to configured reporters.
216248
func (e *Engine) sendNotifications(ctx context.Context, result *analysisengine.Result) {
217249
reporterResult := &reporter.AnalysisResult{

pkg/krknai/analysisengine/engine_test.go

Lines changed: 177 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -50,13 +50,177 @@ func TestNew_ValidConfig(t *testing.T) {
5050
assert.Contains(t, err.Error(), "results directory is required")
5151
}
5252

53-
func TestEmbeddedPromptTemplate(t *testing.T) {
54-
// Verify the embedded prompt template loads correctly
55-
data, err := krknaiTemplatesFS.ReadFile("prompts/krknai.yaml")
53+
func TestPromptTemplatesAvailable(t *testing.T) {
54+
store := newTestPromptStore(t)
55+
56+
tmpl, err := store.GetTemplate("krknai")
57+
require.NoError(t, err)
58+
assert.Contains(t, tmpl.SystemPrompt, "chaos engineering")
59+
assert.Contains(t, tmpl.UserPrompt, "Summary")
60+
61+
assert.Contains(t, tmpl.SystemPrompt, "markdown")
62+
assert.Contains(t, tmpl.SystemPrompt, "genetic algorithm")
63+
}
64+
65+
func TestRenderKrknAIPrompt(t *testing.T) {
66+
store := newTestPromptStore(t)
67+
68+
variables := map[string]any{
69+
"ClusterInfo": &analysisengine.ClusterInfo{
70+
ID: "abc-123",
71+
Name: "test-rosa-cluster",
72+
Version: "4.17.3",
73+
Type: "rosa-hcp",
74+
Provider: "aws",
75+
Region: "us-east-1",
76+
Hypershift: true,
77+
},
78+
"Summary": map[string]any{
79+
"TotalScenarioCount": 30,
80+
"SuccessfulScenarioCount": 27,
81+
"FailedScenarioCount": 3,
82+
"Generations": 3,
83+
"MaxFitnessScore": 8.75,
84+
"AvgFitnessScore": 4.32,
85+
"ScenarioTypes": []string{"node-cpu-hog", "node-memory-hog", "pod-scenarios"},
86+
},
87+
"TopScenarios": []map[string]any{
88+
{
89+
"Scenario": "node-cpu-hog",
90+
"GenerationID": 2,
91+
"ScenarioID": 15,
92+
"FitnessScore": 8.75,
93+
"HealthCheckResponseTimeScore": 6.50,
94+
"HealthCheckFailureScore": 2.25,
95+
"KrknFailureScore": 0.0,
96+
"Parameters": "node_selector: node-role.kubernetes.io/worker",
97+
},
98+
},
99+
"FailedScenarios": []map[string]any{
100+
{
101+
"Scenario": "dns-outage",
102+
"GenerationID": 1,
103+
"ScenarioID": 7,
104+
"KrknFailureScore": -1.0,
105+
"Parameters": "namespace: openshift-dns",
106+
},
107+
},
108+
"HealthCheckReport": []map[string]any{
109+
{
110+
"ScenarioID": 15,
111+
"ComponentName": "console",
112+
"MinResponseTime": 12.5,
113+
"MaxResponseTime": 850.3,
114+
"AverageResponseTime": 245.7,
115+
"SuccessCount": 48,
116+
"FailureCount": 2,
117+
},
118+
},
119+
"LogArtifacts": []map[string]any{
120+
{"Source": "/results/reports/all.csv", "LineCount": 31},
121+
{"Source": "/results/krkn-ai.yaml", "LineCount": 85},
122+
},
123+
"ConfigSummary": "generations: 3\npopulation_size: 10\n",
124+
}
125+
126+
userPrompt, config, err := store.RenderPrompt("krknai", variables)
127+
require.NoError(t, err)
128+
require.NotNil(t, config)
129+
130+
assert.Contains(t, userPrompt, "id=abc-123")
131+
assert.Contains(t, userPrompt, "name=test-rosa-cluster")
132+
assert.Contains(t, userPrompt, "version=4.17.3")
133+
assert.Contains(t, userPrompt, "type=rosa-hcp")
134+
assert.Contains(t, userPrompt, "hypershift=true")
135+
136+
assert.Contains(t, userPrompt, "30 scenarios")
137+
assert.Contains(t, userPrompt, "27 ok")
138+
assert.Contains(t, userPrompt, "3 failed")
139+
assert.Contains(t, userPrompt, "max=8.75")
140+
assert.Contains(t, userPrompt, "fitness=8.75")
141+
assert.Contains(t, userPrompt, "node_selector: node-role.kubernetes.io/worker")
142+
assert.Contains(t, userPrompt, "dns-outage")
143+
assert.Contains(t, userPrompt, "console")
144+
assert.Contains(t, userPrompt, "avg=245.70ms")
145+
assert.Contains(t, userPrompt, "/results/reports/all.csv (31L)")
146+
assert.Contains(t, userPrompt, "generations: 3")
147+
148+
assert.NotNil(t, config.SystemInstruction)
149+
assert.Contains(t, *config.SystemInstruction, "chaos engineering analyst")
150+
assert.Contains(t, *config.SystemInstruction, "genetic algorithm")
151+
}
152+
153+
func TestRun_MarkdownReportFormat(t *testing.T) {
154+
tempDir := t.TempDir()
155+
reportsDir := filepath.Join(tempDir, "reports")
156+
require.NoError(t, os.MkdirAll(reportsDir, 0o755))
157+
158+
createTestResultFiles(t, tempDir, reportsDir)
159+
160+
ctx := context.Background()
161+
agg := krknAggregator.NewKrknAIAggregator(ctx)
162+
promptStore := newTestPromptStore(t)
163+
164+
mockClient := &mockLLMClient{
165+
response: &llm.AnalysisResult{
166+
Content: "# Krkn-AI Chaos Test Report\n\n## Executive Summary\nCluster shows moderate resilience.",
167+
},
168+
}
169+
170+
engine := &Engine{
171+
config: &Config{
172+
BaseConfig: analysisengine.BaseConfig{ArtifactsDir: tempDir, APIKey: "fake-key"},
173+
ReportFormat: "markdown",
174+
},
175+
aggregator: agg,
176+
promptStore: promptStore,
177+
llmClient: mockClient,
178+
reporterRegistry: newTestReporterRegistry(),
179+
}
180+
181+
result, err := engine.Run(ctx)
56182
require.NoError(t, err)
57-
assert.Contains(t, string(data), "system_prompt")
58-
assert.Contains(t, string(data), "user_prompt")
59-
assert.Contains(t, string(data), "chaos engineering")
183+
require.NotNil(t, result)
184+
assert.Contains(t, result.Content, "Chaos Test Report")
185+
}
186+
187+
func TestRun_HTMLReportFormat(t *testing.T) {
188+
tempDir := t.TempDir()
189+
reportsDir := filepath.Join(tempDir, "reports")
190+
require.NoError(t, os.MkdirAll(reportsDir, 0o755))
191+
192+
createTestResultFiles(t, tempDir, reportsDir)
193+
194+
ctx := context.Background()
195+
agg := krknAggregator.NewKrknAIAggregator(ctx)
196+
promptStore := newTestPromptStore(t)
197+
198+
mockClient := &mockLLMClient{
199+
response: &llm.AnalysisResult{
200+
Content: "# Krkn-AI Chaos Test Report\n\n## Executive Summary\nCluster shows **moderate** resilience.\n\n| Metric | Value |\n|--------|-------|\n| Total | 5 |\n",
201+
},
202+
}
203+
204+
engine := &Engine{
205+
config: &Config{
206+
BaseConfig: analysisengine.BaseConfig{ArtifactsDir: tempDir, APIKey: "fake-key"},
207+
ReportFormat: "html",
208+
},
209+
aggregator: agg,
210+
promptStore: promptStore,
211+
llmClient: mockClient,
212+
reporterRegistry: newTestReporterRegistry(),
213+
}
214+
215+
result, err := engine.Run(ctx)
216+
require.NoError(t, err)
217+
require.NotNil(t, result)
218+
219+
assert.Contains(t, result.Content, "<!DOCTYPE html>")
220+
assert.Contains(t, result.Content, "<h1")
221+
assert.Contains(t, result.Content, "<table>")
222+
assert.Contains(t, result.Content, "<strong>moderate</strong>")
223+
assert.NotContains(t, result.Content, "## Executive Summary")
60224
}
61225

62226
func TestWriteSummary(t *testing.T) {
@@ -96,7 +260,7 @@ func TestWriteSummary(t *testing.T) {
96260
},
97261
}
98262

99-
err := engine.writeSummary(result, data)
263+
err := engine.writeSummary(result, data, nil)
100264
require.NoError(t, err)
101265

102266
// Verify summary file exists
@@ -224,13 +388,16 @@ func TestRun_MissingResults(t *testing.T) {
224388
assert.Contains(t, err.Error(), "failed to collect krkn-ai results")
225389
}
226390

227-
// newTestPromptStore creates a prompt store using the embedded krkn-ai templates.
391+
// newTestPromptStore creates a prompt store using the central prompt templates.
228392
func newTestPromptStore(t *testing.T) *prompts.PromptStore {
229393
t.Helper()
230-
templatesFS, err := fs.Sub(krknaiTemplatesFS, "prompts")
394+
store, err := prompts.NewPromptStore(prompts.DefaultTemplates())
231395
require.NoError(t, err)
232-
store, err := prompts.NewPromptStore(templatesFS)
396+
397+
localFS, err := fs.Sub(krknPrompts, "prompts")
233398
require.NoError(t, err)
399+
require.NoError(t, store.RegisterTemplates(localFS))
400+
234401
return store
235402
}
236403

0 commit comments

Comments
 (0)