feat: gate AI actions on heat threshold and restructure dashboard layout

Skip LLM actions call when peakTempC < 22°C and risk is low. Add
low-risk guidance to both summary and actions system prompts so the
LLM returns appropriate responses on mild days. Restructure dashboard
into a top-level two-column grid with sidebar beside all main content
and remove max-w-7xl cap for full-width layout.
This commit is contained in:
2026-02-09 15:14:01 +01:00
parent f7f77f45b4
commit c23ac1611a
5 changed files with 183 additions and 42 deletions

View File

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

View File

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

View File

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

View File

@@ -49,44 +49,45 @@
<!-- Warnings -->
<div id="warnings-section" class="hidden space-y-2"></div>
<!-- Summary cards -->
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div id="risk-card" class="rounded-xl p-4 text-center transition-all duration-200 hover:shadow-md">
<div id="risk-icon" class="mb-1"></div>
<div class="text-sm text-gray-500 dark:text-gray-400 mb-1">{{t "dashboard.riskLevel"}}</div>
<div id="risk-level" class="text-2xl font-bold"></div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-xl p-4 text-center shadow-sm border-l-4 border-orange-400 transition-all duration-200 hover:shadow-md">
<div class="mb-1"><svg class="w-5 h-5 mx-auto text-orange-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2v6m0 0a6 6 0 106 6V8a6 6 0 10-6 0z"/></svg></div>
<div class="text-sm text-gray-500 dark:text-gray-400 mb-1">{{t "dashboard.peakTemp"}}</div>
<div id="peak-temp" class="text-2xl font-bold"></div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-xl p-4 text-center shadow-sm border-l-4 border-blue-400 transition-all duration-200 hover:shadow-md">
<div class="mb-1"><svg class="w-5 h-5 mx-auto text-blue-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z"/></svg></div>
<div class="text-sm text-gray-500 dark:text-gray-400 mb-1">{{t "dashboard.minNightTemp"}}</div>
<div id="min-night-temp" class="text-2xl font-bold"></div>
<div id="poor-night-cool" class="hidden text-xs text-orange-600 dark:text-orange-400 mt-1">{{t "dashboard.poorNightCool"}}</div>
</div>
</div>
<!-- Risk windows -->
<div id="risk-windows-section" class="hidden">
<h2 class="text-lg font-semibold mb-2">{{t "dashboard.riskWindows"}}</h2>
<div id="risk-windows" class="space-y-2"></div>
</div>
<!-- Timeline heatmap -->
<div class="relative">
<h2 class="text-lg font-semibold mb-2">{{t "dashboard.timeline"}}</h2>
<div id="timeline-chart" class="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm"></div>
<div id="cooling-legend" class="flex gap-4 text-xs text-gray-400 mt-1"></div>
<div id="timeline-tooltip" class="hidden absolute z-50 bg-gray-800 text-white text-xs rounded-lg p-3 shadow-lg max-w-xs pointer-events-none"></div>
</div>
<!-- Two-column: Actions + Sidebar (budgets, summary, care) -->
<!-- Two-column: Main content + Sidebar -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-5">
<!-- Left column: AI Actions -->
<div class="lg:col-span-2">
<!-- Left column: cards, risk windows, timeline, actions -->
<div class="lg:col-span-2 space-y-5">
<!-- Summary cards -->
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div id="risk-card" class="rounded-xl p-4 text-center transition-all duration-200 hover:shadow-md">
<div id="risk-icon" class="mb-1"></div>
<div class="text-sm text-gray-500 dark:text-gray-400 mb-1">{{t "dashboard.riskLevel"}}</div>
<div id="risk-level" class="text-2xl font-bold"></div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-xl p-4 text-center shadow-sm border-l-4 border-orange-400 transition-all duration-200 hover:shadow-md">
<div class="mb-1"><svg class="w-5 h-5 mx-auto text-orange-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2v6m0 0a6 6 0 106 6V8a6 6 0 10-6 0z"/></svg></div>
<div class="text-sm text-gray-500 dark:text-gray-400 mb-1">{{t "dashboard.peakTemp"}}</div>
<div id="peak-temp" class="text-2xl font-bold"></div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-xl p-4 text-center shadow-sm border-l-4 border-blue-400 transition-all duration-200 hover:shadow-md">
<div class="mb-1"><svg class="w-5 h-5 mx-auto text-blue-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z"/></svg></div>
<div class="text-sm text-gray-500 dark:text-gray-400 mb-1">{{t "dashboard.minNightTemp"}}</div>
<div id="min-night-temp" class="text-2xl font-bold"></div>
<div id="poor-night-cool" class="hidden text-xs text-orange-600 dark:text-orange-400 mt-1">{{t "dashboard.poorNightCool"}}</div>
</div>
</div>
<!-- Risk windows -->
<div id="risk-windows-section" class="hidden">
<h2 class="text-lg font-semibold mb-2">{{t "dashboard.riskWindows"}}</h2>
<div id="risk-windows" class="space-y-2"></div>
</div>
<!-- Timeline heatmap -->
<div class="relative">
<h2 class="text-lg font-semibold mb-2">{{t "dashboard.timeline"}}</h2>
<div id="timeline-chart" class="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm"></div>
<div id="cooling-legend" class="flex gap-4 text-xs text-gray-400 mt-1"></div>
<div id="timeline-tooltip" class="hidden absolute z-50 bg-gray-800 text-white text-xs rounded-lg p-3 shadow-lg max-w-xs pointer-events-none"></div>
</div>
<!-- AI Actions -->
<div id="actions-section" class="hidden">
<div class="flex items-center gap-2 mb-3">
<h2 class="text-lg font-semibold">{{t "dashboard.actions"}}</h2>

View File

@@ -12,7 +12,7 @@
</head>
<body class="h-full bg-gray-50 text-gray-900 dark:bg-gray-900 dark:text-gray-100">
<nav class="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-14">
<div class="flex items-center gap-6">
<a href="/" class="font-bold text-lg text-orange-600 dark:text-orange-400">{{t "app.name"}}</a>
@@ -32,12 +32,12 @@
</div>
</nav>
<main class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<main class="mx-auto px-4 sm:px-6 lg:px-8 py-6">
{{block "content" .}}{{end}}
</main>
<footer class="border-t border-gray-200 dark:border-gray-700 mt-12 py-4">
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 text-center text-xs text-gray-400 dark:text-gray-500 space-y-1">
<div class="mx-auto px-4 sm:px-6 lg:px-8 text-center text-xs text-gray-400 dark:text-gray-500 space-y-1">
<div>{{t "app.name"}} v1.0.0 — {{t "app.tagline"}}</div>
<div><a href="https://somegit.dev/vikingowl/HeatGuard" class="hover:text-orange-600 dark:hover:text-orange-400" target="_blank" rel="noopener">{{t "footer.source"}}</a> · {{t "footer.license"}}</div>
</div>