agentfit 0.1.3 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/.github/workflows/release.yml +4 -0
  2. package/README.md +0 -2
  3. package/app/(dashboard)/ai-insights/page.tsx +271 -0
  4. package/app/(dashboard)/models/page.tsx +21 -0
  5. package/app/(dashboard)/page.tsx +2 -0
  6. package/app/(dashboard)/sessions/[id]/page.tsx +16 -2
  7. package/app/(dashboard)/settings/page.tsx +168 -0
  8. package/app/api/analyze/aggregate/route.ts +88 -0
  9. package/app/api/analyze/estimate/route.ts +62 -0
  10. package/app/api/analyze/route.ts +142 -0
  11. package/app/api/cc-versions/route.ts +84 -0
  12. package/app/api/config/route.ts +35 -0
  13. package/bin/agentfit.mjs +18 -8
  14. package/components/analyze-confirm-dialog.tsx +81 -0
  15. package/components/app-sidebar.tsx +14 -0
  16. package/components/data-provider.tsx +4 -2
  17. package/components/model-usage-chart.tsx +216 -0
  18. package/components/overview-cards.tsx +1 -1
  19. package/components/session-ai-analysis.tsx +318 -0
  20. package/components/sessions-table.tsx +169 -15
  21. package/components/version-lag-chart.tsx +284 -0
  22. package/electron/main.mjs +61 -34
  23. package/generated/prisma/browser.ts +5 -0
  24. package/generated/prisma/client.ts +5 -0
  25. package/generated/prisma/internal/class.ts +14 -4
  26. package/generated/prisma/internal/prismaNamespace.ts +95 -2
  27. package/generated/prisma/internal/prismaNamespaceBrowser.ts +19 -1
  28. package/generated/prisma/models/Session.ts +57 -1
  29. package/generated/prisma/models/SessionAnalysis.ts +1321 -0
  30. package/generated/prisma/models.ts +1 -0
  31. package/lib/config.ts +45 -0
  32. package/lib/openai.ts +253 -0
  33. package/lib/parse-codex.ts +2 -0
  34. package/lib/parse-logs.ts +21 -7
  35. package/lib/queries.ts +5 -1
  36. package/lib/sync.ts +17 -5
  37. package/package.json +2 -1
  38. package/prisma/migrations/20260404151230_add_session_analysis/migration.sql +18 -0
  39. package/prisma/migrations/20260405230736_add_cli_version/migration.sql +41 -0
  40. package/prisma/migrations/20260406205546_add_model_counts/migration.sql +42 -0
  41. package/prisma/schema.prisma +16 -0
  42. package/prisma/schema.sql +21 -0
@@ -37,6 +37,10 @@ jobs:
37
37
  rm certificate.p12
38
38
 
39
39
  - name: Build macOS DMGs
40
+ env:
41
+ APPLE_ID: ${{ secrets.APPLE_ID }}
42
+ APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
43
+ APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
40
44
  run: npm run electron:build:mac
41
45
 
42
46
  - name: Upload DMGs
package/README.md CHANGED
@@ -31,7 +31,6 @@ npx agentfit
31
31
  git clone https://github.com/harrywang/agentfit.git
32
32
  cd agentfit
33
33
  npm install
34
- echo 'DATABASE_URL="file:./agentfit.db"' > .env
35
34
  npx prisma migrate deploy
36
35
  npm run build
37
36
  npm start
@@ -101,7 +100,6 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for the full guide. Quick version:
101
100
  | Variable | Default | Description |
102
101
  |----------|---------|-------------|
103
102
  | `PORT` or `AGENTFIT_PORT` | `3000` | Server port |
104
- | `DATABASE_URL` | `file:./agentfit.db` | SQLite database path |
105
103
 
106
104
  ## Credits
107
105
 
@@ -0,0 +1,271 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useState } from 'react'
4
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
5
+ import { Badge } from '@/components/ui/badge'
6
+ import {
7
+ ChartContainer,
8
+ ChartTooltip,
9
+ ChartTooltipContent,
10
+ type ChartConfig,
11
+ } from '@/components/ui/chart'
12
+ import { Bar, BarChart, XAxis, YAxis, Cell } from 'recharts'
13
+ import { Sparkles, Loader2 } from 'lucide-react'
14
+ import { formatCost } from '@/lib/format'
15
+ import Link from 'next/link'
16
+
17
+ const CHART_COLORS = [
18
+ 'var(--chart-1)',
19
+ 'var(--chart-2)',
20
+ 'var(--chart-3)',
21
+ 'var(--chart-4)',
22
+ 'var(--chart-5)',
23
+ 'var(--chart-6)',
24
+ 'var(--chart-7)',
25
+ 'var(--chart-8)',
26
+ 'var(--chart-9)',
27
+ 'var(--chart-10)',
28
+ ]
29
+
30
+ interface AggregateData {
31
+ totalMessages: number
32
+ messageTypes: Record<string, number>
33
+ roles: Record<string, number>
34
+ skillLevels: Record<string, number>
35
+ sentiments: Record<string, number>
36
+ roleByType: Record<string, Record<string, number>>
37
+ roleBySentiment: Record<string, Record<string, number>>
38
+ }
39
+
40
+ function DistributionChart({
41
+ title,
42
+ description,
43
+ data,
44
+ }: {
45
+ title: string
46
+ description: string
47
+ data: Record<string, number>
48
+ }) {
49
+ const chartData = Object.entries(data)
50
+ .sort((a, b) => b[1] - a[1])
51
+ .map(([name, count]) => ({ name, count }))
52
+
53
+ const config: ChartConfig = {
54
+ count: { label: 'Count', color: 'var(--chart-1)' },
55
+ }
56
+
57
+ const total = chartData.reduce((sum, d) => sum + d.count, 0)
58
+
59
+ return (
60
+ <Card>
61
+ <CardHeader className="pb-2">
62
+ <CardTitle className="text-sm">{title}</CardTitle>
63
+ <CardDescription>{description}</CardDescription>
64
+ </CardHeader>
65
+ <CardContent>
66
+ <ChartContainer
67
+ config={config}
68
+ className="w-full"
69
+ style={{ minHeight: Math.max(150, chartData.length * 32) }}
70
+ >
71
+ <BarChart data={chartData} layout="vertical" margin={{ left: 8 }} accessibilityLayer>
72
+ <XAxis type="number" tickLine={false} axisLine={false} />
73
+ <YAxis
74
+ type="category"
75
+ dataKey="name"
76
+ tickLine={false}
77
+ axisLine={false}
78
+ width={120}
79
+ tick={{ fontSize: 12 }}
80
+ />
81
+ <ChartTooltip
82
+ content={
83
+ <ChartTooltipContent
84
+ formatter={(value) => {
85
+ const n = Number(value)
86
+ return `${n} (${((n / total) * 100).toFixed(1)}%)`
87
+ }}
88
+ />
89
+ }
90
+ />
91
+ <Bar dataKey="count" radius={[0, 4, 4, 0]}>
92
+ {chartData.map((_, i) => (
93
+ <Cell key={i} fill={CHART_COLORS[i % CHART_COLORS.length]} />
94
+ ))}
95
+ </Bar>
96
+ </BarChart>
97
+ </ChartContainer>
98
+ </CardContent>
99
+ </Card>
100
+ )
101
+ }
102
+
103
+ function CrossTab({
104
+ title,
105
+ description,
106
+ data,
107
+ }: {
108
+ title: string
109
+ description: string
110
+ data: Record<string, Record<string, number>>
111
+ }) {
112
+ const rows = Object.keys(data).sort()
113
+ const colSet = new Set<string>()
114
+ for (const row of rows) {
115
+ for (const col of Object.keys(data[row])) colSet.add(col)
116
+ }
117
+ const cols = [...colSet].sort()
118
+
119
+ return (
120
+ <Card>
121
+ <CardHeader className="pb-2">
122
+ <CardTitle className="text-sm">{title}</CardTitle>
123
+ <CardDescription>{description}</CardDescription>
124
+ </CardHeader>
125
+ <CardContent>
126
+ <div className="overflow-x-auto">
127
+ <table className="w-full text-xs">
128
+ <thead>
129
+ <tr className="border-b">
130
+ <th className="text-left py-1.5 pr-3 font-medium text-muted-foreground" />
131
+ {cols.map((col) => (
132
+ <th
133
+ key={col}
134
+ className="text-center py-1.5 px-2 font-medium text-muted-foreground"
135
+ >
136
+ {col}
137
+ </th>
138
+ ))}
139
+ </tr>
140
+ </thead>
141
+ <tbody>
142
+ {rows.map((row) => (
143
+ <tr key={row} className="border-b last:border-0">
144
+ <td className="py-1.5 pr-3 font-medium">{row}</td>
145
+ {cols.map((col) => (
146
+ <td key={col} className="text-center py-1.5 px-2 text-muted-foreground">
147
+ {data[row][col] || ''}
148
+ </td>
149
+ ))}
150
+ </tr>
151
+ ))}
152
+ </tbody>
153
+ </table>
154
+ </div>
155
+ </CardContent>
156
+ </Card>
157
+ )
158
+ }
159
+
160
+ export default function AIInsightsPage() {
161
+ const [aggregate, setAggregate] = useState<AggregateData | null>(null)
162
+ const [analyzedCount, setAnalyzedCount] = useState(0)
163
+ const [totalCost, setTotalCost] = useState(0)
164
+ const [loading, setLoading] = useState(true)
165
+
166
+ useEffect(() => {
167
+ async function load() {
168
+ try {
169
+ const res = await fetch('/api/analyze/aggregate')
170
+ const data = await res.json()
171
+ setAggregate(data.aggregate)
172
+ setAnalyzedCount(data.analyzedCount || 0)
173
+ setTotalCost(data.totalCostUSD || 0)
174
+ } catch {
175
+ // ignore
176
+ } finally {
177
+ setLoading(false)
178
+ }
179
+ }
180
+ load()
181
+ }, [])
182
+
183
+ if (loading) {
184
+ return (
185
+ <div className="flex items-center justify-center py-20 text-muted-foreground">
186
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" /> Loading...
187
+ </div>
188
+ )
189
+ }
190
+
191
+ if (!aggregate) {
192
+ return (
193
+ <Card>
194
+ <CardContent className="flex flex-col items-center justify-center py-16 space-y-4">
195
+ <Sparkles className="h-12 w-12 text-muted-foreground" />
196
+ <div className="text-center space-y-1">
197
+ <p className="text-lg font-medium">No AI analysis data yet</p>
198
+ <p className="text-sm text-muted-foreground">
199
+ Go to{' '}
200
+ <Link href="/sessions" className="text-primary hover:underline">
201
+ Sessions
202
+ </Link>{' '}
203
+ and analyze individual sessions, or use the &quot;Analyze All&quot; button.
204
+ </p>
205
+ <p className="text-sm text-muted-foreground">
206
+ Make sure to add your OpenAI API key in{' '}
207
+ <Link href="/settings" className="text-primary hover:underline">
208
+ Settings
209
+ </Link>{' '}
210
+ first.
211
+ </p>
212
+ </div>
213
+ </CardContent>
214
+ </Card>
215
+ )
216
+ }
217
+
218
+ return (
219
+ <div className="space-y-6">
220
+ <div className="flex items-center gap-3">
221
+ <div className="flex items-center gap-2">
222
+ <Badge variant="outline">
223
+ {analyzedCount} sessions analyzed
224
+ </Badge>
225
+ <Badge variant="outline">
226
+ {aggregate.totalMessages} messages classified
227
+ </Badge>
228
+ <Badge variant="outline">
229
+ Total cost: {formatCost(totalCost)}
230
+ </Badge>
231
+ </div>
232
+ </div>
233
+
234
+ <div className="grid gap-4 md:grid-cols-2">
235
+ <DistributionChart
236
+ title="Message Type Distribution"
237
+ description="How you communicate with AI agents"
238
+ data={aggregate.messageTypes}
239
+ />
240
+ <DistributionChart
241
+ title="Role Distribution"
242
+ description="Professional roles you play during sessions"
243
+ data={aggregate.roles}
244
+ />
245
+ <DistributionChart
246
+ title="Skill Level Distribution"
247
+ description="Technical depth of your messages"
248
+ data={aggregate.skillLevels}
249
+ />
250
+ <DistributionChart
251
+ title="Sentiment Distribution"
252
+ description="Emotional tone across all messages"
253
+ data={aggregate.sentiments}
254
+ />
255
+ </div>
256
+
257
+ <div className="grid gap-4 md:grid-cols-2">
258
+ <CrossTab
259
+ title="Role x Message Type"
260
+ description="What types of messages each role produces"
261
+ data={aggregate.roleByType}
262
+ />
263
+ <CrossTab
264
+ title="Role x Sentiment"
265
+ description="Sentiment patterns by role"
266
+ data={aggregate.roleBySentiment}
267
+ />
268
+ </div>
269
+ </div>
270
+ )
271
+ }
@@ -0,0 +1,21 @@
1
+ 'use client'
2
+
3
+ import { useData } from '@/components/data-provider'
4
+ import { ModelDistributionChart, ModelUsageOverTimeChart } from '@/components/model-usage-chart'
5
+ import { VersionLagChart } from '@/components/version-lag-chart'
6
+
7
+ export default function ModelsPage() {
8
+ const { data } = useData()
9
+ if (!data) return null
10
+
11
+ return (
12
+ <>
13
+ <h1 className="text-2xl font-bold">Model Usage</h1>
14
+ <div className="grid gap-4 lg:grid-cols-2">
15
+ <ModelDistributionChart sessions={data.sessions} />
16
+ <VersionLagChart sessions={data.sessions} />
17
+ </div>
18
+ <ModelUsageOverTimeChart sessions={data.sessions} />
19
+ </>
20
+ )
21
+ }
@@ -4,6 +4,7 @@ import { useData } from '@/components/data-provider'
4
4
  import { FitnessScore } from '@/components/fitness-score'
5
5
  import { OverviewCards } from '@/components/overview-cards'
6
6
  import { DailyCostChart, TopCommandsChart, TokenUsageHeatmap, UserVsAssistantChart, InterruptionRateChart, ToolMixChart } from '@/components/daily-chart'
7
+ import { VersionLagChart } from '@/components/version-lag-chart'
7
8
  import { RefreshCw } from 'lucide-react'
8
9
 
9
10
  export default function DashboardPage() {
@@ -36,6 +37,7 @@ export default function DashboardPage() {
36
37
  <UserVsAssistantChart daily={data.daily} />
37
38
  <InterruptionRateChart daily={data.daily} />
38
39
  <ToolMixChart daily={data.daily} />
40
+ <VersionLagChart sessions={data.sessions} />
39
41
  </div>
40
42
  </>
41
43
  )
@@ -4,9 +4,10 @@ import { useEffect, useState } from 'react'
4
4
  import { useParams } from 'next/navigation'
5
5
  import Link from 'next/link'
6
6
  import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
7
- import { ArrowLeft, Clock, Coins, MessageSquare, Wrench, CheckCircle, XCircle } from 'lucide-react'
7
+ import { ArrowLeft, Clock, Coins, MessageSquare, Wrench, CheckCircle, XCircle, Sparkles } from 'lucide-react'
8
8
  import { SessionWorkflow } from '@/components/session-workflow'
9
9
  import { SessionChatLog } from '@/components/session-chatlog'
10
+ import { SessionAIAnalysis } from '@/components/session-ai-analysis'
10
11
  import type { SessionDetail } from '@/lib/session-detail'
11
12
 
12
13
  export default function SessionDetailPage() {
@@ -15,7 +16,7 @@ export default function SessionDetailPage() {
15
16
  const [detail, setDetail] = useState<SessionDetail | null>(null)
16
17
  const [loading, setLoading] = useState(true)
17
18
  const [error, setError] = useState<string | null>(null)
18
- const [activeTab, setActiveTab] = useState<'workflow' | 'dialog'>('dialog')
19
+ const [activeTab, setActiveTab] = useState<'dialog' | 'workflow' | 'ai'>('dialog')
19
20
 
20
21
  useEffect(() => {
21
22
  async function load() {
@@ -135,6 +136,17 @@ export default function SessionDetailPage() {
135
136
  >
136
137
  Workflow
137
138
  </button>
139
+ <button
140
+ onClick={() => setActiveTab('ai')}
141
+ className={`rounded-md px-3 py-1.5 text-sm font-medium transition-colors flex items-center gap-1 ${
142
+ activeTab === 'ai'
143
+ ? 'bg-primary text-primary-foreground'
144
+ : 'text-muted-foreground hover:text-foreground'
145
+ }`}
146
+ >
147
+ <Sparkles className="h-3 w-3" />
148
+ AI Analysis
149
+ </button>
138
150
  </div>
139
151
 
140
152
  {activeTab === 'dialog' && (
@@ -162,6 +174,8 @@ export default function SessionDetailPage() {
162
174
  </CardContent>
163
175
  </Card>
164
176
  )}
177
+
178
+ {activeTab === 'ai' && <SessionAIAnalysis sessionId={sessionId} />}
165
179
  </div>
166
180
  )
167
181
  }
@@ -0,0 +1,168 @@
1
+ 'use client'
2
+
3
+ import { useState, useEffect } from 'react'
4
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
5
+ import { Button } from '@/components/ui/button'
6
+ import { Input } from '@/components/ui/input'
7
+ import { Badge } from '@/components/ui/badge'
8
+ import { Key, Eye, EyeOff, CheckCircle, XCircle, Loader2 } from 'lucide-react'
9
+
10
+ export default function SettingsPage() {
11
+ const [apiKey, setApiKey] = useState('')
12
+ const [maskedKey, setMaskedKey] = useState<string | null>(null)
13
+ const [saved, setSaved] = useState(false)
14
+ const [showKey, setShowKey] = useState(false)
15
+ const [testing, setTesting] = useState(false)
16
+ const [testResult, setTestResult] = useState<'valid' | 'invalid' | null>(null)
17
+
18
+ useEffect(() => {
19
+ fetch('/api/config')
20
+ .then((r) => r.json())
21
+ .then((data) => {
22
+ if (data.hasOpenAIKey) {
23
+ setMaskedKey(data.maskedKey)
24
+ setSaved(true)
25
+ }
26
+ })
27
+ .catch(() => {})
28
+ }, [])
29
+
30
+ async function handleSave() {
31
+ if (!apiKey.trim()) return
32
+ try {
33
+ const res = await fetch('/api/config', {
34
+ method: 'POST',
35
+ headers: { 'Content-Type': 'application/json' },
36
+ body: JSON.stringify({ apiKey: apiKey.trim() }),
37
+ })
38
+ if (res.ok) {
39
+ setSaved(true)
40
+ setMaskedKey(`${apiKey.trim().slice(0, 7)}...${apiKey.trim().slice(-4)}`)
41
+ setTestResult(null)
42
+ }
43
+ } catch {
44
+ // ignore
45
+ }
46
+ }
47
+
48
+ async function handleClear() {
49
+ try {
50
+ await fetch('/api/config', {
51
+ method: 'POST',
52
+ headers: { 'Content-Type': 'application/json' },
53
+ body: JSON.stringify({ action: 'clear' }),
54
+ })
55
+ setApiKey('')
56
+ setMaskedKey(null)
57
+ setSaved(false)
58
+ setTestResult(null)
59
+ } catch {
60
+ // ignore
61
+ }
62
+ }
63
+
64
+ async function handleTest() {
65
+ // Save first if not saved
66
+ if (!saved && apiKey.trim()) await handleSave()
67
+
68
+ setTesting(true)
69
+ setTestResult(null)
70
+ try {
71
+ const res = await fetch('/api/analyze', {
72
+ method: 'POST',
73
+ headers: { 'Content-Type': 'application/json' },
74
+ body: JSON.stringify({ test: true }),
75
+ })
76
+ // 401 = no key or invalid key, 400 = key works (missing sessionId is expected)
77
+ setTestResult(res.status === 401 ? 'invalid' : 'valid')
78
+ } catch {
79
+ setTestResult('invalid')
80
+ } finally {
81
+ setTesting(false)
82
+ }
83
+ }
84
+
85
+ return (
86
+ <div className="space-y-6 max-w-2xl">
87
+ <Card>
88
+ <CardHeader>
89
+ <div className="flex items-center gap-2">
90
+ <Key className="h-5 w-5 text-muted-foreground" />
91
+ <div>
92
+ <CardTitle>AI Analysis</CardTitle>
93
+ <CardDescription>
94
+ Configure OpenAI API access for AI-powered message classification
95
+ </CardDescription>
96
+ </div>
97
+ </div>
98
+ </CardHeader>
99
+ <CardContent className="space-y-4">
100
+ <div className="space-y-2">
101
+ <label className="text-sm font-medium">OpenAI API Key</label>
102
+ {saved && !apiKey && maskedKey && (
103
+ <p className="text-sm text-muted-foreground">
104
+ Current key: <code className="text-xs">{maskedKey}</code>
105
+ </p>
106
+ )}
107
+ <div className="flex gap-2">
108
+ <div className="relative flex-1">
109
+ <Input
110
+ type={showKey ? 'text' : 'password'}
111
+ value={apiKey}
112
+ onChange={(e) => {
113
+ setApiKey(e.target.value)
114
+ setSaved(false)
115
+ setTestResult(null)
116
+ }}
117
+ placeholder={saved ? 'Enter new key to replace...' : 'sk-...'}
118
+ />
119
+ <button
120
+ type="button"
121
+ onClick={() => setShowKey(!showKey)}
122
+ className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
123
+ >
124
+ {showKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
125
+ </button>
126
+ </div>
127
+ <Button onClick={handleSave} disabled={!apiKey.trim() || saved} size="sm">
128
+ Save
129
+ </Button>
130
+ {(apiKey || maskedKey) && (
131
+ <Button onClick={handleClear} variant="outline" size="sm">
132
+ Clear
133
+ </Button>
134
+ )}
135
+ </div>
136
+ </div>
137
+
138
+ <div className="flex items-center gap-3">
139
+ <Button
140
+ onClick={handleTest}
141
+ variant="outline"
142
+ size="sm"
143
+ disabled={(!apiKey.trim() && !maskedKey) || testing}
144
+ >
145
+ {testing && <Loader2 className="mr-1 h-3 w-3 animate-spin" />}
146
+ Test Connection
147
+ </Button>
148
+ {testResult === 'valid' && (
149
+ <Badge variant="outline" className="text-green-600 border-green-600">
150
+ <CheckCircle className="mr-1 h-3 w-3" /> Valid
151
+ </Badge>
152
+ )}
153
+ {testResult === 'invalid' && (
154
+ <Badge variant="outline" className="text-destructive border-destructive">
155
+ <XCircle className="mr-1 h-3 w-3" /> Invalid
156
+ </Badge>
157
+ )}
158
+ </div>
159
+
160
+ <p className="text-xs text-muted-foreground">
161
+ Your API key is stored in <code>~/.agentfit/config.json</code> on your machine and never
162
+ leaves your local server. Used with gpt-4.1-mini (~$0.001 per 100 messages).
163
+ </p>
164
+ </CardContent>
165
+ </Card>
166
+ </div>
167
+ )
168
+ }
@@ -0,0 +1,88 @@
1
+ import { NextRequest, NextResponse } from 'next/server'
2
+ import { prisma } from '@/lib/db'
3
+ import type { MessageClassification } from '@/lib/openai'
4
+
5
+ export const dynamic = 'force-dynamic'
6
+
7
+ export async function GET(request: NextRequest) {
8
+ const project = request.nextUrl.searchParams.get('project')
9
+
10
+ try {
11
+ // Get all analyses, optionally filtered by project
12
+ let sessionIds: string[] | undefined
13
+
14
+ if (project) {
15
+ const sessions = await prisma.session.findMany({
16
+ where: { project },
17
+ select: { sessionId: true },
18
+ })
19
+ sessionIds = sessions.map((s) => s.sessionId)
20
+ }
21
+
22
+ const analyses = await prisma.sessionAnalysis.findMany({
23
+ where: sessionIds ? { sessionId: { in: sessionIds } } : undefined,
24
+ })
25
+
26
+ if (analyses.length === 0) {
27
+ return NextResponse.json({ aggregate: null, analyzedCount: 0 })
28
+ }
29
+
30
+ // Aggregate all classifications
31
+ const allClassifications: MessageClassification[] = []
32
+ let totalCostUSD = 0
33
+ let totalInputTokens = 0
34
+ let totalOutputTokens = 0
35
+
36
+ for (const a of analyses) {
37
+ const cls = JSON.parse(a.classifications) as MessageClassification[]
38
+ allClassifications.push(...cls)
39
+ totalCostUSD += a.costUSD
40
+ totalInputTokens += a.inputTokens
41
+ totalOutputTokens += a.outputTokens
42
+ }
43
+
44
+ // Build distributions
45
+ const messageTypes: Record<string, number> = {}
46
+ const roles: Record<string, number> = {}
47
+ const skillLevels: Record<string, number> = {}
48
+ const sentiments: Record<string, number> = {}
49
+
50
+ for (const cls of allClassifications) {
51
+ messageTypes[cls.messageType] = (messageTypes[cls.messageType] || 0) + 1
52
+ roles[cls.role] = (roles[cls.role] || 0) + 1
53
+ skillLevels[cls.skillLevel] = (skillLevels[cls.skillLevel] || 0) + 1
54
+ sentiments[cls.sentiment] = (sentiments[cls.sentiment] || 0) + 1
55
+ }
56
+
57
+ // Cross-tabulations
58
+ const roleByType: Record<string, Record<string, number>> = {}
59
+ const roleBySentiment: Record<string, Record<string, number>> = {}
60
+
61
+ for (const cls of allClassifications) {
62
+ if (!roleByType[cls.role]) roleByType[cls.role] = {}
63
+ roleByType[cls.role][cls.messageType] = (roleByType[cls.role][cls.messageType] || 0) + 1
64
+
65
+ if (!roleBySentiment[cls.role]) roleBySentiment[cls.role] = {}
66
+ roleBySentiment[cls.role][cls.sentiment] =
67
+ (roleBySentiment[cls.role][cls.sentiment] || 0) + 1
68
+ }
69
+
70
+ return NextResponse.json({
71
+ aggregate: {
72
+ totalMessages: allClassifications.length,
73
+ messageTypes,
74
+ roles,
75
+ skillLevels,
76
+ sentiments,
77
+ roleByType,
78
+ roleBySentiment,
79
+ },
80
+ analyzedCount: analyses.length,
81
+ totalCostUSD,
82
+ totalInputTokens,
83
+ totalOutputTokens,
84
+ })
85
+ } catch (error) {
86
+ return NextResponse.json({ error: (error as Error).message }, { status: 500 })
87
+ }
88
+ }