diff --git a/internal/llm/prompt.go b/internal/llm/prompt.go index 3a659cf..1c15577 100644 --- a/internal/llm/prompt.go +++ b/internal/llm/prompt.go @@ -11,6 +11,7 @@ Rules: - Reference ONLY the data provided below. Do not invent or assume additional information. - Use preparedness language (comfort, planning). Never give medical advice or diagnoses. - Each bullet: max 20 words, plain language, actionable insight. +- If peak temperature is below 22°C and risk level is "low", state simply that no heat risk is expected today. - Format: "- [bullet text]" (markdown list)` const rewriteActionSystemPrompt = `You are a heat preparedness assistant. Rewrite the given technical action into a clear, friendly, plain-language instruction. @@ -20,6 +21,19 @@ Rules: - Use preparedness language. Never give medical advice. - Return only the rewritten sentence, nothing else.` +const generateActionsSystemPrompt = `You are a heat preparedness assistant. Generate context-aware cooling actions for a specific day. +Rules: +- Return ONLY a JSON array of action objects. No markdown, no explanation, no wrapping text. +- Each object schema: {"name": string, "description": string, "category": string, "effort": string, "impact": string, "hours": [int]} +- Valid categories: shading, ventilation, internal_gains, ac_strategy, hydration, care +- Valid effort values: none, low, medium, high +- Valid impact values: low, medium, high +- Be specific: reference temperatures, time windows, indoor/outdoor differentials, solar gain timing, and room orientations. +- Use the coolMode data to recommend ventilation vs AC per time window. +- If peak outdoor temperature is below 22°C and risk level is "low", return an empty array []. No cooling actions are needed on mild or cold days. +- Generate 5-12 actions covering the most impactful strategies for the day. +- Use preparedness language. Never give medical advice or diagnoses.` + const heatPlanSystemPrompt = `You are a heat preparedness assistant. Generate a 1-page plain-language heat plan document. Rules: - Reference ONLY the data provided below. Do not invent information. @@ -91,6 +105,51 @@ func BuildHeatPlanPrompt(input HeatPlanInput) string { return b.String() } +// BuildActionsPrompt constructs the user message for GenerateActions. +func BuildActionsPrompt(input ActionsInput) string { + var b strings.Builder + if input.Language != "" { + fmt.Fprintf(&b, "Respond in: %s\n\n", input.Language) + } + fmt.Fprintf(&b, "Date: %s\n", input.Date) + fmt.Fprintf(&b, "Indoor target: %.1f°C\n", input.IndoorTempC) + fmt.Fprintf(&b, "Peak outdoor temp: %.1f°C\n", input.PeakTempC) + fmt.Fprintf(&b, "Min night temp: %.1f°C\n", input.MinNightTempC) + fmt.Fprintf(&b, "Poor night cooling: %v\n", input.PoorNightCool) + fmt.Fprintf(&b, "Risk level: %s\n", input.RiskLevel) + + if len(input.RiskWindows) > 0 { + b.WriteString("\nRisk windows:\n") + for _, rw := range input.RiskWindows { + fmt.Fprintf(&b, " %02d:00–%02d:00, peak %.1f°C, level: %s\n", rw.StartHour, rw.EndHour, rw.PeakTempC, rw.Level) + } + } + + if len(input.Timeline) > 0 { + b.WriteString("\nHourly data:\n") + b.WriteString(" HH:00 | outdoor°C | Δ indoor | humidity% | budget | coolMode | gainsW\n") + for _, s := range input.Timeline { + delta := s.TempC - input.IndoorTempC + fmt.Fprintf(&b, " %02d:00 | %5.1f°C | %+5.1f°C | %5.1f%% | %-10s| %-10s | %.0fW\n", + s.Hour, s.TempC, delta, s.HumidityPct, s.BudgetStatus, s.CoolMode, s.GainsW) + } + } + + if len(input.Rooms) > 0 { + b.WriteString("\nRooms:\n") + for _, r := range input.Rooms { + acStr := "no" + if r.HasAC { + acStr = "yes" + } + fmt.Fprintf(&b, " - %s (orientation: %s, shading: %s, AC: %s)\n", + r.Name, r.Orientation, r.ShadingType, acStr) + } + } + + return b.String() +} + // SummarizeSystemPrompt returns the system prompt for Summarize. func SummarizeSystemPrompt() string { return summarizeSystemPrompt } @@ -99,3 +158,6 @@ func RewriteActionSystemPrompt() string { return rewriteActionSystemPrompt } // HeatPlanSystemPrompt returns the system prompt for GenerateHeatPlan. func HeatPlanSystemPrompt() string { return heatPlanSystemPrompt } + +// GenerateActionsSystemPrompt returns the system prompt for GenerateActions. +func GenerateActionsSystemPrompt() string { return generateActionsSystemPrompt } diff --git a/internal/llm/prompt_test.go b/internal/llm/prompt_test.go index e1e945c..bf3d37b 100644 --- a/internal/llm/prompt_test.go +++ b/internal/llm/prompt_test.go @@ -80,6 +80,83 @@ func TestBuildHeatPlanPrompt(t *testing.T) { } } +func testActionsInput() ActionsInput { + return ActionsInput{ + Date: "2025-07-15", + Language: "English", + IndoorTempC: 25.0, + PeakTempC: 37.2, + MinNightTempC: 22.5, + PoorNightCool: true, + RiskLevel: "high", + RiskWindows: []RiskWindowSummary{{StartHour: 11, EndHour: 18, PeakTempC: 37.2, Level: "high"}}, + Timeline: []ActionsTimelineSlot{ + {Hour: 6, TempC: 20, HumidityPct: 60, BudgetStatus: "comfortable", CoolMode: "ventilate", GainsW: 150}, + {Hour: 14, TempC: 37, HumidityPct: 35, BudgetStatus: "overloaded", CoolMode: "overloaded", GainsW: 800}, + }, + Rooms: []ActionsRoom{ + {Name: "Office", Orientation: "S", ShadingType: "shutters", HasAC: true}, + {Name: "Bedroom", Orientation: "N", ShadingType: "blinds", HasAC: false}, + }, + } +} + +func TestBuildActionsPrompt_ContainsAllFields(t *testing.T) { + p := BuildActionsPrompt(testActionsInput()) + + checks := []string{ + "2025-07-15", + "English", + "25.0", + "37.2", + "22.5", + "true", + "high", + "11:00", + "18:00", + "Office", + "Bedroom", + "shutters", + "ventilate", + "overloaded", + "800W", + } + for _, c := range checks { + if !strings.Contains(p, c) { + t.Errorf("prompt missing %q", c) + } + } +} + +func TestGenerateActionsSystemPrompt_NotEmpty(t *testing.T) { + if GenerateActionsSystemPrompt() == "" { + t.Error("empty generate actions system prompt") + } + if !strings.Contains(GenerateActionsSystemPrompt(), "JSON array") { + t.Error("system prompt should mention JSON array format") + } +} + +func TestGenerateActionsSystemPrompt_ContainsLowRiskGuidance(t *testing.T) { + p := GenerateActionsSystemPrompt() + if !strings.Contains(p, "below 22°C") { + t.Error("system prompt should mention below 22°C threshold") + } + if !strings.Contains(p, "empty array") { + t.Error("system prompt should instruct returning empty array for low risk") + } +} + +func TestSummarizeSystemPrompt_ContainsLowRiskGuidance(t *testing.T) { + p := SummarizeSystemPrompt() + if !strings.Contains(p, "below 22°C") { + t.Error("system prompt should mention below 22°C threshold") + } + if !strings.Contains(p, "no heat risk") { + t.Error("system prompt should mention no heat risk for low temps") + } +} + func TestSystemPrompts_NotEmpty(t *testing.T) { if SummarizeSystemPrompt() == "" { t.Error("empty summarize system prompt") diff --git a/web/js/dashboard.js b/web/js/dashboard.js index 86b116a..4c94c96 100644 --- a/web/js/dashboard.js +++ b/web/js/dashboard.js @@ -157,8 +157,9 @@ $("llm-section").classList.add("hidden"); } - // AI actions (async) - if (llmProvider && llmApiKey) { + // AI actions (async) — skip when no heat risk + const needsHeatActions = data.peakTempC >= 22 || data.riskLevel !== "low"; + if (llmProvider && llmApiKey && needsHeatActions) { show("actions-loading"); try { const rooms = (payload.rooms || []).map(r => ({ diff --git a/web/templates/dashboard.html b/web/templates/dashboard.html index 2a52744..ef227e7 100644 --- a/web/templates/dashboard.html +++ b/web/templates/dashboard.html @@ -49,44 +49,45 @@ - -
-
-
-
{{t "dashboard.riskLevel"}}
-
-
-
-
-
{{t "dashboard.peakTemp"}}
-
-
-
-
-
{{t "dashboard.minNightTemp"}}
-
- -
-
- - - - - -
-

{{t "dashboard.timeline"}}

-
-
- -
- - +
- -
+ +
+ +
+
+
+
{{t "dashboard.riskLevel"}}
+
+
+
+
+
{{t "dashboard.peakTemp"}}
+
+
+
+
+
{{t "dashboard.minNightTemp"}}
+
+ +
+
+ + + + + +
+

{{t "dashboard.timeline"}}

+
+
+ +
+ +