Skip to content

Commit db8a0fd

Browse files
cpcloudclaude
andcommitted
feat(insights): stream insights incrementally, add categories
Switch from ChatComplete to ChatStream so insight items appear in the dashboard as each complete JSON object finishes streaming. Add structured categories (attention, stale, pattern) to produce genuinely useful insights grouped by urgency. Streaming: - Add insightsStreamStartedMsg and insightsChunkMsg message types - Add parsePartialInsights for bracket-balanced JSON extraction - Rebuild dashboard nav on each chunk for immediate navigability - Use standard spinner.Dot Categories: - Add insightCategory type with three values: attention (needs action), stale (forgotten/abandoned items), pattern (non-obvious trends) - Rewrite prompt to emphasize non-obvious observations and natural language, discouraging regurgitation of obvious dashboard data - Group insights by category with sub-header rows in the dashboard - Update JSON schema to require category field closes #748 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b8d40d5 commit db8a0fd

6 files changed

Lines changed: 418 additions & 121 deletions

File tree

internal/app/dashboard.go

Lines changed: 123 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -972,11 +972,23 @@ func (m *Model) dashToggleAll() {
972972
// Proactive insights (LLM-powered)
973973
// ---------------------------------------------------------------------------
974974

975-
// insightsResultMsg delivers the result of an async insights fetch.
976-
// Generation correlates the result with the request that produced it;
977-
// stale results from canceled requests are discarded by the handler.
975+
// insightsResultMsg delivers an error from an insights fetch that failed
976+
// before streaming could begin (no data, stream start failure).
978977
type insightsResultMsg struct {
979-
Items []insightItem
978+
Err error
979+
Generation uint64
980+
}
981+
982+
// insightsStreamStartedMsg delivers the stream channel to the update loop.
983+
type insightsStreamStartedMsg struct {
984+
Channel <-chan llm.StreamChunk
985+
Generation uint64
986+
}
987+
988+
// insightsChunkMsg carries one streamed token from the insights LLM call.
989+
type insightsChunkMsg struct {
990+
Content string
991+
Done bool
980992
Err error
981993
Generation uint64
982994
}
@@ -1004,11 +1016,11 @@ func (m *Model) cancelInsights() {
10041016
m.dash.insights.cancel()
10051017
m.dash.insights.cancel = nil
10061018
}
1019+
m.dash.insights.streamCh = nil
10071020
}
10081021

1009-
// fetchInsights starts an async LLM call to generate proactive insights.
1010-
// Returns a tea.Cmd that resolves to insightsResultMsg. The returned cmd
1011-
// runs in a background goroutine.
1022+
// fetchInsights starts a streaming LLM call to generate proactive insights.
1023+
// Items appear incrementally as each complete JSON object streams in.
10121024
func (m *Model) fetchInsights() tea.Cmd {
10131025
if !m.insightsWanted() {
10141026
return nil
@@ -1018,6 +1030,7 @@ func (m *Model) fetchInsights() tea.Cmd {
10181030
m.dash.insights.loading = true
10191031
m.dash.insights.err = nil
10201032
m.dash.insights.generation++
1033+
m.dash.insights.streamBuf.Reset()
10211034

10221035
client := m.llmClient
10231036
store := m.store
@@ -1046,40 +1059,82 @@ func (m *Model) fetchInsights() tea.Cmd {
10461059
{Role: "user", Content: "Analyze my home data and provide proactive insights."},
10471060
}
10481061

1049-
raw, err := client.ChatComplete(
1062+
streamCh, err := client.ChatStream(
10501063
ctx, messages,
10511064
llm.WithJSONSchema("insights", llm.InsightsJSONSchema()),
10521065
llm.WithNoThinking(),
10531066
)
10541067
if err != nil {
10551068
return insightsResultMsg{Err: err, Generation: gen}
10561069
}
1057-
if strings.TrimSpace(raw) == "" {
1058-
return insightsResultMsg{
1059-
Err: fmt.Errorf(
1060-
"model returned empty response -- try a larger context_length or smaller dataset",
1061-
),
1062-
Generation: gen,
1063-
}
1070+
return insightsStreamStartedMsg{Channel: streamCh, Generation: gen}
1071+
}
1072+
}
1073+
1074+
// waitForInsightChunk returns a Cmd that reads the next chunk from the
1075+
// insights stream channel.
1076+
func waitForInsightChunk(ch <-chan llm.StreamChunk, gen uint64) tea.Cmd {
1077+
return waitForStream(ch, func(c llm.StreamChunk) tea.Msg {
1078+
return insightsChunkMsg{
1079+
Content: c.Content,
1080+
Done: c.Done,
1081+
Err: c.Err,
1082+
Generation: gen,
10641083
}
1084+
}, insightsChunkMsg{Done: true, Generation: gen})
1085+
}
10651086

1066-
var result struct {
1067-
Insights []insightItem `json:"insights"`
1087+
// parsePartialInsights extracts complete insightItem objects from a partial
1088+
// JSON stream. It finds balanced top-level {...} blocks within the first
1089+
// JSON array and parses each individually, so items appear as they complete.
1090+
func parsePartialInsights(partial string) []insightItem {
1091+
// Find the opening bracket of the insights array.
1092+
idx := strings.Index(partial, "[")
1093+
if idx < 0 {
1094+
return nil
1095+
}
1096+
1097+
var items []insightItem
1098+
depth := 0
1099+
inString := false
1100+
escaped := false
1101+
objStart := -1
1102+
for i := idx; i < len(partial); i++ {
1103+
ch := partial[i]
1104+
if escaped {
1105+
escaped = false
1106+
continue
10681107
}
1069-
if err := json.Unmarshal([]byte(raw), &result); err != nil {
1070-
return insightsResultMsg{Err: fmt.Errorf("parse insights: %w", err), Generation: gen}
1108+
if ch == '\\' && inString {
1109+
escaped = true
1110+
continue
10711111
}
1072-
1073-
// Defense-in-depth: drop insights the LLM returned without a valid entity.
1074-
valid := result.Insights[:0]
1075-
for _, item := range result.Insights {
1076-
if item.EntityID >= 1 {
1077-
valid = append(valid, item)
1112+
if ch == '"' {
1113+
inString = !inString
1114+
continue
1115+
}
1116+
if inString {
1117+
continue
1118+
}
1119+
switch ch {
1120+
case '{':
1121+
if depth == 0 {
1122+
objStart = i
1123+
}
1124+
depth++
1125+
case '}':
1126+
depth--
1127+
if depth == 0 && objStart >= 0 {
1128+
var item insightItem
1129+
if err := json.Unmarshal([]byte(partial[objStart:i+1]), &item); err == nil &&
1130+
item.EntityID >= 1 {
1131+
items = append(items, item)
1132+
}
1133+
objStart = -1
10781134
}
10791135
}
1080-
1081-
return insightsResultMsg{Items: valid, Generation: gen}
10821136
}
1137+
return items
10831138
}
10841139

10851140
// refreshInsights cancels any in-flight request and starts a fresh fetch.
@@ -1153,29 +1208,58 @@ func tabAbbrev(tab string) string {
11531208
return tab
11541209
}
11551210

1156-
// dashInsightsRows returns dashboard rows for the insights section.
1211+
// dashInsightsRows returns dashboard rows for the insights section,
1212+
// grouped by category with sub-header rows.
11571213
func (m *Model) dashInsightsRows() []dashRow {
11581214
ins := m.dash.insights
11591215
if len(ins.items) == 0 {
11601216
return nil
11611217
}
1162-
rows := make([]dashRow, 0, len(ins.items))
1218+
1219+
// Group items by category in display order.
1220+
order := []insightCategory{insightAttention, insightStale, insightPattern}
1221+
grouped := make(map[insightCategory][]insightItem, len(order))
11631222
for _, item := range ins.items {
1164-
target := &dashNavEntry{Section: dashSectionInsights, InfoOnly: true}
1165-
if tab, ok := tabKindFromString(item.Tab); ok {
1166-
target = &dashNavEntry{
1167-
Tab: tab,
1168-
ID: item.EntityID,
1169-
Section: dashSectionInsights,
1170-
}
1223+
grouped[item.Category] = append(grouped[item.Category], item)
1224+
}
1225+
1226+
var rows []dashRow
1227+
for _, cat := range order {
1228+
items := grouped[cat]
1229+
if len(items) == 0 {
1230+
continue
11711231
}
1232+
// Category sub-header row.
11721233
rows = append(rows, dashRow{
11731234
Cells: []dashCell{
1174-
{Text: item.Text, Style: m.styles.DashValue()},
1175-
{Text: tabAbbrev(item.Tab), Style: m.styles.DashLabel(), Align: alignRight},
1235+
{
1236+
Text: insightCategoryLabel(cat),
1237+
Style: m.styles.DashLabel(),
1238+
},
11761239
},
1177-
Target: target,
1240+
Target: &dashNavEntry{Section: dashSectionInsights, InfoOnly: true},
11781241
})
1242+
for _, item := range items {
1243+
target := &dashNavEntry{Section: dashSectionInsights, InfoOnly: true}
1244+
if tab, ok := tabKindFromString(item.Tab); ok {
1245+
target = &dashNavEntry{
1246+
Tab: tab,
1247+
ID: item.EntityID,
1248+
Section: dashSectionInsights,
1249+
}
1250+
}
1251+
rows = append(rows, dashRow{
1252+
Cells: []dashCell{
1253+
{Text: item.Text, Style: m.styles.DashValue()},
1254+
{
1255+
Text: tabAbbrev(item.Tab),
1256+
Style: m.styles.DashLabel(),
1257+
Align: alignRight,
1258+
},
1259+
},
1260+
Target: target,
1261+
})
1262+
}
11791263
}
11801264
return rows
11811265
}

0 commit comments

Comments
 (0)