Skip to content

Commit 80c9875

Browse files
cpcloudclaude
andcommitted
feat(llm): add proactive insights to dashboard
Surface LLM-powered insights as the last dashboard section when llm.insights = true in config. Insights are generated on-demand when the dashboard opens, rendered non-blocking with a spinner, cached per session, and auto-invalidated on mutations. Each insight row navigates to the relevant tab/entity. Press `r` to manually refresh. - Add `Insights *bool` config field with TOML/env support - Add `insightsState` to track loading, items, staleness, errors - Add `fetchInsights()` async tea.Cmd using ChatComplete + JSON schema - Add `BuildInsightsPrompt()` and `InsightsJSONSchema()` in llm package - Add dashboard section rendering with spinner, error, staleness - Add navigable rows that jump to target tab/entity - Wire stale invalidation into `reloadAfterMutation()` - Add comprehensive tests across config, dashboard, and prompt packages closes #691 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 3bc8003 commit 80c9875

11 files changed

Lines changed: 952 additions & 20 deletions

File tree

cmd/micasa/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,7 @@ func (cmd *runCmd) Run() error {
156156
chatCfg.ExtraContext,
157157
chatCfg.Timeout,
158158
chatCfg.Thinking,
159+
cfg.LLM.InsightsEnabled(),
159160
)
160161

161162
exCfg := cfg.LLM.ExtractionConfig()

internal/app/dashboard.go

Lines changed: 243 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,18 @@ package app
55

66
import (
77
"cmp"
8+
"context"
9+
"encoding/json"
810
"fmt"
911
"slices"
1012
"strings"
1113
"time"
1214

15+
tea "github.com/charmbracelet/bubbletea"
1316
"github.com/charmbracelet/lipgloss"
1417
"github.com/charmbracelet/x/ansi"
1518
"github.com/cpcloud/micasa/internal/data"
19+
"github.com/cpcloud/micasa/internal/llm"
1620
)
1721

1822
// Dashboard section title constants.
@@ -22,6 +26,7 @@ const (
2226
dashSectionUpcoming = "Upcoming"
2327
dashSectionProjects = "Active Projects"
2428
dashSectionExpiring = "Expiring Soon"
29+
dashSectionInsights = "Insights"
2530
)
2631

2732
// ---------------------------------------------------------------------------
@@ -398,6 +403,19 @@ func (m *Model) buildDashNav() {
398403
}
399404
add(dashSectionExpiring, expiring)
400405

406+
// Insights: LLM-generated items with navigation targets.
407+
if insRows := m.dashInsightsRows(); len(insRows) > 0 {
408+
entries := make([]dashNavEntry, len(insRows))
409+
for i, row := range insRows {
410+
if row.Target != nil {
411+
entries[i] = *row.Target
412+
} else {
413+
entries[i] = dashNavEntry{Section: dashSectionInsights, InfoOnly: true}
414+
}
415+
}
416+
add(dashSectionInsights, entries)
417+
}
418+
401419
var nav []dashNavEntry
402420
for _, g := range groups {
403421
nav = append(nav, dashNavEntry{
@@ -491,7 +509,19 @@ func (m *Model) dashboardView(budget, maxWidth int) string {
491509
})
492510
}
493511

494-
if len(sections) == 0 {
512+
if insRows := m.dashInsightsRows(); len(insRows) > 0 {
513+
sections = append(sections, dashSection{
514+
title: dashSectionInsights,
515+
headers: []string{"", "tab"},
516+
rows: insRows,
517+
})
518+
}
519+
520+
// Show loading/error state for insights even when there are no items yet.
521+
showInsightsLoading := m.insightsEnabled && m.dash.insights.loading
522+
showInsightsError := m.insightsEnabled && m.dash.insights.err != nil && !m.dash.insights.loading
523+
524+
if len(sections) == 0 && !showInsightsLoading && !showInsightsError {
495525
return ""
496526
}
497527

@@ -552,6 +582,37 @@ func (m *Model) dashboardView(budget, maxWidth int) string {
552582
navIdx = dataNavIdx
553583
}
554584

585+
// Insights loading/error indicator (shown even when no insight items exist yet).
586+
if showInsightsLoading {
587+
if len(lines) > 0 {
588+
lines = append(lines, "")
589+
}
590+
dimmed := cursorSection != "" && cursorSection != dashSectionInsights
591+
hdr := m.dashSectionHeader(dashSectionInsights, 0, dimmed)
592+
lines = append(lines, hdr, " "+m.dash.spinner.View()+" analyzing...")
593+
} else if showInsightsError {
594+
if len(lines) > 0 {
595+
lines = append(lines, "")
596+
}
597+
dimmed := cursorSection != "" && cursorSection != dashSectionInsights
598+
hdr := m.dashSectionHeader(dashSectionInsights, 0, dimmed)
599+
lines = append(lines, hdr, " "+m.styles.Error().Render("error: "+m.dash.insights.err.Error()))
600+
}
601+
602+
// Staleness indicator for insights section header.
603+
if m.insightsEnabled && !m.dash.insights.loading && len(m.dash.insights.items) > 0 {
604+
// Append staleness to the insights header line (already in lines).
605+
staleness := m.insightsStaleness()
606+
if staleness != "" {
607+
for i := len(lines) - 1; i >= 0; i-- {
608+
if strings.Contains(lines[i], dashSectionInsights) {
609+
lines[i] += " " + m.styles.DashLabel().Render(staleness)
610+
break
611+
}
612+
}
613+
}
614+
}
615+
555616
// Scroll windowing: show only `budget` lines, following the cursor.
556617
// Reserve lines for scroll indicators (▲/▼) so content is never clipped
557618
// without feedback. Iterate to convergence since reserving indicator
@@ -869,6 +930,187 @@ func (m *Model) dashToggleAll() {
869930
}
870931
}
871932

933+
// ---------------------------------------------------------------------------
934+
// Proactive insights (LLM-powered)
935+
// ---------------------------------------------------------------------------
936+
937+
// insightsResultMsg delivers the result of an async insights fetch.
938+
type insightsResultMsg struct {
939+
Items []insightItem
940+
Err error
941+
}
942+
943+
// insightsWanted reports whether insights should be shown on the dashboard.
944+
func (m *Model) insightsWanted() bool {
945+
return m.insightsEnabled && m.llmClient != nil
946+
}
947+
948+
// fetchInsights starts an async LLM call to generate proactive insights.
949+
// Returns a tea.Cmd that resolves to insightsResultMsg. The returned cmd
950+
// runs in a background goroutine.
951+
func (m *Model) fetchInsights() tea.Cmd {
952+
if !m.insightsWanted() {
953+
return nil
954+
}
955+
956+
// Cancel any in-flight request.
957+
if m.dash.insights.cancel != nil {
958+
m.dash.insights.cancel()
959+
}
960+
961+
m.dash.insights.loading = true
962+
m.dash.insights.err = nil
963+
964+
client := m.llmClient
965+
store := m.store
966+
extraContext := m.llmExtraContext
967+
968+
ctx, cancel := context.WithCancel(context.Background())
969+
m.dash.insights.cancel = cancel
970+
971+
return func() tea.Msg {
972+
dataSummary := ""
973+
if store != nil {
974+
dataSummary = store.DataDump()
975+
}
976+
if dataSummary == "" {
977+
return insightsResultMsg{Err: fmt.Errorf("no data to analyze")}
978+
}
979+
980+
prompt := llm.BuildInsightsPrompt(dataSummary, time.Now(), extraContext)
981+
messages := []llm.Message{
982+
{Role: "system", Content: prompt},
983+
{Role: "user", Content: "Analyze my home data and provide proactive insights."},
984+
}
985+
986+
raw, err := client.ChatComplete(
987+
ctx, messages,
988+
llm.WithJSONSchema("insights", llm.InsightsJSONSchema()),
989+
)
990+
if err != nil {
991+
return insightsResultMsg{Err: err}
992+
}
993+
994+
var result struct {
995+
Insights []insightItem `json:"insights"`
996+
}
997+
if err := json.Unmarshal([]byte(raw), &result); err != nil {
998+
return insightsResultMsg{Err: fmt.Errorf("parse insights: %w", err)}
999+
}
1000+
1001+
return insightsResultMsg{Items: result.Insights}
1002+
}
1003+
}
1004+
1005+
// refreshInsights cancels any in-flight request and starts a fresh fetch.
1006+
func (m *Model) refreshInsights() tea.Cmd {
1007+
m.dash.insights.stale = true
1008+
return m.maybeStartInsights()
1009+
}
1010+
1011+
// maybeStartInsights starts an insights fetch if needed (stale/absent and
1012+
// not already loading).
1013+
func (m *Model) maybeStartInsights() tea.Cmd {
1014+
if !m.insightsWanted() {
1015+
return nil
1016+
}
1017+
if m.dash.insights.loading {
1018+
return nil
1019+
}
1020+
// Already have fresh results.
1021+
if len(m.dash.insights.items) > 0 && !m.dash.insights.stale {
1022+
return nil
1023+
}
1024+
return tea.Batch(m.fetchInsights(), m.dash.spinner.Tick)
1025+
}
1026+
1027+
// markInsightsStale flags insights for refresh on next dashboard open.
1028+
func (m *Model) markInsightsStale() {
1029+
m.dash.insights.stale = true
1030+
}
1031+
1032+
// tabKindFromString maps a tab name string to a TabKind.
1033+
func tabKindFromString(s string) (TabKind, bool) {
1034+
switch s {
1035+
case "projects":
1036+
return tabProjects, true
1037+
case "quotes":
1038+
return tabQuotes, true
1039+
case "maintenance":
1040+
return tabMaintenance, true
1041+
case "incidents":
1042+
return tabIncidents, true
1043+
case "appliances":
1044+
return tabAppliances, true
1045+
case "vendors":
1046+
return tabVendors, true
1047+
case "documents":
1048+
return tabDocuments, true
1049+
}
1050+
return 0, false
1051+
}
1052+
1053+
// tabAbbrev returns a short 5-char abbreviation for a tab name.
1054+
func tabAbbrev(tab string) string {
1055+
switch tab {
1056+
case "projects":
1057+
return "Proj."
1058+
case "quotes":
1059+
return "Quot."
1060+
case "maintenance":
1061+
return "Mnt."
1062+
case "incidents":
1063+
return "Inc."
1064+
case "appliances":
1065+
return "Appl."
1066+
case "vendors":
1067+
return "Vend."
1068+
case "documents":
1069+
return "Docs."
1070+
}
1071+
return tab
1072+
}
1073+
1074+
// dashInsightsRows returns dashboard rows for the insights section.
1075+
func (m *Model) dashInsightsRows() []dashRow {
1076+
ins := m.dash.insights
1077+
if ins.loading || len(ins.items) == 0 {
1078+
return nil
1079+
}
1080+
rows := make([]dashRow, 0, len(ins.items))
1081+
for _, item := range ins.items {
1082+
target := &dashNavEntry{Section: dashSectionInsights, InfoOnly: true}
1083+
if tab, ok := tabKindFromString(item.Tab); ok {
1084+
target = &dashNavEntry{
1085+
Tab: tab,
1086+
ID: item.EntityID,
1087+
Section: dashSectionInsights,
1088+
}
1089+
if item.EntityID == 0 {
1090+
target.InfoOnly = true
1091+
}
1092+
}
1093+
rows = append(rows, dashRow{
1094+
Cells: []dashCell{
1095+
{Text: item.Text, Style: m.styles.DashValue()},
1096+
{Text: tabAbbrev(item.Tab), Style: m.styles.DashLabel(), Align: alignRight},
1097+
},
1098+
Target: target,
1099+
})
1100+
}
1101+
return rows
1102+
}
1103+
1104+
// insightsStaleness returns a human-readable staleness indicator.
1105+
func (m *Model) insightsStaleness() string {
1106+
ins := m.dash.insights
1107+
if ins.generatedAt.IsZero() {
1108+
return ""
1109+
}
1110+
d := time.Since(ins.generatedAt)
1111+
return shortDur(d) + " ago"
1112+
}
1113+
8721114
// ---------------------------------------------------------------------------
8731115
// Utility helpers
8741116
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)