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:
@@ -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 }
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 => ({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user