feat(analyst): Implement data analysis agent with authentication and query handling

- Introduced `analyst_auth_adapter.py` for managing authentication context and access control for the data analysis agent.
- Added new API endpoints in `analyst.py` for chat, dashboard, asset management, and metrics, utilizing the new authentication context.
- Created Pydantic models in `analyst_schemas.py` for request and response structures, ensuring consistent data handling.
- Updated SQL guard logic in `sql_guard.py` to enforce access restrictions based on user roles and contexts.
- Implemented migration scripts for new database tables related to the data analysis agent, enhancing data management capabilities.
- Removed legacy authentication code from `auth.py`, streamlining the authentication process.

This update significantly enhances the data analysis capabilities, providing a robust framework for querying and managing data securely.
This commit is contained in:
2026-09-09 21:02:11 +08:00
parent aa23e1ae1d
commit bcd6175d4e
28 changed files with 911 additions and 428 deletions
+3 -3
View File
@@ -1,7 +1,7 @@
import { Navigate, Route, Routes } from 'react-router-dom'
import { AppLayout } from './layouts/AppLayout'
import { LoginPage } from './pages/login/LoginPage'
import { PlaceholderPage } from './pages/PlaceholderPage'
import { AnalystQueryPage } from './pages/analytics/AnalystQueryPage'
import { loadAuth } from './stores/authStore'
import { AgentBanner } from './components/AgentBanner'
import { CustomerWealthDashboard } from './pages/dashboard/CustomerWealthDashboard'
@@ -87,12 +87,12 @@ export default function AppRoutes() {
/>
<Route path="analyst/home" element={<AnalystMarketDashboard />} />
<Route path="analytics/query" element={<PlaceholderPage title="数据分析 · 问数" description="NL2SQL 问数下一迭代;可先使用分析对话。" />} />
<Route path="analytics/query" element={<AnalystQueryPage />} />
<Route
path="analytics/chat"
element={
<div className="space-y-4">
<AgentBanner message="数据分析 Agent:非 analyst 角色查表明细可能返回 403,以 JWT 权限为准。" />
<AgentBanner message="轻量对话占位;NL2SQL 查数(表格+SQL)请使用「问数工作台」。" />
<AnalystChatShell />
</div>
}
+40
View File
@@ -0,0 +1,40 @@
import { apiFetch } from './client'
export type AnalystTable = {
columns: string[]
rows: unknown[][]
}
export type AnalystMeta = {
exec_ms: number
row_count: number
cache_hit: boolean
data_as_of?: string
source?: string
cost_est?: number
}
export type AnalystChatResponse = {
answer: string
table: AnalystTable
sql: string
meta: AnalystMeta
disclaimer: string
status: string
error_code?: string | null
suggestions?: string[] | null
trace_id?: string | null
}
export async function postAnalystChat(
token: string,
question: string,
sessionId?: string,
): Promise<AnalystChatResponse> {
const { data } = await apiFetch<AnalystChatResponse>('/api/analyst/chat', {
method: 'POST',
token,
body: JSON.stringify({ question, session_id: sessionId ?? null }),
})
return data
}
@@ -0,0 +1,138 @@
import { Alert, Button, Card, Collapse, Input, Space, Spin, Table, Typography } from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { useState } from 'react'
import { postAnalystChat, type AnalystChatResponse } from '../../api/analyst'
import { ApiError } from '../../api/client'
import { ApiErrorResult } from '../../components/ApiErrorResult'
import { PageShell } from '../../components/PageShell'
import { useAppAuth } from '../../layouts/AppLayout'
const { Text, Paragraph } = Typography
type RowRecord = Record<string, unknown> & { key: number }
export function AnalystQueryPage() {
const auth = useAppAuth()
const [question, setQuestion] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<ApiError | Error | null>(null)
const [result, setResult] = useState<AnalystChatResponse | null>(null)
async function onAsk() {
const q = question.trim()
if (!q) return
setLoading(true)
setError(null)
try {
const resp = await postAnalystChat(auth.accessToken, q)
setResult(resp)
} catch (e) {
setError(e instanceof Error ? e : new Error(String(e)))
setResult(null)
} finally {
setLoading(false)
}
}
const columns: ColumnsType<RowRecord> =
result?.table.columns.map((col: string, i: number) => ({
title: col,
key: `${col}-${i}`,
render: (_: unknown, record: RowRecord) => String(record[`c${i}`] ?? ''),
})) ?? []
const dataSource: RowRecord[] =
result?.table.rows.map((row: unknown[], idx: number) => {
const record: RowRecord = { key: idx }
row.forEach((cell: unknown, i: number) => {
record[`c${i}`] = cell
})
return record
}) ?? []
return (
<PageShell
title="数据分析 · 问数工作台"
alert={
<Alert
type="info"
showIcon
message="复杂查数走本页;轻量对话请用「分析对话」。客户侧仅趋势与统计解读,不含投资建议。"
/>
}
>
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Space.Compact style={{ width: '100%' }}>
<Input.TextArea
rows={2}
value={question}
onChange={(e) => setQuestion(e.target.value)}
placeholder="例如:我近30天有多少笔交易? / 待处理预警按类型统计"
onPressEnter={(e) => {
if (!e.shiftKey) {
e.preventDefault()
void onAsk()
}
}}
/>
<Button type="primary" loading={loading} onClick={() => void onAsk()}>
问数
</Button>
</Space.Compact>
{error ? <ApiErrorResult error={error} /> : null}
{loading ? <Spin tip="生成 SQL 并查询中…" /> : null}
{result ? (
<>
{result.status === 'clarify' ? <Alert type="warning" message={result.answer} /> : null}
{result.status === 'deny' ? (
<Alert
type="error"
message={result.answer}
description={result.suggestions?.join(' · ')}
/>
) : null}
{(result.status === 'success' || result.status === 'degrade') && result.answer ? (
<Card title="解读" size="small">
<Paragraph>{result.answer}</Paragraph>
{result.disclaimer ? (
<Text type="secondary" style={{ fontSize: 12 }}>
{result.disclaimer}
</Text>
) : null}
</Card>
) : null}
{result.table.rows.length > 0 ? (
<Card title="结果表格" size="small">
<Table<RowRecord>
size="small"
rowKey="key"
pagination={{ pageSize: 10 }}
columns={columns}
dataSource={dataSource}
scroll={{ x: true }}
/>
</Card>
) : null}
{result.sql ? (
<Collapse
size="small"
items={[
{
key: 'sql',
label: `SQL(${result.meta.row_count} 行 · ${result.meta.exec_ms} ms${
result.meta.cache_hit ? ' · 缓存命中' : ''
})`,
children: <pre style={{ margin: 0, whiteSpace: 'pre-wrap' }}>{result.sql}</pre>,
},
]}
/>
) : null}
</>
) : null}
</Space>
</PageShell>
)
}