M6: working memory + the PO→QA trigger + analytics — V1 complete
Working memory (Memory module's first real code): - MemoryEntry (schema "memory", vector(384), InitialMemory migration); TeamMemory implements the SharedKernel ITeamMemory seam (embed-and-store on write, cosine recall on read); GET /api/memory/search. HashingTextEmbedder promoted to SharedKernel (pure, deterministic; swapped for ONNX/BYOK embedders later behind ITextEmbedder). - Written on approval: Governance's approve stores an Approval/Correction entry per decision. - Read at assembly: the executor recalls the team's top-3 relevant entries; the prompt gains a "# Team memory" section (treated as data, not instructions). The single V1 event trigger: - IAgentDispatcher (SharedKernel) implemented by Assembler's AgentRunDispatcher (shared by the API and triggers). OrgBoard's QaHandoffTrigger: a task hitting done creates a QA task (provenance parent, assigned to the QA agent) and dispatches a run for the team's QA AI seat. Guardrails: Test/Review tasks never re-trigger (no self-cascade) and a task hands off at most once. Audited as handoff.triggered. Analytics — the V1 verdict view: - IBoardStats (SharedKernel) implemented by OrgBoard; GET /api/governance/analytics returns approval rate, avg edit distance, per-agent metrics + edit-distance trend, tasks done. - UI: /analytics — stat cards, per-agent table, recharts edit-distance trend per agent. Verified: build green; ArchitectureTests 8/8; IntegrationTests 42/42 incl. the M6 acceptance end to end — a dev marks a story done → Quill wakes via the handoff (QA task with provenance, assigned to the agent) → drafts a test plan that waits in review → approve records the second agent's edit distance → analytics show approval rate 100%, avg edit distance > 0, and trends for BOTH Aria and Quill; memory written on Aria's corrected approval is recalled into her next prompt; the guardrails hold. Client build green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { Navigate, Route, Routes } from 'react-router'
|
||||
import { Toaster } from '@/components/ui/sonner'
|
||||
import { AnalyticsPage } from '@/pages/AnalyticsPage'
|
||||
import { BoardPage } from '@/pages/BoardPage'
|
||||
import { LoginPage } from '@/pages/LoginPage'
|
||||
import { ReviewsPage } from '@/pages/ReviewsPage'
|
||||
@@ -16,6 +17,7 @@ export default function App() {
|
||||
<Route path="/" element={token ? <BoardPage /> : <Navigate to="/login" replace />} />
|
||||
<Route path="/seats" element={token ? <SeatsPage /> : <Navigate to="/login" replace />} />
|
||||
<Route path="/reviews" element={token ? <ReviewsPage /> : <Navigate to="/login" replace />} />
|
||||
<Route path="/analytics" element={token ? <AnalyticsPage /> : <Navigate to="/login" replace />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
<Toaster richColors position="top-right" />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Link, useLocation } from 'react-router'
|
||||
import { Bot, Inbox, type LucideIcon, LayoutDashboard, LogOut, Network, ShieldCheck } from 'lucide-react'
|
||||
import { Bot, ChartColumn, Inbox, type LucideIcon, LayoutDashboard, LogOut, Network, ShieldCheck } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -29,6 +29,7 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
<NavItem icon={LayoutDashboard} label="Board" to="/" />
|
||||
<NavItem icon={Bot} label="AI seats" to="/seats" />
|
||||
<NavItem icon={ShieldCheck} label="Review inbox" to="/reviews" />
|
||||
<NavItem icon={ChartColumn} label="Analytics" to="/analytics" />
|
||||
<NavItem icon={Inbox} label="Cartable" muted />
|
||||
<NavItem icon={Network} label="Org chart" muted />
|
||||
</nav>
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { CartesianGrid, Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'
|
||||
import { toast } from 'sonner'
|
||||
import { AppShell } from '@/components/AppShell'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { api } from '@/lib/api'
|
||||
import { useAuth } from '@/store/auth'
|
||||
|
||||
interface EditDistancePoint {
|
||||
decidedAtUtc: string
|
||||
distance: number
|
||||
}
|
||||
|
||||
interface AgentAnalytics {
|
||||
agentId: string
|
||||
name: string
|
||||
reviews: number
|
||||
approvalRate: number | null
|
||||
avgEditDistance: number | null
|
||||
trend: EditDistancePoint[]
|
||||
}
|
||||
|
||||
interface Analytics {
|
||||
tasksDone: number
|
||||
pendingReviews: number
|
||||
decided: number
|
||||
approved: number
|
||||
sentBack: number
|
||||
approvalRate: number | null
|
||||
avgEditDistance: number | null
|
||||
agents: AgentAnalytics[]
|
||||
}
|
||||
|
||||
const LINE_COLORS = ['var(--color-seat-ai)', 'var(--color-teal-500, #14b8a6)', '#f59e0b', '#64748b']
|
||||
|
||||
export function AnalyticsPage() {
|
||||
const organizationId = useAuth((s) => s.organizationId)
|
||||
const [data, setData] = useState<Analytics | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!organizationId) return
|
||||
try {
|
||||
setData(await api.get<Analytics>(`/api/governance/analytics?organizationId=${organizationId}`))
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message)
|
||||
}
|
||||
}, [organizationId])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<div className="mx-auto max-w-4xl p-6">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Analytics</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The bet, measured: human edit distance low and falling means the agents are earning trust.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{data === null ? (
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} className="h-24 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
<Stat label="Approval rate" value={formatPercent(data.approvalRate)} />
|
||||
<Stat label="Avg edit distance" value={formatDistance(data.avgEditDistance)} />
|
||||
<Stat label="Tasks done" value={String(data.tasksDone)} />
|
||||
<Stat label="Pending reviews" value={String(data.pendingReviews)} />
|
||||
</div>
|
||||
|
||||
<Card className="mt-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Edit distance per agent</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{data.agents.every((a) => a.trend.length === 0) ? (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">
|
||||
No approvals yet — approve agent work to start the trend.
|
||||
</p>
|
||||
) : (
|
||||
<div className="h-64">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={mergeTrends(data.agents)}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border)" />
|
||||
<XAxis dataKey="time" tick={{ fontSize: 11 }} />
|
||||
<YAxis domain={[0, 1]} tick={{ fontSize: 11 }} />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
{data.agents.map((agent, i) => (
|
||||
<Line
|
||||
key={agent.agentId}
|
||||
type="monotone"
|
||||
dataKey={agent.name}
|
||||
stroke={LINE_COLORS[i % LINE_COLORS.length]}
|
||||
connectNulls
|
||||
dot
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="mt-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Per agent</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{data.agents.length === 0 ? (
|
||||
<p className="py-4 text-center text-sm text-muted-foreground">No agent activity yet.</p>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="py-2 font-medium">Agent</th>
|
||||
<th className="py-2 font-medium">Reviews</th>
|
||||
<th className="py-2 font-medium">Approval rate</th>
|
||||
<th className="py-2 font-medium">Avg edit distance</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.agents.map((agent) => (
|
||||
<tr key={agent.agentId} className="border-b last:border-0">
|
||||
<td className="py-2 font-medium">{agent.name}</td>
|
||||
<td className="py-2">{agent.reviews}</td>
|
||||
<td className="py-2">{formatPercent(agent.approvalRate)}</td>
|
||||
<td className="py-2">{formatDistance(agent.avgEditDistance)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
||||
function Stat({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<div className="text-xs text-muted-foreground">{label}</div>
|
||||
<div className="mt-1 text-2xl font-semibold tracking-tight">{value}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function formatPercent(value: number | null): string {
|
||||
return value === null ? '—' : `${Math.round(value * 100)}%`
|
||||
}
|
||||
|
||||
function formatDistance(value: number | null): string {
|
||||
return value === null ? '—' : value.toFixed(3)
|
||||
}
|
||||
|
||||
function mergeTrends(agents: AgentAnalytics[]): Record<string, string | number>[] {
|
||||
const rows = agents
|
||||
.flatMap((agent) =>
|
||||
agent.trend.map((point) => ({
|
||||
sortKey: point.decidedAtUtc,
|
||||
time: new Date(point.decidedAtUtc).toLocaleDateString(),
|
||||
name: agent.name,
|
||||
distance: point.distance,
|
||||
})),
|
||||
)
|
||||
.sort((a, b) => a.sortKey.localeCompare(b.sortKey))
|
||||
|
||||
return rows.map((row) => ({ time: row.time, [row.name]: row.distance }))
|
||||
}
|
||||
Reference in New Issue
Block a user