feat: add independent AI insights to analytics pages
This commit is contained in:
@@ -15,6 +15,59 @@ export type GrowthReport = components['schemas']['GrowthReport'];
|
||||
export type CreateGrowthReportRequest =
|
||||
operations['createGrowthReport']['requestBody']['content']['application/json'];
|
||||
|
||||
export type AIInsightModule = 'overview' | 'payments' | 'fees' | 'peers' | 'transactions' | 'growth';
|
||||
export type AIInsightNarrative = { headline: string; body: string; evidenceIds: string[] };
|
||||
export type AIInsight = {
|
||||
insightId: string;
|
||||
module: AIInsightModule;
|
||||
revision: number;
|
||||
status: 'READY' | 'PARTIAL';
|
||||
scope: { terminalId?: string | null };
|
||||
asOfDate: string;
|
||||
dataThroughDate: string;
|
||||
forecastStartDate: string;
|
||||
freshness: 'FRESH' | 'LAGGING' | 'STALE';
|
||||
dataLagDays: number;
|
||||
confidence: 'HIGH' | 'MEDIUM' | 'LOW' | 'INSUFFICIENT_DATA';
|
||||
copy: {
|
||||
past: AIInsightNarrative;
|
||||
latestObserved: AIInsightNarrative;
|
||||
future7Days: AIInsightNarrative;
|
||||
future30Days: AIInsightNarrative;
|
||||
future90Days?: AIInsightNarrative;
|
||||
findings: Array<{
|
||||
evidenceId: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
scoreBps: number;
|
||||
confidence: string;
|
||||
}>;
|
||||
options: Array<{ title: string; description: string; evidenceIds: string[] }>;
|
||||
};
|
||||
evidence: Record<string, unknown>;
|
||||
calendarContext: Array<{
|
||||
eventKey: string;
|
||||
titleFa: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
tone: string;
|
||||
commercialPosture: string;
|
||||
officialHoliday: boolean;
|
||||
evidenceSupported: boolean;
|
||||
overlapConfounded: boolean;
|
||||
}>;
|
||||
refreshState: { state: string; jobId?: string; eventsUrl?: string; nextRefreshAt?: string };
|
||||
generatedAt: string;
|
||||
};
|
||||
export type AIInsightJob = {
|
||||
jobId: string;
|
||||
snapshotId: string;
|
||||
state: string;
|
||||
eventsUrl: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
const apiBase = (import.meta.env.PUBLIC_API_BASE_URL || '/api/v1').replace(/\/$/, '');
|
||||
|
||||
export class ApiError extends Error {
|
||||
@@ -139,6 +192,20 @@ export const api = {
|
||||
growthReport(reportId: string) {
|
||||
return request<GrowthReport>(`/analytics/growth-reports/${encodeURIComponent(reportId)}`);
|
||||
},
|
||||
latestAIInsight(module: AIInsightModule, terminalId?: string) {
|
||||
const query = terminalId ? `?terminalId=${encodeURIComponent(terminalId)}` : '';
|
||||
return request<AIInsight>(`/analytics/ai-insights/${module}${query}`);
|
||||
},
|
||||
refreshAIInsight(module: AIInsightModule, csrfToken: string, terminalId?: string) {
|
||||
return request<AIInsightJob>(`/analytics/ai-insights/${module}/refreshes`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': csrfToken, 'Idempotency-Key': crypto.randomUUID() },
|
||||
body: JSON.stringify(terminalId ? { terminalId } : {}),
|
||||
});
|
||||
},
|
||||
aiInsightJob(jobId: string) {
|
||||
return request<AIInsightJob>(`/analytics/ai-insight-refreshes/${encodeURIComponent(jobId)}`);
|
||||
},
|
||||
exportUrl(
|
||||
periodId: string,
|
||||
view: DetailView,
|
||||
|
||||
3837
src/lib/api/schema.d.ts
vendored
3837
src/lib/api/schema.d.ts
vendored
File diff suppressed because it is too large
Load Diff
199
src/lib/components/AIInsightPanel.svelte
Normal file
199
src/lib/components/AIInsightPanel.svelte
Normal file
@@ -0,0 +1,199 @@
|
||||
<script lang="ts">
|
||||
import BrainCircuitIcon from '@lucide/svelte/icons/brain-circuit';
|
||||
import CalendarDaysIcon from '@lucide/svelte/icons/calendar-days';
|
||||
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
|
||||
import RefreshCcwIcon from '@lucide/svelte/icons/refresh-ccw';
|
||||
|
||||
import { ApiError, api, type AIInsight, type AIInsightModule } from '$lib/api/client';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
||||
import { Skeleton } from '$lib/components/ui/skeleton';
|
||||
import { useDashboardContext } from '$lib/dashboard-context.svelte';
|
||||
import { formatDate } from '$lib/format';
|
||||
|
||||
let { module, terminalId }: { module: AIInsightModule; terminalId?: string } = $props();
|
||||
const dashboard = useDashboardContext();
|
||||
let insight = $state<AIInsight | null>(null);
|
||||
let loading = $state(true);
|
||||
let refreshing = $state(false);
|
||||
let error = $state('');
|
||||
let sequence = 0;
|
||||
|
||||
const confidenceLabel = $derived(
|
||||
(
|
||||
{
|
||||
HIGH: 'اطمینان بالا',
|
||||
MEDIUM: 'اطمینان متوسط',
|
||||
LOW: 'اطمینان پایین',
|
||||
INSUFFICIENT_DATA: 'داده ناکافی',
|
||||
} as const
|
||||
)[insight?.confidence ?? 'INSUFFICIENT_DATA'],
|
||||
);
|
||||
|
||||
async function load() {
|
||||
const current = ++sequence;
|
||||
loading = !insight;
|
||||
error = '';
|
||||
try {
|
||||
const next = await api.latestAIInsight(module, terminalId);
|
||||
if (current === sequence) insight = next;
|
||||
} catch (cause) {
|
||||
if (current !== sequence) return;
|
||||
error =
|
||||
cause instanceof ApiError && cause.status === 404
|
||||
? 'هنوز تحلیلی برای این محدوده آماده نشده است.'
|
||||
: 'بخش تحلیل هوشمند در دسترس نیست؛ سایر اطلاعات صفحه همچنان قابل استفادهاند.';
|
||||
} finally {
|
||||
if (current === sequence) loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
if (!dashboard.session || refreshing) return;
|
||||
refreshing = true;
|
||||
error = '';
|
||||
try {
|
||||
const job = await api.refreshAIInsight(module, dashboard.session.csrfToken, terminalId);
|
||||
for (let attempt = 0; attempt < 45; attempt += 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
const current = await api.aiInsightJob(job.jobId);
|
||||
if (current.state === 'COMPLETED') {
|
||||
await load();
|
||||
return;
|
||||
}
|
||||
if (current.state === 'FAILED') throw new Error('job failed');
|
||||
}
|
||||
error = 'ساخت نسخه تازه ادامه دارد؛ آخرین تحلیل معتبر همچنان نمایش داده میشود.';
|
||||
} catch (cause) {
|
||||
error =
|
||||
cause instanceof ApiError && cause.status === 429
|
||||
? 'سهم بهروزرسانی امروز استفاده شده است؛ نسخه ذخیرهشده همچنان نمایش داده میشود.'
|
||||
: 'نسخه تازه آماده نشد؛ سایر بخشهای صفحه تحتتأثیر قرار نگرفتند.';
|
||||
} finally {
|
||||
refreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
module;
|
||||
terminalId;
|
||||
insight = null;
|
||||
void load();
|
||||
});
|
||||
|
||||
function eventTone(tone: string) {
|
||||
return tone === 'MOURNING' || tone === 'MEMORIAL'
|
||||
? 'border-slate-300 bg-slate-50 text-slate-700 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300'
|
||||
: 'border-primary/20 bg-primary/5 text-foreground';
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="my-4" aria-label="تحلیل هوشمند" aria-busy={loading || refreshing}>
|
||||
{#if loading}
|
||||
<Card class="rounded-lg"
|
||||
><CardContent class="space-y-3 p-5" role="status" aria-label="در حال دریافت تحلیل هوشمند"
|
||||
><Skeleton class="h-6 w-44" /><Skeleton class="h-20 w-full" /></CardContent
|
||||
></Card
|
||||
>
|
||||
{:else if !insight}
|
||||
<Card class="rounded-lg border-dashed">
|
||||
<CardContent class="flex flex-col gap-4 p-5 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p class="font-bold">تحلیل هوشمند</p>
|
||||
<p class="text-muted-foreground mt-1 text-sm" role="status">{error}</p>
|
||||
</div>
|
||||
<Button variant="outline" onclick={refresh} disabled={refreshing}
|
||||
><RefreshCcwIcon class={refreshing ? 'animate-spin' : ''} /> ساخت تحلیل</Button
|
||||
>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{:else}
|
||||
<Card class="border-primary/20 overflow-hidden rounded-lg shadow-xs">
|
||||
<CardHeader class="bg-primary/[.035] border-b">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<CardTitle class="flex items-center gap-2 text-base"
|
||||
><BrainCircuitIcon class="text-primary size-5" /> تحلیل هوشمند این صفحه</CardTitle
|
||||
>
|
||||
<p class="text-muted-foreground mt-2 text-xs leading-6">
|
||||
مبنای تحلیل تا {formatDate(insight.dataThroughDate)} است؛ تاریخ واقعی تحلیل {formatDate(
|
||||
insight.asOfDate,
|
||||
)} و شروع چشمانداز {formatDate(insight.forecastStartDate)} است.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Badge variant={insight.freshness === 'STALE' ? 'destructive' : 'secondary'}
|
||||
>{insight.freshness === 'FRESH'
|
||||
? 'داده تازه'
|
||||
: insight.freshness === 'LAGGING'
|
||||
? `${insight.dataLagDays} روز فاصله`
|
||||
: `داده قدیمی؛ ${insight.dataLagDays} روز فاصله`}</Badge
|
||||
>
|
||||
<Badge variant="outline">{confidenceLabel}</Badge>
|
||||
{#if insight.status === 'PARTIAL'}<Badge variant="outline">نسخه قطعی و بدون متن مولد</Badge>{/if}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-5 p-5">
|
||||
<div class="grid gap-3 lg:grid-cols-2 xl:grid-cols-4">
|
||||
{#each [insight.copy.past, insight.copy.latestObserved, insight.copy.future7Days, insight.copy.future30Days] as section}
|
||||
<article class="bg-muted/35 rounded-md border p-4">
|
||||
<h3 class="text-sm font-bold">{section.headline}</h3>
|
||||
<p class="text-muted-foreground mt-2 text-sm leading-7">{section.body}</p>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{#if insight.copy.future90Days}
|
||||
<article class="border-primary/15 bg-primary/[.025] rounded-md border p-4">
|
||||
<h3 class="text-sm font-bold">{insight.copy.future90Days.headline}</h3>
|
||||
<p class="text-muted-foreground mt-2 text-sm leading-7">{insight.copy.future90Days.body}</p>
|
||||
</article>
|
||||
{/if}
|
||||
{#if insight.calendarContext.length}
|
||||
<div>
|
||||
<h3 class="flex items-center gap-2 text-sm font-bold">
|
||||
<CalendarDaysIcon class="size-4" /> رویدادهای مرتبط
|
||||
</h3>
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
{#each insight.calendarContext as event}<span
|
||||
class={`rounded-md border px-3 py-2 text-xs ${eventTone(event.tone)}`}
|
||||
>{event.titleFa} · {formatDate(event.startDate)}</span
|
||||
>{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<details class="group rounded-md border p-4">
|
||||
<summary class="flex cursor-pointer list-none items-center justify-between text-sm font-bold"
|
||||
>شواهد و گزینههای بررسی <ChevronDownIcon
|
||||
class="size-4 transition-transform group-open:rotate-180"
|
||||
/></summary
|
||||
>
|
||||
<div class="mt-4 grid gap-3 md:grid-cols-2">
|
||||
{#each insight.copy.findings as finding}<div>
|
||||
<p class="text-sm font-bold">{finding.title}</p>
|
||||
<p class="text-muted-foreground mt-1 text-sm leading-6">{finding.summary}</p>
|
||||
<code class="text-muted-foreground mt-1 block text-[11px]">{finding.evidenceId}</code>
|
||||
</div>{/each}{#each insight.copy.options as option}<div
|
||||
class="border-primary/15 bg-primary/[.025] rounded-md border p-3"
|
||||
>
|
||||
<p class="text-sm font-bold">{option.title}</p>
|
||||
<p class="text-muted-foreground mt-1 text-sm leading-6">{option.description}</p>
|
||||
</div>{/each}
|
||||
</div>
|
||||
</details>
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p class="text-muted-foreground text-xs" aria-live="polite">
|
||||
{error ||
|
||||
(refreshing
|
||||
? 'نسخه تازه در پسزمینه ساخته میشود؛ تحلیل فعلی قابل استفاده است.'
|
||||
: 'این تحلیل پیشنهادی است و تصمیم خودکار ایجاد نمیکند.')}
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onclick={refresh} disabled={refreshing}
|
||||
><RefreshCcwIcon class={refreshing ? 'animate-spin' : ''} /> بهروزرسانی تحلیل</Button
|
||||
>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/if}
|
||||
</section>
|
||||
@@ -36,6 +36,7 @@
|
||||
import FeeShareDonut from './FeeShareDonut.svelte';
|
||||
import PageHeader from './PageHeader.svelte';
|
||||
import PaymentHistoryChart from './PaymentHistoryChart.svelte';
|
||||
import AIInsightPanel from './AIInsightPanel.svelte';
|
||||
|
||||
let { kind }: { kind: 'overview' | 'payments' | 'fees' | 'peers' } = $props();
|
||||
const dashboard = useDashboardContext();
|
||||
@@ -120,6 +121,8 @@
|
||||
|
||||
<PageHeader title={titles[kind][0]} description={titles[kind][1]} icon={icons[kind]} />
|
||||
|
||||
<AIInsightPanel module={kind} terminalId={filters.terminal === 'all' ? undefined : filters.terminal} />
|
||||
|
||||
{#if loading}
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4" role="status" aria-label="در حال محاسبه گزارش">
|
||||
{#each Array(4) as _}<Skeleton class="h-40 rounded-lg" />{/each}
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
import GrowthForecastChart from './GrowthForecastChart.svelte';
|
||||
import GrowthInsights from './GrowthInsights.svelte';
|
||||
import PageHeader from './PageHeader.svelte';
|
||||
import AIInsightPanel from './AIInsightPanel.svelte';
|
||||
|
||||
const dashboard = useDashboardContext();
|
||||
const terminalId = $derived(page.url.searchParams.get('terminal') ?? 'all');
|
||||
@@ -196,6 +197,8 @@
|
||||
controls={false}
|
||||
/>
|
||||
|
||||
<AIInsightPanel module="growth" terminalId={scopedTerminal()} />
|
||||
|
||||
{#if !growthFeatureEnabled}
|
||||
<Card class="rounded-lg"
|
||||
><CardContent class="p-10 text-center"
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
import { parseDashboardFilters } from '$lib/dashboard-filters';
|
||||
import { formatDate, formatMoney, formatValue } from '$lib/format';
|
||||
import PageHeader from './PageHeader.svelte';
|
||||
import AIInsightPanel from './AIInsightPanel.svelte';
|
||||
|
||||
const dashboard = useDashboardContext();
|
||||
const terminalIds = $derived(dashboard.terminals.map((terminal) => terminal.terminalId));
|
||||
@@ -98,6 +99,11 @@
|
||||
icon={ArrowUpDownIcon}
|
||||
/>
|
||||
|
||||
<AIInsightPanel
|
||||
module="transactions"
|
||||
terminalId={filters.terminal === 'all' ? undefined : filters.terminal}
|
||||
/>
|
||||
|
||||
<Card class="rounded-lg shadow-xs">
|
||||
<CardContent class="p-0">
|
||||
{#if loading}
|
||||
|
||||
Reference in New Issue
Block a user