Skip to content

Commit 4d6d7db

Browse files
feat: role-aware insight assembler from Brain stores
Implements PR2 of the thinking database feature: role-aware, personalized digest content assembly. New components: - DigestInsight: ranked insight model with severity, freshness, actionability - InsightCategory: enum mapping insight types to persona relevance weights - DigestAssemblyResult: assembled digest with metadata and executive summary - DigestInsightAssemblerService: mines and ranks insights from Brain stores Signal mining from existing stores: - BrainV2Alert: workload changes, config drift, plan regressions - BrainScore: low health score alerts - BrainLearningProgress: tuning milestones - IndexRecommendation: pending index recommendations by priority - SchemaChange: breaking/critical schema changes - GrowthAnomaly: table growth spikes - PlaybookAlert: system alerts - SlowQueryHistory: critical/high slow query counts Ranking formula: score = categoryWeight × severityMultiplier × freshnessMultiplier × personaRelevance × roleMultiplier × actionabilityBonus Persona-aware weighting (examples): - DBA: 2x on QUERY_PERFORMANCE, INDEX_RECOMMENDATIONS, CONFIG_TUNING - DATA_ENG: 2x on DOCUMENTATION_GAPS, SCHEMA_CHANGES - APP_ENG: 2x on SCHEMA_CHANGES, 1.8x on QUERY_PERFORMANCE - EXEC: 2x on COST_CAPACITY, limited to 5 insights with summary Suppression logic: - Filters acknowledged insights - Suppresses duplicates from last digest (via signature key) - High-severity insights (>=80) bypass suppression Tests cover: - Different personas get differently ranked digests - Duplicate suppression across digests - Acknowledged insight filtering - Severity/freshness/actionability ranking - Signal mining from all Brain stores Depends on PR #103 (cursor/per-user-digest-prefs-3784) for UserDigestPreference, PersonaTag, and SlackDigestLog models. Part of: Thinking database — continuous-learning Brain + role-aware digests Co-authored-by: Venkat SF <venkatesh.sakamuri@stayflexi.com>
1 parent 7b4dea1 commit 4d6d7db

5 files changed

Lines changed: 2012 additions & 0 deletions

File tree

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
package com.dbaagent.model.digest;
2+
3+
import com.dbaagent.model.PersonaTag;
4+
import com.dbaagent.model.Role;
5+
import lombok.Builder;
6+
import lombok.Data;
7+
8+
import java.time.LocalDateTime;
9+
import java.util.List;
10+
import java.util.Map;
11+
12+
/**
13+
* Result of assembling a personalized digest for a user.
14+
*
15+
* <p>Contains the ranked insights tailored to the user's role and persona,
16+
* along with metadata about the assembly process.
17+
*/
18+
@Data
19+
@Builder
20+
public class DigestAssemblyResult {
21+
22+
/**
23+
* Username this digest was assembled for.
24+
*/
25+
private String username;
26+
27+
/**
28+
* Connection ID this digest covers.
29+
*/
30+
private String connectionId;
31+
32+
/**
33+
* User's RBAC role at assembly time.
34+
*/
35+
private Role role;
36+
37+
/**
38+
* User's persona tag (may be null for role-only personalization).
39+
*/
40+
private PersonaTag personaTag;
41+
42+
/**
43+
* When this digest was assembled.
44+
*/
45+
private LocalDateTime assembledAt;
46+
47+
/**
48+
* Time window start for insights (e.g., since last digest).
49+
*/
50+
private LocalDateTime windowStart;
51+
52+
/**
53+
* Time window end for insights.
54+
*/
55+
private LocalDateTime windowEnd;
56+
57+
/**
58+
* Ranked insights for this user (highest rank first).
59+
*/
60+
private List<DigestInsight> insights;
61+
62+
/**
63+
* Summary counts by category for quick overview.
64+
*/
65+
private Map<InsightCategory, Integer> categoryCounts;
66+
67+
/**
68+
* Total insights considered before filtering/ranking.
69+
*/
70+
private int totalCandidates;
71+
72+
/**
73+
* Insights suppressed as duplicates from last digest.
74+
*/
75+
private int suppressedDuplicates;
76+
77+
/**
78+
* Insights filtered out due to acknowledgment.
79+
*/
80+
private int filteredAcknowledged;
81+
82+
/**
83+
* Whether this is an empty digest (no actionable insights).
84+
*/
85+
@Builder.Default
86+
private boolean empty = false;
87+
88+
/**
89+
* Executive summary for EXEC personas (3 bullets max).
90+
* Null for other personas.
91+
*/
92+
private List<String> executiveSummary;
93+
94+
/**
95+
* Top decision ask for EXEC personas.
96+
* Null for other personas.
97+
*/
98+
private String decisionAsk;
99+
100+
/**
101+
* Get insights by category.
102+
*/
103+
public List<DigestInsight> getInsightsByCategory(InsightCategory category) {
104+
if (insights == null) return List.of();
105+
return insights.stream()
106+
.filter(i -> i.getCategory() == category)
107+
.toList();
108+
}
109+
110+
/**
111+
* Get top N insights.
112+
*/
113+
public List<DigestInsight> getTopInsights(int n) {
114+
if (insights == null) return List.of();
115+
return insights.stream().limit(n).toList();
116+
}
117+
118+
/**
119+
* Check if digest has critical insights.
120+
*/
121+
public boolean hasCriticalInsights() {
122+
return insights != null && insights.stream()
123+
.anyMatch(i -> i.getSeverity() >= 90);
124+
}
125+
126+
/**
127+
* Check if digest has high-severity insights.
128+
*/
129+
public boolean hasHighSeverityInsights() {
130+
return insights != null && insights.stream()
131+
.anyMatch(i -> i.getSeverity() >= 70);
132+
}
133+
134+
/**
135+
* Get the headline for the digest.
136+
*/
137+
public String getHeadline() {
138+
if (empty || insights == null || insights.isEmpty()) {
139+
return "No new insights since your last digest";
140+
}
141+
int count = insights.size();
142+
if (hasCriticalInsights()) {
143+
long criticalCount = insights.stream().filter(i -> i.getSeverity() >= 90).count();
144+
return String.format("%d insight%s (%d critical)",
145+
count, count != 1 ? "s" : "", criticalCount);
146+
}
147+
if (hasHighSeverityInsights()) {
148+
long highCount = insights.stream().filter(i -> i.getSeverity() >= 70).count();
149+
return String.format("%d insight%s (%d high priority)",
150+
count, count != 1 ? "s" : "", highCount);
151+
}
152+
return String.format("%d new insight%s", count, count != 1 ? "s" : "");
153+
}
154+
155+
/**
156+
* Create an empty result for a user with no insights.
157+
*/
158+
public static DigestAssemblyResult empty(String username, String connectionId,
159+
Role role, PersonaTag personaTag) {
160+
return DigestAssemblyResult.builder()
161+
.username(username)
162+
.connectionId(connectionId)
163+
.role(role)
164+
.personaTag(personaTag)
165+
.assembledAt(LocalDateTime.now())
166+
.insights(List.of())
167+
.categoryCounts(Map.of())
168+
.empty(true)
169+
.build();
170+
}
171+
}
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
package com.dbaagent.model.digest;
2+
3+
import lombok.Builder;
4+
import lombok.Data;
5+
import lombok.With;
6+
7+
import java.time.LocalDateTime;
8+
import java.util.Map;
9+
10+
/**
11+
* A single ranked insight for inclusion in a digest.
12+
*
13+
* <p>Insights are mined from Brain stores, slow-query analysis, schema changes,
14+
* growth anomalies, and other sources. Each insight carries enough context
15+
* for rendering in Slack/email and for suppression logic.
16+
*
17+
* <h3>Ranking Formula</h3>
18+
* <pre>
19+
* finalScore = baseScore
20+
* × severityMultiplier
21+
* × freshnessMultiplier
22+
* × roleRelevanceMultiplier
23+
* × actionabilityBonus
24+
* </pre>
25+
*
26+
* <h3>Suppression</h3>
27+
* <p>The {@code signatureKey} is used to detect duplicates across digests.
28+
* If the same signature was included in a recent digest and the insight
29+
* has not materially changed, it is suppressed to avoid "blast of the same
30+
* top item" syndrome.
31+
*/
32+
@Data
33+
@Builder
34+
@With
35+
public class DigestInsight {
36+
37+
/**
38+
* Category of this insight.
39+
*/
40+
private InsightCategory category;
41+
42+
/**
43+
* Short headline for the insight (one line).
44+
* Example: "3 high-impact index recommendations pending"
45+
*/
46+
private String headline;
47+
48+
/**
49+
* Detailed description with context.
50+
* Example: "Index on orders(customer_id) could save 2.3s/query..."
51+
*/
52+
private String description;
53+
54+
/**
55+
* Severity level (0-100). Higher = more urgent.
56+
* Maps to CRITICAL=100, HIGH=75, WARNING=50, INFO=25.
57+
*/
58+
private int severity;
59+
60+
/**
61+
* When this insight was detected or last updated.
62+
*/
63+
private LocalDateTime timestamp;
64+
65+
/**
66+
* Whether this insight is actionable (user can do something about it).
67+
* Actionable insights get a ranking bonus.
68+
*/
69+
@Builder.Default
70+
private boolean actionable = true;
71+
72+
/**
73+
* Suggested action for the user, if any.
74+
* Example: "Run ANALYZE on affected tables"
75+
*/
76+
private String suggestedAction;
77+
78+
/**
79+
* Source entity ID (e.g., alert ID, recommendation ID).
80+
* Used for deduplication and linking back to the source.
81+
*/
82+
private String sourceId;
83+
84+
/**
85+
* Source entity type (e.g., "BrainV2Alert", "IndexRecommendation").
86+
*/
87+
private String sourceType;
88+
89+
/**
90+
* Unique signature for deduplication across digests.
91+
* Format: "{category}:{sourceType}:{key-fields-hash}"
92+
* Example: "INDEX_RECOMMENDATIONS:IndexRecommendation:orders_customer_id_idx"
93+
*/
94+
private String signatureKey;
95+
96+
/**
97+
* Connection ID this insight belongs to.
98+
*/
99+
private String connectionId;
100+
101+
/**
102+
* Additional context data for rendering.
103+
* Keys depend on the insight type.
104+
*/
105+
private Map<String, Object> contextData;
106+
107+
/**
108+
* The final computed rank score (higher = more important).
109+
* Set by the assembler after applying all multipliers.
110+
*/
111+
private double rankScore;
112+
113+
/**
114+
* Whether this insight was acknowledged by the user.
115+
* Acknowledged insights are typically suppressed from digests.
116+
*/
117+
@Builder.Default
118+
private boolean acknowledged = false;
119+
120+
/**
121+
* Whether this insight appeared in the last digest to this recipient.
122+
* Used for "since last digest" filtering.
123+
*/
124+
@Builder.Default
125+
private boolean appearedInLastDigest = false;
126+
127+
/**
128+
* Tables involved in this insight (for schema-based filtering).
129+
*/
130+
private String[] involvedTables;
131+
132+
/**
133+
* Get severity as a display string.
134+
*/
135+
public String getSeverityLabel() {
136+
if (severity >= 90) return "CRITICAL";
137+
if (severity >= 70) return "HIGH";
138+
if (severity >= 40) return "WARNING";
139+
return "INFO";
140+
}
141+
142+
/**
143+
* Get the severity multiplier for ranking (1.0 to 2.0).
144+
*/
145+
public double getSeverityMultiplier() {
146+
return 1.0 + (severity / 100.0);
147+
}
148+
149+
/**
150+
* Get the freshness multiplier for ranking (0.5 to 1.5).
151+
* Insights from the last hour are boosted; older insights decay.
152+
*/
153+
public double getFreshnessMultiplier() {
154+
if (timestamp == null) {
155+
return 1.0;
156+
}
157+
LocalDateTime now = LocalDateTime.now();
158+
long hoursAgo = java.time.Duration.between(timestamp, now).toHours();
159+
160+
if (hoursAgo <= 1) return 1.5;
161+
if (hoursAgo <= 6) return 1.3;
162+
if (hoursAgo <= 24) return 1.1;
163+
if (hoursAgo <= 72) return 1.0;
164+
if (hoursAgo <= 168) return 0.8;
165+
return 0.5;
166+
}
167+
168+
/**
169+
* Get the actionability bonus (1.0 or 1.2).
170+
*/
171+
public double getActionabilityBonus() {
172+
return actionable && suggestedAction != null ? 1.2 : 1.0;
173+
}
174+
175+
/**
176+
* Convenience builder method for creating from an alert.
177+
*/
178+
public static DigestInsightBuilder fromBrainAlert() {
179+
return DigestInsight.builder()
180+
.sourceType("BrainV2Alert");
181+
}
182+
183+
/**
184+
* Convenience builder method for creating from a recommendation.
185+
*/
186+
public static DigestInsightBuilder fromIndexRecommendation() {
187+
return DigestInsight.builder()
188+
.sourceType("IndexRecommendation")
189+
.category(InsightCategory.INDEX_RECOMMENDATIONS);
190+
}
191+
192+
/**
193+
* Convenience builder method for creating from a schema change.
194+
*/
195+
public static DigestInsightBuilder fromSchemaChange() {
196+
return DigestInsight.builder()
197+
.sourceType("SchemaChange")
198+
.category(InsightCategory.SCHEMA_CHANGES);
199+
}
200+
201+
/**
202+
* Convenience builder method for creating from a growth anomaly.
203+
*/
204+
public static DigestInsightBuilder fromGrowthAnomaly() {
205+
return DigestInsight.builder()
206+
.sourceType("GrowthAnomaly")
207+
.category(InsightCategory.GROWTH_ANOMALIES);
208+
}
209+
}

0 commit comments

Comments
 (0)