deploy: security hardening, multi-model support, S3 storage, analytics, CI improvements (70 commits) #4
@@ -389,6 +389,78 @@ async def get_analytics(
|
||||
)
|
||||
|
||||
|
||||
@app.get("/analytics/trends", tags=["Analytics"])
|
||||
async def get_analytics_trends(
|
||||
days: int = Query(default=90, ge=7, le=365),
|
||||
_: UserResponse = Depends(get_current_user),
|
||||
):
|
||||
"""Get trend data for patent analysis over time.
|
||||
|
||||
Returns two datasets:
|
||||
- ``by_month``: analysis count per company per month
|
||||
- ``by_type_over_time``: analysis type distribution per month
|
||||
|
||||
Args:
|
||||
days: Number of days to look back (default 90)
|
||||
|
||||
Returns:
|
||||
Trend data suitable for time-series and distribution charts
|
||||
"""
|
||||
db = get_db_client()
|
||||
|
||||
with db.get_conn() as conn:
|
||||
with conn.cursor() as cur:
|
||||
# Analyses per company per month
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
TO_CHAR(timestamp, 'YYYY-MM') AS month,
|
||||
company_name,
|
||||
COUNT(*) AS count
|
||||
FROM llm_messages
|
||||
WHERE timestamp >= NOW() - INTERVAL '%s days'
|
||||
AND is_cached = FALSE
|
||||
AND company_name IS NOT NULL
|
||||
GROUP BY month, company_name
|
||||
ORDER BY month
|
||||
""",
|
||||
(days,),
|
||||
)
|
||||
by_month_rows = cur.fetchall()
|
||||
|
||||
# Analysis type distribution per month
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
TO_CHAR(timestamp, 'YYYY-MM') AS month,
|
||||
analysis_type,
|
||||
COUNT(*) AS count
|
||||
FROM llm_messages
|
||||
WHERE timestamp >= NOW() - INTERVAL '%s days'
|
||||
AND is_cached = FALSE
|
||||
GROUP BY month, analysis_type
|
||||
ORDER BY month
|
||||
""",
|
||||
(days,),
|
||||
)
|
||||
by_type_rows = cur.fetchall()
|
||||
|
||||
by_month = [
|
||||
{"month": row[0], "company_name": row[1], "count": row[2]}
|
||||
for row in by_month_rows
|
||||
]
|
||||
by_type_over_time = [
|
||||
{"month": row[0], "analysis_type": row[1], "count": row[2]}
|
||||
for row in by_type_rows
|
||||
]
|
||||
|
||||
return {
|
||||
"by_month": by_month,
|
||||
"by_type_over_time": by_type_over_time,
|
||||
"period_days": days,
|
||||
}
|
||||
|
||||
|
||||
# ============== System Endpoints ==============
|
||||
|
||||
|
||||
|
||||
@@ -127,11 +127,22 @@ export const analysisApi = {
|
||||
};
|
||||
|
||||
// Analytics API
|
||||
export interface TrendData {
|
||||
by_month: Array<{ month: string; company_name: string; count: number }>;
|
||||
by_type_over_time: Array<{ month: string; analysis_type: string; count: number }>;
|
||||
period_days: number;
|
||||
}
|
||||
|
||||
export const analyticsApi = {
|
||||
getAnalytics: async (days = 30): Promise<Analytics> => {
|
||||
const response = await api.get<Analytics>(`/analytics?days=${days}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getTrends: async (days = 90): Promise<TrendData> => {
|
||||
const response = await api.get<TrendData>(`/analytics/trends?days=${days}`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
// Admin API
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { analyticsApi } from '../api/client';
|
||||
import { AlertCircle, Database } from 'lucide-react';
|
||||
import { PieChart, Pie, Cell, BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Legend } from 'recharts';
|
||||
import { PieChart, Pie, Cell, BarChart, Bar, LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, Legend } from 'recharts';
|
||||
|
||||
const COLORS = ['#6366f1', '#0ea5e9', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6'];
|
||||
|
||||
@@ -14,6 +14,11 @@ export function AnalyticsPage() {
|
||||
queryFn: () => analyticsApi.getAnalytics(days),
|
||||
});
|
||||
|
||||
const trendsQuery = useQuery({
|
||||
queryKey: ['analytics-trends', days],
|
||||
queryFn: () => analyticsApi.getTrends(days),
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
@@ -163,6 +168,114 @@ export function AnalyticsPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Trend Charts */}
|
||||
{trendsQuery.data && (
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-lg font-semibold text-text-primary border-b-2 border-primary/30 pb-2">
|
||||
Trends Over Time
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Patent count over time per company (line chart) */}
|
||||
{trendsQuery.data.by_month.length > 0 && (() => {
|
||||
// Pivot data: each month as a row, companies as columns
|
||||
const companies = [...new Set(trendsQuery.data!.by_month.map(d => d.company_name))];
|
||||
const months = [...new Set(trendsQuery.data!.by_month.map(d => d.month))].sort();
|
||||
const pivoted = months.map(month => {
|
||||
const row: Record<string, string | number> = { month };
|
||||
for (const c of companies) {
|
||||
const entry = trendsQuery.data!.by_month.find(d => d.month === month && d.company_name === c);
|
||||
row[c] = entry?.count || 0;
|
||||
}
|
||||
return row;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="bg-bg-card/60 border border-primary/15 rounded-2xl p-6">
|
||||
<h4 className="text-md font-semibold text-text-primary mb-4">Analyses per Company Over Time</h4>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={pivoted}>
|
||||
<XAxis dataKey="month" stroke="#94a3b8" fontSize={12} />
|
||||
<YAxis stroke="#94a3b8" fontSize={12} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#1e293b',
|
||||
border: '1px solid rgba(99, 102, 241, 0.3)',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
labelStyle={{ color: '#f8fafc' }}
|
||||
/>
|
||||
<Legend />
|
||||
{companies.map((company, idx) => (
|
||||
<Line
|
||||
key={company}
|
||||
type="monotone"
|
||||
dataKey={company}
|
||||
stroke={COLORS[idx % COLORS.length]}
|
||||
strokeWidth={2}
|
||||
dot={{ r: 4 }}
|
||||
name={company.toUpperCase()}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Analysis type distribution over time (stacked bar) */}
|
||||
{trendsQuery.data.by_type_over_time.length > 0 && (() => {
|
||||
const types = [...new Set(trendsQuery.data!.by_type_over_time.map(d => d.analysis_type))];
|
||||
const months = [...new Set(trendsQuery.data!.by_type_over_time.map(d => d.month))].sort();
|
||||
const pivoted = months.map(month => {
|
||||
const row: Record<string, string | number> = { month };
|
||||
for (const t of types) {
|
||||
const entry = trendsQuery.data!.by_type_over_time.find(d => d.month === month && d.analysis_type === t);
|
||||
row[t] = entry?.count || 0;
|
||||
}
|
||||
return row;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="bg-bg-card/60 border border-primary/15 rounded-2xl p-6">
|
||||
<h4 className="text-md font-semibold text-text-primary mb-4">Analysis Types Over Time</h4>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={pivoted}>
|
||||
<XAxis dataKey="month" stroke="#94a3b8" fontSize={12} />
|
||||
<YAxis stroke="#94a3b8" fontSize={12} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#1e293b',
|
||||
border: '1px solid rgba(99, 102, 241, 0.3)',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
labelStyle={{ color: '#f8fafc' }}
|
||||
/>
|
||||
<Legend />
|
||||
{types.map((type, idx) => (
|
||||
<Bar
|
||||
key={type}
|
||||
dataKey={type}
|
||||
stackId="types"
|
||||
fill={COLORS[idx % COLORS.length]}
|
||||
name={type}
|
||||
/>
|
||||
))}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{trendsQuery.data.by_month.length === 0 && (
|
||||
<div className="text-text-secondary text-center py-8">
|
||||
No trend data available yet. Run analyses over multiple days to see trends.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user