Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 68 additions & 1 deletion bookstack_agent/api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@

import asyncio
import json
import logging
import os
import time
import uuid
from collections import OrderedDict
from collections.abc import AsyncGenerator
Expand All @@ -41,8 +43,11 @@
from pydantic import BaseModel, Field

from aieng_bot.bookstack import BookstackQAAgent
from aieng_bot.bookstack.activity_logger import BookstackActivityLogger
from aieng_bot.bookstack.agent import MessageHistory

api_logger = logging.getLogger(__name__)

load_dotenv()

MAX_SESSIONS = 500 # prune oldest sessions beyond this limit
Expand Down Expand Up @@ -78,6 +83,9 @@ async def lifespan(application: FastAPI) -> AsyncGenerator[None, None]:
application.state.sessions = OrderedDict()
application.state.session_locks = {}

# Analytics logger — initialised lazily; failures are non-fatal
application.state.activity_logger = BookstackActivityLogger()

yield


Expand Down Expand Up @@ -158,6 +166,35 @@ def _get_session_lock(session_id: str) -> asyncio.Lock:
return locks[session_id]


# ---------------------------------------------------------------------------
# Analytics helpers
# ---------------------------------------------------------------------------


async def _log_query_bg(
activity_logger: BookstackActivityLogger,
session_id: str,
question: str,
tool_calls: list[dict[str, Any]],
answer: str,
duration_seconds: float,
status: str,
) -> None:
"""Run analytics logging in a thread pool (non-blocking)."""
try:
await asyncio.to_thread(
activity_logger.log_query,
session_id,
question,
tool_calls,
answer,
duration_seconds,
status,
)
except Exception as exc: # noqa: BLE001
api_logger.warning("Analytics logging failed (non-fatal): %s", exc)


# ---------------------------------------------------------------------------
# Models
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -207,6 +244,12 @@ async def event_stream() -> AsyncGenerator[str, None]:
sid, history = _get_or_create_session(request.session_id)
lock = _get_session_lock(sid)

# Analytics accumulators
start_time = time.monotonic()
query_tool_calls: list[dict[str, Any]] = []
final_answer = ""
final_status = "error"

# Emit the session ID immediately so the client can store it
yield f"data: {json.dumps({'type': 'session', 'session_id': sid})}\n\n"

Expand All @@ -216,18 +259,42 @@ async def event_stream() -> AsyncGenerator[str, None]:
async for event in agent.ask_stream(request.question, history=history):
event_type = event.get("type")

if event_type == "answer":
if event_type == "tool_use":
query_tool_calls.append(
{"tool": event.get("tool", ""), "input": event.get("input", {})}
)
yield f"data: {json.dumps(event)}\n\n"

elif event_type == "answer":
updated_history = event.pop("history", history)
final_answer = event.get("text", "")
final_status = "success"
yield f"data: {json.dumps(event)}\n\n"

elif event_type == "error":
final_status = "error"
yield f"data: {json.dumps(event)}\n\n"

else:
yield f"data: {json.dumps(event)}\n\n"

_save_session(sid, updated_history)

# Fire analytics logging asynchronously — does not block the stream
duration = time.monotonic() - start_time
activity_logger: BookstackActivityLogger = app.state.activity_logger
asyncio.create_task(
_log_query_bg(
activity_logger=activity_logger,
session_id=sid,
question=request.question,
tool_calls=query_tool_calls,
answer=final_answer,
duration_seconds=duration,
status=final_status,
)
)

yield "data: [DONE]\n\n"

return StreamingResponse(
Expand Down
107 changes: 107 additions & 0 deletions bookstack_agent/ui/app/analytics/components/query-metrics.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { MessageSquare, Users, Clock, Wrench, TrendingUp, CheckCircle } from 'lucide-react'
import type { BookstackMetrics } from '@/lib/bookstack-types'

interface MetricCardProps {
label: string
value: string | number
sub?: string
icon: React.ReactNode
accent?: string
}

function MetricCard({ label, value, sub, icon, accent = 'from-vector-magenta to-vector-violet' }: MetricCardProps) {
return (
<div className="rounded-xl border border-white/10 bg-slate-800/60 p-5 flex flex-col gap-3">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wider">{label}</span>
<div className={`w-8 h-8 rounded-lg bg-gradient-to-br ${accent} flex items-center justify-center`}>
{icon}
</div>
</div>
<div>
<p className="text-3xl font-bold text-white">{value}</p>
{sub && <p className="text-xs text-slate-400 mt-1">{sub}</p>}
</div>
</div>
)
}

interface QueryMetricsProps {
metrics: BookstackMetrics
}

export default function QueryMetrics({ metrics }: QueryMetricsProps) {
const successPct = Math.round(metrics.success_rate * 100)

return (
<div className="space-y-4">
<div>
<h2 className="text-xl font-bold text-white">Overview</h2>
<p className="text-sm text-slate-400 mt-0.5">All-time query statistics</p>
</div>

<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
<MetricCard
label="Total Queries"
value={metrics.total_queries.toLocaleString()}
sub={`${metrics.queries_today} today`}
icon={<MessageSquare className="w-4 h-4 text-white" />}
accent="from-vector-magenta to-vector-violet"
/>
<MetricCard
label="This Week"
value={metrics.queries_this_week.toLocaleString()}
sub="last 7 days"
icon={<TrendingUp className="w-4 h-4 text-white" />}
accent="from-vector-violet to-vector-cobalt"
/>
<MetricCard
label="Unique Sessions"
value={metrics.unique_sessions.toLocaleString()}
sub="distinct conversations"
icon={<Users className="w-4 h-4 text-white" />}
accent="from-vector-cobalt to-vector-violet"
/>
<MetricCard
label="Success Rate"
value={`${successPct}%`}
sub={`${metrics.successful_queries} answered`}
icon={<CheckCircle className="w-4 h-4 text-white" />}
accent="from-emerald-500 to-teal-600"
/>
<MetricCard
label="Avg Duration"
value={`${metrics.avg_duration_seconds.toFixed(1)}s`}
sub="per query"
icon={<Clock className="w-4 h-4 text-white" />}
accent="from-amber-500 to-orange-600"
/>
<MetricCard
label="Tool Calls"
value={metrics.total_tool_calls.toLocaleString()}
sub={`${metrics.avg_tool_calls_per_query.toFixed(1)} avg / query`}
icon={<Wrench className="w-4 h-4 text-white" />}
accent="from-purple-500 to-pink-600"
/>
</div>

{/* Success / error bar */}
{metrics.total_queries > 0 && (
<div className="rounded-xl border border-white/10 bg-slate-800/60 p-5">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-semibold text-slate-300">Answer Rate</span>
<span className="text-sm text-slate-400">
{metrics.successful_queries} answered · {metrics.error_queries} errored
</span>
</div>
<div className="h-2 rounded-full bg-slate-700 overflow-hidden">
<div
className="h-full rounded-full bg-gradient-to-r from-emerald-500 to-teal-400 transition-all duration-500"
style={{ width: `${successPct}%` }}
/>
</div>
</div>
)}
</div>
)
}
115 changes: 115 additions & 0 deletions bookstack_agent/ui/app/analytics/components/query-velocity-chart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
'use client'

import {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Legend,
} from 'recharts'

type ChartPoint = {
date: string
success: number
error: number
total: number
}

const CHART_CONFIG = {
grid: { strokeDasharray: '3 3', stroke: '#334155', opacity: 0.4 },
axis: { stroke: '#64748b', style: { fontSize: '11px' }, tickLine: false },
tooltip: {
contentStyle: {
backgroundColor: '#1e293b',
border: 'none',
borderRadius: '8px',
color: '#fff',
padding: '8px 12px',
},
labelStyle: { color: '#94a3b8', marginBottom: '4px' },
},
}

function shouldShowLabel(index: number, total: number): boolean {
if (total <= 7) return true
if (total <= 14) return index % 2 === 0
if (total <= 30) return index % 3 === 0
if (total <= 45) return index % 5 === 0
return index % 7 === 0
}

export default function QueryVelocityChart({ data }: { data: ChartPoint[] }) {
const maxVal = data.length > 0 ? Math.max(...data.map(d => d.total)) : 0
const yMax = maxVal <= 10 ? 10 : maxVal <= 20 ? 20 : Math.ceil(maxVal * 1.2 / 5) * 5

return (
<div className="rounded-xl border border-white/10 bg-slate-800/60 p-6">
<div className="mb-5">
<h2 className="text-xl font-bold text-white">Query Velocity</h2>
<p className="text-sm text-slate-400 mt-0.5">Queries answered per day (last 90 days)</p>
</div>

{data.length === 0 ? (
<div className="h-64 flex items-center justify-center text-slate-500 text-sm">
No data available yet
</div>
) : (
<div className="h-72">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={data} margin={{ top: 5, right: 20, left: 0, bottom: 5 }}>
<defs>
<linearGradient id="bsColorSuccess" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#8A25C9" stopOpacity={0.8} />
<stop offset="95%" stopColor="#8A25C9" stopOpacity={0.05} />
</linearGradient>
<linearGradient id="bsColorError" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#EB088A" stopOpacity={0.6} />
<stop offset="95%" stopColor="#EB088A" stopOpacity={0.05} />
</linearGradient>
</defs>
<CartesianGrid {...CHART_CONFIG.grid} />
<XAxis
dataKey="date"
{...CHART_CONFIG.axis}
interval="preserveStartEnd"
tick={(props) => {
const { x, y, payload, index } = props as { x: number; y: number; payload: { value: string }; index: number }
if (index === 0 || index === data.length - 1 || shouldShowLabel(index, data.length)) {
return (
<text x={x} y={y + 10} fill="#64748b" fontSize="11px" textAnchor="middle">
{payload.value}
</text>
)
}
return <g />
}}
/>
<YAxis {...CHART_CONFIG.axis} domain={[0, yMax]} allowDecimals={false} />
<Tooltip {...CHART_CONFIG.tooltip} />
<Legend wrapperStyle={{ fontSize: '12px' }} />
<Area
type="linear"
dataKey="success"
name="Answered"
stroke="#8A25C9"
strokeWidth={2}
fill="url(#bsColorSuccess)"
/>
<Area
type="linear"
dataKey="error"
name="Error"
stroke="#EB088A"
strokeWidth={2}
fill="url(#bsColorError)"
/>
</AreaChart>
</ResponsiveContainer>
</div>
)}
</div>
)
}
Loading
Loading