feat: wire Analytics tab to real data, add markdown rendering, fix UI labels
- Analytics endpoints now query document_processing_events table instead of returning hardcoded zeros (/documents/analytics, /processing-stats, /health/agentic-rag) - Add react-markdown for rich text rendering in CIM review template (readOnly textarea fields now render markdown formatting) - Fix upload page references from "Firebase Storage" to "Cloud Storage" - Fix financial year labels from "FY3" to "FY-3" in DocumentViewer Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -130,18 +130,8 @@ app.get('/health/config', (_req, res) => {
|
||||
// Agentic RAG health check endpoint (for analytics dashboard)
|
||||
app.get('/health/agentic-rag', async (_req, res) => {
|
||||
try {
|
||||
// Return health status (agentic RAG is not fully implemented)
|
||||
const healthStatus = {
|
||||
status: 'healthy' as const,
|
||||
agents: {},
|
||||
overall: {
|
||||
successRate: 1.0,
|
||||
averageProcessingTime: 0,
|
||||
activeSessions: 0,
|
||||
errorRate: 0
|
||||
},
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
const { getHealthFromEvents } = await import('./services/analyticsService');
|
||||
const healthStatus = await getHealthFromEvents();
|
||||
|
||||
res.json(healthStatus);
|
||||
} catch (error) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import express from 'express';
|
||||
import { verifyFirebaseToken } from '../middleware/firebaseAuth';
|
||||
import { documentController } from '../controllers/documentController';
|
||||
import { unifiedDocumentProcessor } from '../services/unifiedDocumentProcessor';
|
||||
import { getSessionAnalytics, getProcessingStatsFromEvents } from '../services/analyticsService';
|
||||
import { logger } from '../utils/logger';
|
||||
import { config } from '../config/env';
|
||||
import { DocumentModel } from '../models/DocumentModel';
|
||||
@@ -40,18 +41,7 @@ router.get('/analytics', async (req, res) => {
|
||||
}
|
||||
|
||||
const days = parseInt(req.query['days'] as string) || 30;
|
||||
// Return empty analytics data (agentic RAG analytics not fully implemented)
|
||||
const analytics = {
|
||||
totalSessions: 0,
|
||||
successfulSessions: 0,
|
||||
failedSessions: 0,
|
||||
avgQualityScore: 0.8,
|
||||
avgCompleteness: 0.9,
|
||||
avgProcessingTime: 0,
|
||||
sessionsOverTime: [],
|
||||
agentPerformance: [],
|
||||
qualityTrends: []
|
||||
};
|
||||
const analytics = await getSessionAnalytics(days);
|
||||
return res.json({
|
||||
...analytics,
|
||||
correlationId: req.correlationId || undefined
|
||||
@@ -70,7 +60,7 @@ router.get('/analytics', async (req, res) => {
|
||||
|
||||
router.get('/processing-stats', async (req, res) => {
|
||||
try {
|
||||
const stats = await unifiedDocumentProcessor.getProcessingStats();
|
||||
const stats = await getProcessingStatsFromEvents();
|
||||
return res.json({
|
||||
...stats,
|
||||
correlationId: req.correlationId || undefined
|
||||
|
||||
@@ -146,3 +146,188 @@ export async function getAnalyticsSummary(range: string = '24h'): Promise<Analyt
|
||||
generatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// getSessionAnalytics — per-day session stats for Analytics tab
|
||||
// =============================================================================
|
||||
|
||||
export interface SessionAnalytics {
|
||||
sessionStats: Array<{
|
||||
date: string;
|
||||
total_sessions: string;
|
||||
successful_sessions: string;
|
||||
failed_sessions: string;
|
||||
avg_processing_time: string;
|
||||
avg_cost: string;
|
||||
}>;
|
||||
agentStats: Array<Record<string, string>>;
|
||||
qualityStats: Array<Record<string, string>>;
|
||||
period: {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
days: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns per-day session statistics from document_processing_events.
|
||||
* Groups upload_started events by date, then joins completed/failed counts.
|
||||
*/
|
||||
export async function getSessionAnalytics(days: number): Promise<SessionAnalytics> {
|
||||
const pool = getPostgresPool();
|
||||
const interval = `${days} days`;
|
||||
|
||||
const { rows } = await pool.query<{
|
||||
date: string;
|
||||
total_sessions: string;
|
||||
successful_sessions: string;
|
||||
failed_sessions: string;
|
||||
avg_processing_time: string;
|
||||
}>(`
|
||||
SELECT
|
||||
DATE(created_at) AS date,
|
||||
COUNT(*) FILTER (WHERE event_type = 'upload_started') AS total_sessions,
|
||||
COUNT(*) FILTER (WHERE event_type = 'completed') AS successful_sessions,
|
||||
COUNT(*) FILTER (WHERE event_type = 'failed') AS failed_sessions,
|
||||
COALESCE(AVG(duration_ms) FILTER (WHERE event_type = 'completed'), 0) AS avg_processing_time
|
||||
FROM document_processing_events
|
||||
WHERE created_at >= NOW() - $1::interval
|
||||
GROUP BY DATE(created_at)
|
||||
ORDER BY date DESC
|
||||
`, [interval]);
|
||||
|
||||
const endDate = new Date();
|
||||
const startDate = new Date(Date.now() - days * 86400000);
|
||||
|
||||
return {
|
||||
sessionStats: rows.map(r => ({
|
||||
date: r.date,
|
||||
total_sessions: r.total_sessions,
|
||||
successful_sessions: r.successful_sessions,
|
||||
failed_sessions: r.failed_sessions,
|
||||
avg_processing_time: r.avg_processing_time,
|
||||
avg_cost: '0', // cost tracking not implemented
|
||||
})),
|
||||
agentStats: [], // agent-level tracking not available in current schema
|
||||
qualityStats: [], // quality scores not available in current schema
|
||||
period: {
|
||||
startDate: startDate.toISOString(),
|
||||
endDate: endDate.toISOString(),
|
||||
days,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// getProcessingStatsFromEvents — processing pipeline stats for Analytics tab
|
||||
// =============================================================================
|
||||
|
||||
export interface ProcessingStatsFromEvents {
|
||||
totalDocuments: number;
|
||||
documentAiAgenticRagSuccess: number;
|
||||
averageProcessingTime: { documentAiAgenticRag: number };
|
||||
averageApiCalls: { documentAiAgenticRag: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns processing pipeline statistics from document_processing_events.
|
||||
*/
|
||||
export async function getProcessingStatsFromEvents(): Promise<ProcessingStatsFromEvents> {
|
||||
const pool = getPostgresPool();
|
||||
|
||||
const { rows } = await pool.query<{
|
||||
total_documents: string;
|
||||
succeeded: string;
|
||||
avg_processing_ms: string | null;
|
||||
}>(`
|
||||
SELECT
|
||||
COUNT(DISTINCT document_id) AS total_documents,
|
||||
COUNT(*) FILTER (WHERE event_type = 'completed') AS succeeded,
|
||||
AVG(duration_ms) FILTER (WHERE event_type = 'completed') AS avg_processing_ms
|
||||
FROM document_processing_events
|
||||
`);
|
||||
|
||||
const row = rows[0]!;
|
||||
|
||||
return {
|
||||
totalDocuments: parseInt(row.total_documents, 10),
|
||||
documentAiAgenticRagSuccess: parseInt(row.succeeded, 10),
|
||||
averageProcessingTime: {
|
||||
documentAiAgenticRag: row.avg_processing_ms ? parseFloat(row.avg_processing_ms) : 0,
|
||||
},
|
||||
averageApiCalls: {
|
||||
documentAiAgenticRag: 0, // API call counting not tracked in events table
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// getHealthFromEvents — system health status for Analytics tab
|
||||
// =============================================================================
|
||||
|
||||
export interface HealthFromEvents {
|
||||
status: 'healthy' | 'degraded' | 'unhealthy';
|
||||
agents: Record<string, unknown>;
|
||||
overall: {
|
||||
successRate: number;
|
||||
averageProcessingTime: number;
|
||||
activeSessions: number;
|
||||
errorRate: number;
|
||||
};
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives system health status from recent document_processing_events.
|
||||
* Looks at the last 24 hours to determine health.
|
||||
*/
|
||||
export async function getHealthFromEvents(): Promise<HealthFromEvents> {
|
||||
const pool = getPostgresPool();
|
||||
|
||||
const { rows } = await pool.query<{
|
||||
total: string;
|
||||
succeeded: string;
|
||||
failed: string;
|
||||
avg_processing_ms: string | null;
|
||||
active: string;
|
||||
}>(`
|
||||
SELECT
|
||||
COUNT(*) AS total,
|
||||
COUNT(*) FILTER (WHERE event_type = 'completed') AS succeeded,
|
||||
COUNT(*) FILTER (WHERE event_type = 'failed') AS failed,
|
||||
AVG(duration_ms) FILTER (WHERE event_type = 'completed') AS avg_processing_ms,
|
||||
COUNT(*) FILTER (
|
||||
WHERE event_type = 'processing_started'
|
||||
AND document_id NOT IN (
|
||||
SELECT document_id FROM document_processing_events
|
||||
WHERE event_type IN ('completed', 'failed')
|
||||
AND created_at >= NOW() - INTERVAL '24 hours'
|
||||
)
|
||||
) AS active
|
||||
FROM document_processing_events
|
||||
WHERE created_at >= NOW() - INTERVAL '24 hours'
|
||||
`);
|
||||
|
||||
const row = rows[0]!;
|
||||
const total = parseInt(row.total, 10);
|
||||
const succeeded = parseInt(row.succeeded, 10);
|
||||
const failed = parseInt(row.failed, 10);
|
||||
const successRate = total > 0 ? succeeded / total : 1.0;
|
||||
const errorRate = total > 0 ? failed / total : 0;
|
||||
|
||||
let status: 'healthy' | 'degraded' | 'unhealthy' = 'healthy';
|
||||
if (errorRate > 0.5) status = 'unhealthy';
|
||||
else if (errorRate > 0.2) status = 'degraded';
|
||||
|
||||
return {
|
||||
status,
|
||||
agents: {},
|
||||
overall: {
|
||||
successRate,
|
||||
averageProcessingTime: row.avg_processing_ms ? parseFloat(row.avg_processing_ms) : 0,
|
||||
activeSessions: parseInt(row.active, 10),
|
||||
errorRate,
|
||||
},
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
1181
frontend/package-lock.json
generated
1181
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -21,6 +21,7 @@
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-dropzone": "^14.3.8",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^6.20.1",
|
||||
"tailwind-merge": "^2.0.0"
|
||||
},
|
||||
|
||||
@@ -35,18 +35,12 @@ interface AnalyticsData {
|
||||
|
||||
interface ProcessingStats {
|
||||
totalDocuments: number;
|
||||
chunkingSuccess: number;
|
||||
ragSuccess: number;
|
||||
agenticRagSuccess: number;
|
||||
documentAiAgenticRagSuccess: number;
|
||||
averageProcessingTime: {
|
||||
chunking: number;
|
||||
rag: number;
|
||||
agenticRag: number;
|
||||
documentAiAgenticRag: number;
|
||||
};
|
||||
averageApiCalls: {
|
||||
chunking: number;
|
||||
rag: number;
|
||||
agenticRag: number;
|
||||
documentAiAgenticRag: number;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -208,29 +202,21 @@ const Analytics: React.FC = () => {
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Processing Statistics</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-md font-medium text-gray-700">Success Rates</h3>
|
||||
<h3 className="text-md font-medium text-gray-700">Documents Processed</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-gray-600">Chunking</span>
|
||||
<span className="text-sm font-medium">
|
||||
{processingStats.totalDocuments > 0
|
||||
? (((processingStats.chunkingSuccess ?? 0) / processingStats.totalDocuments) * 100).toFixed(1)
|
||||
: 0}%
|
||||
</span>
|
||||
<span className="text-sm text-gray-600">Total Documents</span>
|
||||
<span className="text-sm font-medium">{processingStats.totalDocuments}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-gray-600">RAG</span>
|
||||
<span className="text-sm font-medium">
|
||||
{processingStats.totalDocuments > 0
|
||||
? (((processingStats.ragSuccess ?? 0) / processingStats.totalDocuments) * 100).toFixed(1)
|
||||
: 0}%
|
||||
</span>
|
||||
<span className="text-sm text-gray-600">Successful</span>
|
||||
<span className="text-sm font-medium text-success-600">{processingStats.documentAiAgenticRagSuccess ?? 0}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-gray-600">Agentic RAG</span>
|
||||
<span className="text-sm text-gray-600">Success Rate</span>
|
||||
<span className="text-sm font-medium">
|
||||
{processingStats.totalDocuments > 0
|
||||
? (((processingStats.agenticRagSuccess ?? 0) / processingStats.totalDocuments) * 100).toFixed(1)
|
||||
? (((processingStats.documentAiAgenticRagSuccess ?? 0) / processingStats.totalDocuments) * 100).toFixed(1)
|
||||
: 0}%
|
||||
</span>
|
||||
</div>
|
||||
@@ -240,33 +226,17 @@ const Analytics: React.FC = () => {
|
||||
<h3 className="text-md font-medium text-gray-700">Average Processing Time</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-gray-600">Chunking</span>
|
||||
<span className="text-sm font-medium">{formatTime(processingStats.averageProcessingTime?.chunking ?? 0)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-gray-600">RAG</span>
|
||||
<span className="text-sm font-medium">{formatTime(processingStats.averageProcessingTime?.rag ?? 0)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-gray-600">Agentic RAG</span>
|
||||
<span className="text-sm font-medium">{formatTime(processingStats.averageProcessingTime?.agenticRag ?? 0)}</span>
|
||||
<span className="text-sm text-gray-600">Document AI + Agentic RAG</span>
|
||||
<span className="text-sm font-medium">{formatTime(processingStats.averageProcessingTime?.documentAiAgenticRag ?? 0)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-md font-medium text-gray-700">Average API Calls</h3>
|
||||
<h3 className="text-md font-medium text-gray-700">Pipeline</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-gray-600">Chunking</span>
|
||||
<span className="text-sm font-medium">{(processingStats.averageApiCalls?.chunking ?? 0).toFixed(1)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-gray-600">RAG</span>
|
||||
<span className="text-sm font-medium">{(processingStats.averageApiCalls?.rag ?? 0).toFixed(1)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-gray-600">Agentic RAG</span>
|
||||
<span className="text-sm font-medium">{(processingStats.averageApiCalls?.agenticRag ?? 0).toFixed(1)}</span>
|
||||
<span className="text-sm text-gray-600">Processing Method</span>
|
||||
<span className="text-sm font-medium">Document AI + Agentic RAG</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Markdown from 'react-markdown';
|
||||
import { Save, Download } from 'lucide-react';
|
||||
import { cn } from '../utils/cn';
|
||||
|
||||
@@ -341,17 +342,23 @@ const CIMReviewTemplate: React.FC<CIMReviewTemplateProps> = ({
|
||||
{label}
|
||||
</label>
|
||||
{type === 'textarea' ? (
|
||||
<textarea
|
||||
value={value || ''}
|
||||
onChange={(e) => {
|
||||
updateNestedField(e.target.value);
|
||||
triggerAutoSave();
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
rows={rows || 3}
|
||||
disabled={readOnly}
|
||||
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm disabled:bg-gray-50 disabled:text-gray-500"
|
||||
/>
|
||||
readOnly && value ? (
|
||||
<div className="block w-full rounded-md border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-700 prose prose-sm max-w-none prose-p:my-1 prose-ul:my-1 prose-li:my-0">
|
||||
<Markdown>{value}</Markdown>
|
||||
</div>
|
||||
) : (
|
||||
<textarea
|
||||
value={value || ''}
|
||||
onChange={(e) => {
|
||||
updateNestedField(e.target.value);
|
||||
triggerAutoSave();
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
rows={rows || 3}
|
||||
disabled={readOnly}
|
||||
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm disabled:bg-gray-50 disabled:text-gray-500"
|
||||
/>
|
||||
)
|
||||
) : type === 'date' ? (
|
||||
<input
|
||||
type="date"
|
||||
|
||||
@@ -321,16 +321,16 @@ const DocumentUpload: React.FC<DocumentUploadProps> = ({
|
||||
const getStatusText = (status: UploadedFile['status'], error?: string, storageError?: boolean) => {
|
||||
switch (status) {
|
||||
case 'uploading':
|
||||
return 'Uploading to Firebase Storage...';
|
||||
return 'Uploading to Cloud Storage...';
|
||||
case 'uploaded':
|
||||
return 'Uploaded to Firebase Storage ✓';
|
||||
return 'Uploaded to Cloud Storage ✓';
|
||||
case 'processing':
|
||||
return 'Processing with Document AI + Optimized Agentic RAG...';
|
||||
case 'completed':
|
||||
return 'Completed ✓ (PDF automatically deleted)';
|
||||
case 'error':
|
||||
if (error === 'Upload cancelled') return 'Cancelled';
|
||||
if (storageError) return 'Firebase Storage Error';
|
||||
if (storageError) return 'Cloud Storage Error';
|
||||
return 'Error';
|
||||
default:
|
||||
return '';
|
||||
@@ -372,7 +372,7 @@ const DocumentUpload: React.FC<DocumentUploadProps> = ({
|
||||
Drag and drop PDF files here, or click to browse
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
Maximum file size: 50MB • Supported format: PDF • Stored securely in Firebase Storage • Automatic Document AI + Optimized Agentic RAG Processing • PDFs deleted after processing
|
||||
Maximum file size: 50MB • Supported format: PDF • Stored securely in Cloud Storage • Automatic Document AI + Optimized Agentic RAG Processing • PDFs deleted after processing
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -400,7 +400,7 @@ const DocumentUpload: React.FC<DocumentUploadProps> = ({
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-success-800">Upload Complete</h4>
|
||||
<p className="text-sm text-success-700 mt-1">
|
||||
Files have been uploaded successfully to Firebase Storage! You can now navigate away from this page.
|
||||
Files have been uploaded successfully! You can now navigate away from this page.
|
||||
Processing will continue in the background using Document AI + Optimized Agentic RAG. This can take several minutes. PDFs will be automatically deleted after processing to save costs.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -296,7 +296,7 @@ ${user?.name || user?.email || 'CIM Document Processor User'}`);
|
||||
<div className="space-y-1">
|
||||
{extractedData.financials.revenue.map((value, index) => (
|
||||
<div key={index} className="flex justify-between text-sm">
|
||||
<span className="text-gray-600">{index === 3 ? 'LTM' : `FY${3-index}`}</span>
|
||||
<span className="text-gray-600">{index === 3 ? 'LTM' : `FY-${3-index}`}</span>
|
||||
<span className="font-medium">{value}</span>
|
||||
</div>
|
||||
))}
|
||||
@@ -307,7 +307,7 @@ ${user?.name || user?.email || 'CIM Document Processor User'}`);
|
||||
<div className="space-y-1">
|
||||
{extractedData.financials.ebitda.map((value, index) => (
|
||||
<div key={index} className="flex justify-between text-sm">
|
||||
<span className="text-gray-600">{index === 3 ? 'LTM' : `FY${3-index}`}</span>
|
||||
<span className="text-gray-600">{index === 3 ? 'LTM' : `FY-${3-index}`}</span>
|
||||
<span className="font-medium">{value}</span>
|
||||
</div>
|
||||
))}
|
||||
@@ -318,7 +318,7 @@ ${user?.name || user?.email || 'CIM Document Processor User'}`);
|
||||
<div className="space-y-1">
|
||||
{extractedData.financials.margins.map((value, index) => (
|
||||
<div key={index} className="flex justify-between text-sm">
|
||||
<span className="text-gray-600">{index === 3 ? 'LTM' : `FY${3-index}`}</span>
|
||||
<span className="text-gray-600">{index === 3 ? 'LTM' : `FY-${3-index}`}</span>
|
||||
<span className="font-medium">{value}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user