Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 68 additions & 3 deletions src/analyzer/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@ func (e *ChatEngine) handleChat(w http.ResponseWriter, r *http.Request) {
Message string `json:"message"`
ConversationID string `json:"conversationId"`
Namespace string `json:"namespace"`
IncidentID *int64 `json:"incidentId,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
Expand Down Expand Up @@ -211,7 +212,7 @@ func (e *ChatEngine) handleChat(w http.ResponseWriter, r *http.Request) {
emit(map[string]any{"type": "conversation", "conversationId": convID})

// 1. PLAN
plan := e.plan(ctx, req.Message, history, req.Namespace)
plan := e.plan(ctx, req.Message, history, e.chatNamespace(req))
for _, t := range plan.Tools {
emit(map[string]any{"type": "tool", "name": t.Name, "args": t.Args})
}
Expand All @@ -223,7 +224,7 @@ func (e *ChatEngine) handleChat(w http.ResponseWriter, r *http.Request) {
}

// 3. SYNTHESIZE
messages := e.buildMessages(req.Message, history, grounding, req.Namespace)
messages := e.buildMessages(req.Message, history, grounding, plan.Namespace, e.incidentContext(req.IncidentID))
var answer strings.Builder
err := e.streamAnswer(ctx, messages, func(tok string) {
answer.WriteString(tok)
Expand Down Expand Up @@ -339,6 +340,7 @@ func (e *ChatEngine) plan(ctx context.Context, message string, history []chatMsg
if len(p.Tools) == 0 && len(fallback.Tools) > 0 {
p.Tools = fallback.Tools
}
p.Namespace = namespace
return p
}

Expand Down Expand Up @@ -440,11 +442,74 @@ func (e *ChatEngine) retrieve(ctx context.Context, plan chatPlan) (string, []Cit

// --- synthesis --------------------------------------------------------------

func (e *ChatEngine) buildMessages(message string, history []chatMsg, grounding, namespace string) []types.LLMMessage {
// chatNamespace resolves the namespace context for a chat request: an explicit
// request namespace wins, otherwise the incident's namespace (first signal)
// when one is referenced.
func (e *ChatEngine) chatNamespace(req struct {
Message string `json:"message"`
ConversationID string `json:"conversationId"`
Namespace string `json:"namespace"`
IncidentID *int64 `json:"incidentId,omitempty"`
}) string {
if req.Namespace != "" || req.IncidentID == nil {
return req.Namespace
}
a := e.analyzer
if a == nil || a.rcaEngine == nil || a.rcaEngine.correlator == nil {
return ""
}
inc := a.rcaEngine.correlator.GetIncident(*req.IncidentID)
if inc == nil {
return ""
}
for _, s := range inc.Signals {
if s.Namespace != "" {
return s.Namespace
}
}
return ""
}

// incidentContext builds the incident grounding block for the operational chat
// when the caller references a specific incident (mirrors handleAsk's incident
// prompt so both entry points ground identically). Nil/low-cardinality inputs
// produce an empty string so chat never invents incident state.
func (e *ChatEngine) incidentContext(id *int64) string {
if id == nil {
return ""
}
a := e.analyzer
if a == nil || a.rcaEngine == nil || a.rcaEngine.correlator == nil {
return ""
}
inc := a.rcaEngine.correlator.GetIncident(*id)
if inc == nil {
return ""
}
return rcaIncidentGrounding(a.rcaEngine, inc)
}

// rcaIncidentGrounding renders the incident section (signals, cluster health,
// logs) plus any existing RCA summary into a prompt block. Extracted so both
// handleAsk and the tool-calling chat share identical grounding.
func rcaIncidentGrounding(e *RCAEngine, inc *Incident) string {
var b strings.Builder
b.WriteString(e.buildPrompt(inc))
if inc.RCAReport != nil {
fmt.Fprintf(&b, "\n\nPrevious RCA summary: %s\nRoot cause identified: %s\nRCA confidence: %.0f%%",
inc.RCAReport.Summary, inc.RCAReport.RootCause.Primary, inc.RCAReport.RootCause.Confidence*100)
}
return b.String()
}

func (e *ChatEngine) buildMessages(message string, history []chatMsg, grounding, namespace, incidentCtx string) []types.LLMMessage {
sys := chatSystemPrompt
if namespace != "" {
sys += "\nThe operator's current namespace context is: " + namespace + "."
}
if incidentCtx != "" {
sys += "\n\n" + incidentCtx
}
msgs := []types.LLMMessage{{Role: "system", Content: sys}}

// Recent history (last 8 turns) for continuity.
Expand Down
110 changes: 110 additions & 0 deletions src/analyzer/chat_incident_context_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package main

import (
"strings"
"testing"
"time"

"github.com/hellodk/hetu/pkg/types"
)

// int64p returns a pointer to v (matching the JSON-optional incidentId field).
func int64p(v int64) *int64 { return &v }

// newChatEngineWithIncident builds a ChatEngine whose analyzer has an
// RCAEngine wired to a correlator that already contains one incident.
func newChatEngineWithIncident(t *testing.T) *ChatEngine {
t.Helper()
corr := NewCorrelator("test", time.Minute)
corr.incidents[42] = &Incident{
ID: 42,
Severity: "critical",
Status: "investigating",
DetectedAt: time.Now(),
Affected: []string{"default/api-server-abc"},
Summary: "api-server-abc crashlooping in default",
Signals: []Signal{{
Timestamp: time.Now(),
Source: "logs",
Severity: "critical",
Namespace: "default",
Service: "api-server-abc",
Pod: "api-server-abc-xyz",
Kind: "crashloop",
Title: "api-server-abc CrashLoopBackOff",
}},
RCAReport: &RCAReport{
Summary: "OOM kill from 256Mi memory limit",
RootCause: RootCause{Primary: "memory limit exceeded", Confidence: 0.9},
},
}
a := &Analyzer{
rcaEngine: &RCAEngine{correlator: corr},
correlator: corr,
}
return &ChatEngine{analyzer: a}
}

// TestChatIncidentContext_Seeded verifies the incident context block is
// produced for an incident the correlator knows about.
func TestChatIncidentContext_Seeded(t *testing.T) {
e := newChatEngineWithIncident(t)
ctx := e.incidentContext(int64p(42))
if ctx == "" {
t.Fatal("expected incident context for known incident, got empty")
}
for _, want := range []string{"INC-42", "api-server-abc", "OOM kill from 256Mi memory limit"} {
if !strings.Contains(ctx, want) {
t.Fatalf("incident context missing %q:\n%s", want, ctx)
}
}
}

// TestChatIncidentContext_Unknown verifies an unknown or out-of-scope incident
// produces nothing (so chat does not invent incident context).
func TestChatIncidentContext_Unknown(t *testing.T) {
e := newChatEngineWithIncident(t)
if got := e.incidentContext(int64p(999)); got != "" {
t.Fatalf("expected empty context for unknown incident, got %q", got)
}
}

// TestChatBuildMessages_IncludesIncidentContext verifies a chat turn grounded
// on an incident carries that context inside the system prompt — the same
// grounding handleAsk gets, now available to the tool-calling engine.
func TestChatBuildMessages_IncludesIncidentContext(t *testing.T) {
e := newChatEngineWithIncident(t)
msgs := e.buildMessages("How many pods are affected?", nil, "", "", e.incidentContext(int64p(42)))
if len(msgs) == 0 {
t.Fatal("buildMessages returned no messages")
}
sys := msgs[0].Content
if !strings.Contains(sys, "INC-42") {
t.Fatalf("system prompt missing incident context:\n%s", sys)
}
// Non-grounding turn stays generic.
plain := e.buildMessages("hello", nil, "", "", "")
if strings.Contains(plain[0].Content, "INC-42") {
t.Fatal("system prompt leaked incident context for unrelated turn")
}
}

// TestChatBuildMessages_HistoryTrimGuard keeps the turn format stable: user
// question must be the last message and history must precede it.
func TestChatBuildMessages_HistoryTrimGuard(t *testing.T) {
e := newChatEngineWithIncident(t)
history := []chatMsg{
{Role: "user", Content: "what broke?"},
{Role: "assistant", Content: "the api-server"},
}
msgs := e.buildMessages("and the namespace?", history, "", "default", "")
last := msgs[len(msgs)-1]
if last.Role != "user" || !strings.Contains(last.Content, "and the namespace?") {
t.Fatalf("expected user question as final message, got %+v", last)
}
if got := len(msgs); got < 3 {
t.Fatalf("expected system + 2 history + question, got %d messages", got)
}
}

var _ = types.LLMMessage{} // keep types import when assertions above evolve
Loading
Loading