Skip to content

Commit 77144ff

Browse files
committed
intelligence: enrich executive answers with trends and evidence permalinks
1 parent 946c786 commit 77144ff

File tree

6 files changed

+371
-51
lines changed

6 files changed

+371
-51
lines changed

conf/compose/dev_docker_compose.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,10 @@ services:
157157
- RABBITMQ_EXCHANGE=cleanapp-exchange
158158
- RABBITMQ_ANALYSED_REPORT_ROUTING_KEY=report.analysed
159159
- RABBITMQ_TWITTER_REPLY_ROUTING_KEY=twitter.reply
160+
- GEMINI_API_KEY=${GEMINI_API_KEY}
161+
- GEMINI_MODEL=gemini-2.5-flash
162+
- INTELLIGENCE_FREE_MAX_TURNS=5
163+
- INTELLIGENCE_BASE_URL=https://cleanapp.io
160164
ports:
161165
- 9081:8080
162166
depends_on:

conf/compose/prod_docker_compose.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,10 @@ services:
157157
- RABBITMQ_EXCHANGE=cleanapp-exchange
158158
- RABBITMQ_ANALYSED_REPORT_ROUTING_KEY=report.analysed
159159
- RABBITMQ_TWITTER_REPLY_ROUTING_KEY=twitter.reply
160+
- GEMINI_API_KEY=${GEMINI_API_KEY}
161+
- GEMINI_MODEL=gemini-2.5-flash
162+
- INTELLIGENCE_FREE_MAX_TURNS=5
163+
- INTELLIGENCE_BASE_URL=https://cleanapp.io
160164
ports:
161165
- 9081:8080
162166
depends_on:

platform_blueprint/deploy/prod/docker-compose.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,10 @@ services:
157157
- RABBITMQ_EXCHANGE=cleanapp-exchange
158158
- RABBITMQ_ANALYSED_REPORT_ROUTING_KEY=report.analysed
159159
- RABBITMQ_TWITTER_REPLY_ROUTING_KEY=twitter.reply
160+
- GEMINI_API_KEY=${GEMINI_API_KEY}
161+
- GEMINI_MODEL=gemini-2.5-flash
162+
- INTELLIGENCE_FREE_MAX_TURNS=5
163+
- INTELLIGENCE_BASE_URL=https://cleanapp.io
160164
ports:
161165
- 127.0.0.1:9081:8080
162166
depends_on:

report-listener/config/config.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package config
33
import (
44
"os"
55
"strconv"
6+
"strings"
67
"time"
78
)
89

@@ -37,6 +38,7 @@ type Config struct {
3738
GeminiAPIKey string
3839
GeminiModel string
3940
IntelligenceFreeTierMaxTurn int
41+
IntelligenceBaseURL string
4042
}
4143

4244
// Load loads configuration from environment variables
@@ -71,6 +73,7 @@ func Load() *Config {
7173
GeminiAPIKey: getEnv("GEMINI_API_KEY", ""),
7274
GeminiModel: getEnv("GEMINI_MODEL", "gemini-2.5-flash"),
7375
IntelligenceFreeTierMaxTurn: getIntEnv("INTELLIGENCE_FREE_MAX_TURNS", 5),
76+
IntelligenceBaseURL: strings.TrimRight(getEnv("INTELLIGENCE_BASE_URL", "https://cleanapp.io"), "/"),
7477
}
7578

7679
return config

report-listener/database/intelligence.go

Lines changed: 184 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"database/sql"
66
"fmt"
7+
"regexp"
78
"strings"
89
"time"
910
)
@@ -13,15 +14,42 @@ type NamedCount struct {
1314
Count int
1415
}
1516

17+
type ReportSnippet struct {
18+
Seq int
19+
Title string
20+
Summary string
21+
Classification string
22+
SeverityLevel float64
23+
UpdatedAt time.Time
24+
}
25+
1626
type IntelligenceContext struct {
17-
OrgID string
18-
ReportsAnalyzed int
19-
ReportsThisMonth int
20-
HighPriorityCount int
21-
MediumPriorityCount int
22-
TopClassifications []NamedCount
23-
TopIssues []NamedCount
24-
RecentSummaries []string
27+
OrgID string
28+
ReportsAnalyzed int
29+
ReportsThisMonth int
30+
ReportsLast30Days int
31+
ReportsLast7Days int
32+
ReportsPrev7Days int
33+
GrowthLast7VsPrev7 float64
34+
HighPriorityCount int
35+
MediumPriorityCount int
36+
TopClassifications []NamedCount
37+
TopIssues []NamedCount
38+
RecentSummaries []string
39+
RepresentativeReports []ReportSnippet
40+
MatchedReports []ReportSnippet
41+
Keywords []string
42+
}
43+
44+
var keywordSplitRegex = regexp.MustCompile(`[^\p{L}\p{N}]+`)
45+
46+
var intelligenceStopWords = map[string]struct{}{
47+
"what": {}, "which": {}, "this": {}, "that": {}, "with": {}, "from": {}, "about": {},
48+
"show": {}, "give": {}, "please": {}, "could": {}, "would": {}, "there": {}, "their": {},
49+
"are": {}, "is": {}, "the": {}, "and": {}, "for": {}, "you": {}, "our": {}, "your": {},
50+
"have": {}, "has": {}, "were": {}, "been": {}, "into": {}, "over": {}, "under": {},
51+
"most": {}, "least": {}, "risks": {}, "risk": {}, "issues": {}, "issue": {}, "reports": {},
52+
"problem": {}, "problems": {}, "month": {}, "week": {}, "today": {}, "recent": {},
2553
}
2654

2755
func (d *Database) EnsureIntelligenceTables(ctx context.Context) error {
@@ -103,7 +131,7 @@ func (d *Database) GetAndIncrementIntelligenceUsage(ctx context.Context, session
103131
return true, turnsUsed, nil
104132
}
105133

106-
func (d *Database) GetIntelligenceContext(ctx context.Context, orgID string) (*IntelligenceContext, error) {
134+
func (d *Database) GetIntelligenceContext(ctx context.Context, orgID, question string) (*IntelligenceContext, error) {
107135
org := strings.ToLower(strings.TrimSpace(orgID))
108136
if org == "" {
109137
return nil, fmt.Errorf("org_id is required")
@@ -122,22 +150,36 @@ func (d *Database) GetIntelligenceContext(ctx context.Context, orgID string) (*I
122150
}
123151

124152
_ = d.db.QueryRowContext(ctx, `
125-
SELECT COUNT(*)
126-
FROM reports r
127-
INNER JOIN report_analysis ra ON ra.seq = r.seq
153+
SELECT
154+
COALESCE(SUM(CASE WHEN r.ts >= DATE_FORMAT(UTC_TIMESTAMP(), '%Y-%m-01 00:00:00') THEN 1 ELSE 0 END), 0) AS month_count,
155+
COALESCE(SUM(CASE WHEN r.ts >= UTC_TIMESTAMP() - INTERVAL 30 DAY THEN 1 ELSE 0 END), 0) AS last_30d_count,
156+
COALESCE(SUM(CASE WHEN r.ts >= UTC_TIMESTAMP() - INTERVAL 7 DAY THEN 1 ELSE 0 END), 0) AS last_7d_count,
157+
COALESCE(SUM(CASE WHEN r.ts < UTC_TIMESTAMP() - INTERVAL 7 DAY AND r.ts >= UTC_TIMESTAMP() - INTERVAL 14 DAY THEN 1 ELSE 0 END), 0) AS prev_7d_count
158+
FROM report_analysis ra
159+
INNER JOIN reports r ON ra.seq = r.seq
128160
WHERE ra.brand_name = ?
129161
AND ra.is_valid = TRUE
130-
AND r.ts >= DATE_FORMAT(UTC_TIMESTAMP(), '%Y-%m-01 00:00:00')
131-
`, org).Scan(&result.ReportsThisMonth)
162+
`, org).Scan(
163+
&result.ReportsThisMonth,
164+
&result.ReportsLast30Days,
165+
&result.ReportsLast7Days,
166+
&result.ReportsPrev7Days,
167+
)
168+
169+
if result.ReportsPrev7Days > 0 {
170+
result.GrowthLast7VsPrev7 = (float64(result.ReportsLast7Days-result.ReportsPrev7Days) / float64(result.ReportsPrev7Days)) * 100.0
171+
} else if result.ReportsLast7Days > 0 {
172+
result.GrowthLast7VsPrev7 = 100.0
173+
}
132174

133175
classRows, err := d.db.QueryContext(ctx, `
134-
SELECT COALESCE(classification, 'unknown') AS classification, COUNT(*) AS cnt
176+
SELECT COALESCE(NULLIF(classification, ''), 'unknown') AS classification, COUNT(*) AS cnt
135177
FROM report_analysis
136178
WHERE brand_name = ?
137179
AND is_valid = TRUE
138180
GROUP BY classification
139181
ORDER BY cnt DESC
140-
LIMIT 5
182+
LIMIT 6
141183
`, org)
142184
if err == nil {
143185
defer classRows.Close()
@@ -158,7 +200,7 @@ func (d *Database) GetIntelligenceContext(ctx context.Context, orgID string) (*I
158200
AND title != ''
159201
GROUP BY title
160202
ORDER BY cnt DESC
161-
LIMIT 5
203+
LIMIT 6
162204
`, org)
163205
if err == nil {
164206
defer issueRows.Close()
@@ -178,7 +220,7 @@ func (d *Database) GetIntelligenceContext(ctx context.Context, orgID string) (*I
178220
AND summary IS NOT NULL
179221
AND summary != ''
180222
ORDER BY created_at DESC
181-
LIMIT 5
223+
LIMIT 8
182224
`, org)
183225
if err == nil {
184226
defer summaryRows.Close()
@@ -190,5 +232,129 @@ func (d *Database) GetIntelligenceContext(ctx context.Context, orgID string) (*I
190232
}
191233
}
192234

235+
representative, repErr := d.getReportSnippets(ctx, org, nil, 6)
236+
if repErr == nil {
237+
result.RepresentativeReports = representative
238+
}
239+
240+
keywords := extractKeywords(question)
241+
result.Keywords = keywords
242+
if len(keywords) > 0 {
243+
matched, matchErr := d.getReportSnippets(ctx, org, keywords, 5)
244+
if matchErr == nil {
245+
result.MatchedReports = mergeUniqueSnippets(matched, representative, 5)
246+
}
247+
}
248+
193249
return result, nil
194250
}
251+
252+
func (d *Database) getReportSnippets(ctx context.Context, org string, keywords []string, limit int) ([]ReportSnippet, error) {
253+
if limit <= 0 {
254+
limit = 3
255+
}
256+
257+
query := `
258+
SELECT
259+
ra.seq,
260+
COALESCE(NULLIF(ra.title, ''), '(untitled report)') AS title,
261+
COALESCE(NULLIF(ra.summary, ''), COALESCE(NULLIF(ra.description, ''), '(no summary available)')) AS summary,
262+
COALESCE(NULLIF(ra.classification, ''), 'unknown') AS classification,
263+
COALESCE(ra.severity_level, 0) AS severity_level,
264+
COALESCE(ra.updated_at, ra.created_at, r.ts) AS updated_at
265+
FROM report_analysis ra
266+
INNER JOIN reports r ON r.seq = ra.seq
267+
WHERE ra.brand_name = ?
268+
AND ra.is_valid = TRUE
269+
`
270+
271+
args := make([]interface{}, 0, 1+len(keywords)*3+1)
272+
args = append(args, org)
273+
274+
if len(keywords) > 0 {
275+
clauses := make([]string, 0, len(keywords))
276+
for _, kw := range keywords {
277+
clauses = append(clauses, `(LOWER(ra.title) LIKE ? OR LOWER(ra.summary) LIKE ? OR LOWER(ra.description) LIKE ?)`)
278+
pattern := "%" + strings.ToLower(strings.TrimSpace(kw)) + "%"
279+
args = append(args, pattern, pattern, pattern)
280+
}
281+
query += " AND (" + strings.Join(clauses, " OR ") + ")"
282+
}
283+
284+
query += `
285+
ORDER BY ra.severity_level DESC, updated_at DESC
286+
LIMIT ?
287+
`
288+
args = append(args, limit)
289+
290+
rows, err := d.db.QueryContext(ctx, query, args...)
291+
if err != nil {
292+
return nil, err
293+
}
294+
defer rows.Close()
295+
296+
result := make([]ReportSnippet, 0, limit)
297+
for rows.Next() {
298+
var item ReportSnippet
299+
if scanErr := rows.Scan(&item.Seq, &item.Title, &item.Summary, &item.Classification, &item.SeverityLevel, &item.UpdatedAt); scanErr != nil {
300+
return nil, scanErr
301+
}
302+
result = append(result, item)
303+
}
304+
if err := rows.Err(); err != nil {
305+
return nil, err
306+
}
307+
return result, nil
308+
}
309+
310+
func extractKeywords(question string) []string {
311+
q := strings.ToLower(strings.TrimSpace(question))
312+
if q == "" {
313+
return nil
314+
}
315+
316+
raw := keywordSplitRegex.Split(q, -1)
317+
seen := make(map[string]struct{}, len(raw))
318+
keywords := make([]string, 0, 4)
319+
for _, token := range raw {
320+
t := strings.TrimSpace(token)
321+
if len(t) < 4 {
322+
continue
323+
}
324+
if _, stop := intelligenceStopWords[t]; stop {
325+
continue
326+
}
327+
if _, dup := seen[t]; dup {
328+
continue
329+
}
330+
seen[t] = struct{}{}
331+
keywords = append(keywords, t)
332+
if len(keywords) == 4 {
333+
break
334+
}
335+
}
336+
return keywords
337+
}
338+
339+
func mergeUniqueSnippets(primary, secondary []ReportSnippet, max int) []ReportSnippet {
340+
if max <= 0 {
341+
max = 3
342+
}
343+
out := make([]ReportSnippet, 0, max)
344+
seen := make(map[int]struct{}, max)
345+
appendUnique := func(items []ReportSnippet) {
346+
for _, item := range items {
347+
if len(out) >= max {
348+
return
349+
}
350+
if _, exists := seen[item.Seq]; exists {
351+
continue
352+
}
353+
seen[item.Seq] = struct{}{}
354+
out = append(out, item)
355+
}
356+
}
357+
appendUnique(primary)
358+
appendUnique(secondary)
359+
return out
360+
}

0 commit comments

Comments
 (0)