feat(analyst): Add sample and escalate functionality to analyst API

- Introduced new endpoints `/api/analyst/query/{trace_id}/sample` and `/api/analyst/escalate` for sampling query results and escalating issues to human analysts, respectively.
- Enhanced `AnalystAgent` to support sampling of SQL results based on trace ID and to handle escalation requests, improving user experience in error scenarios.
- Updated `analyst_schemas.py` to include `EscalateRequest` for structured escalation requests.
- Added corresponding frontend API calls and UI components to facilitate user interactions with the new features.
- Implemented unit tests to ensure the reliability of the new functionalities.

This update significantly enhances the analytical capabilities of the application, allowing users to retrieve detailed query samples and escalate issues effectively.
This commit is contained in:
2026-09-11 15:18:56 +08:00
parent 0fb7d34d7a
commit 8556da489a
27 changed files with 656 additions and 42 deletions
+23
View File
@@ -83,6 +83,29 @@ export async function getAnalystDashboard(token: string) {
return data
}
export async function getAnalystSample(token: string, traceId: string, limit = 5) {
const { data } = await apiFetch<AnalystChatResponse>(
`/api/analyst/query/${encodeURIComponent(traceId)}/sample?limit=${limit}`,
{ token },
)
return data
}
export async function postAnalystEscalate(
token: string,
payload: { trace_id: string; question?: string; reason?: string },
) {
const { data } = await apiFetch<{ ok: boolean; message: string; hints?: string[] }>(
'/api/analyst/escalate',
{
method: 'POST',
token,
body: JSON.stringify(payload),
},
)
return data
}
export type AnalystAssetKind = 'dict' | 'few_shot' | 'template'
export async function createAnalystAsset(
+12
View File
@@ -53,3 +53,15 @@ export async function listTrades(token: string, customerId: string, limit = 50,
)
return data
}
export async function postCustomerThresholdCheck(
token: string,
customerId: string,
push = false,
) {
const { data } = await apiFetch<{ alert: string | null; pushed: boolean }>(
`/api/customers/${encodeURIComponent(customerId)}/threshold-check?push=${push ? 'true' : 'false'}`,
{ method: 'POST', token },
)
return data
}
+55
View File
@@ -0,0 +1,55 @@
import { useEffect, useState } from 'react'
import { Alert } from 'antd'
import { apiFetch } from '../api/client'
type ReadyPayload = {
ok: boolean
checks?: Record<string, boolean | string>
}
export function DevReadyBanner() {
const [state, setState] = useState<'loading' | 'ok' | 'warn'>('loading')
const [detail, setDetail] = useState('')
useEffect(() => {
let cancelled = false
;(async () => {
try {
const { data } = await apiFetch<ReadyPayload>('/api/ready')
if (cancelled) return
if (data.ok) {
setState('ok')
setDetail('')
} else {
setState('warn')
const checks = data.checks ?? {}
const parts = Object.entries(checks)
.filter(([k]) => !k.endsWith('_error'))
.map(([k, v]) => `${k}=${String(v)}`)
setDetail(parts.join(' · '))
}
} catch {
if (!cancelled) {
setState('warn')
setDetail('无法连接 /api/ready')
}
}
})()
return () => {
cancelled = true
}
}, [])
if (state === 'loading' || state === 'ok') return null
return (
<Alert
type="warning"
showIcon
banner
message="环境自检未通过"
description={detail || '请确认 Redis 6380 与后端已启动'}
className="!mb-0 !rounded-none"
/>
)
}
+2
View File
@@ -4,6 +4,7 @@ import { useAuth } from '../context/AuthProvider'
import type { AuthState } from '../stores/authStore'
import { buildMenuGroups, flattenMenuPaths, toAntdMenuItems } from '../routes/menus'
import { AppShell, BrandSidebar, Topbar } from '../components/layout'
import { DevReadyBanner } from '../components/DevReadyBanner'
type AppLayoutProps = {
auth: AuthState
@@ -52,6 +53,7 @@ export function AppLayout({ auth }: AppLayoutProps) {
/>
}
>
<DevReadyBanner />
<Outlet context={auth} />
</AppShell>
)
+68 -3
View File
@@ -1,7 +1,7 @@
import { Alert, Button, Card, Collapse, Input, Row, Col, Space, Spin, Table, Tag, Typography } from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { useEffect, useState } from 'react'
import { getAnalystDashboard, labelAnalystInterpretButton, postAnalystChat, postAnalystInterpret, type AnalystChatResponse } from '../../api/analyst'
import { getAnalystDashboard, getAnalystSample, labelAnalystInterpretButton, postAnalystChat, postAnalystEscalate, postAnalystInterpret, type AnalystChatResponse } from '../../api/analyst'
import { ApiError } from '../../api/client'
import { ApiErrorResult } from '../../components/ApiErrorResult'
import { MetricCard } from '../../components/ui'
@@ -22,6 +22,9 @@ export function AnalystQueryPage() {
const [lastQuestion, setLastQuestion] = useState('')
const [interpretLoading, setInterpretLoading] = useState(false)
const [interpretResult, setInterpretResult] = useState<AnalystChatResponse | null>(null)
const [sampleLoading, setSampleLoading] = useState(false)
const [sampleResult, setSampleResult] = useState<AnalystChatResponse | null>(null)
const [escalateMsg, setEscalateMsg] = useState<string | null>(null)
const [dashLoading, setDashLoading] = useState(true)
const [dashError, setDashError] = useState<ApiError | Error | null>(null)
const [dashMetrics, setDashMetrics] = useState<Record<string, number | string>>({})
@@ -55,6 +58,8 @@ export function AnalystQueryPage() {
setLoading(true)
setError(null)
setInterpretResult(null)
setSampleResult(null)
setEscalateMsg(null)
try {
const resp = await postAnalystChat(auth.accessToken, q, { interpret: false })
setLastQuestion(q)
@@ -89,6 +94,33 @@ export function AnalystQueryPage() {
}
}
async function onSample() {
if (!result?.trace_id) return
setSampleLoading(true)
try {
const resp = await getAnalystSample(auth.accessToken, result.trace_id, 5)
setSampleResult(resp)
} catch (e) {
setError(e instanceof Error ? e : new Error(String(e)))
} finally {
setSampleLoading(false)
}
}
async function onEscalate() {
if (!result?.trace_id) return
try {
const resp = await postAnalystEscalate(auth.accessToken, {
trace_id: result.trace_id,
question: lastQuestion,
reason: result.error_code ?? result.status,
})
setEscalateMsg(resp.message)
} catch (e) {
setError(e instanceof Error ? e : new Error(String(e)))
}
}
const interpretButtonLabel = labelAnalystInterpretButton(auth.roleLabel)
const columns: ColumnsType<RowRecord> =
@@ -170,6 +202,23 @@ export function AnalystQueryPage() {
description={result.suggestions?.join(' · ')}
/>
) : null}
{result.status === 'escalate' || result.status === 'error' ? (
<Alert
type="error"
message={result.answer}
description={
<Space direction="vertical" size={8}>
{result.suggestions?.length ? (
<span>{result.suggestions.join(' · ')}</span>
) : null}
<Button size="small" onClick={() => void onEscalate()}>
转交人工分析
</Button>
{escalateMsg ? <Text type="success">{escalateMsg}</Text> : null}
</Space>
}
/>
) : null}
{result.status === 'success' || result.status === 'degrade' ? (
<Space wrap size={[8, 8]}>
{result.meta.template_hit ? (
@@ -187,7 +236,7 @@ export function AnalystQueryPage() {
)}
</Space>
) : null}
{['success', 'degrade', 'clarify', 'deny', 'error'].includes(result.status) ? (
{['success', 'degrade', 'clarify', 'deny', 'error', 'escalate'].includes(result.status) ? (
<Button loading={interpretLoading} onClick={() => void onInterpret()}>
{interpretButtonLabel}
</Button>
@@ -203,7 +252,17 @@ export function AnalystQueryPage() {
</Card>
) : null}
{result.table.rows.length > 0 ? (
<Card title="结果表格" size="small">
<Card
title="结果表格"
size="small"
extra={
result.trace_id ? (
<Button size="small" loading={sampleLoading} onClick={() => void onSample()}>
抽样明细(溯源)
</Button>
) : null
}
>
<Table<RowRecord>
size="small"
rowKey="key"
@@ -214,6 +273,12 @@ export function AnalystQueryPage() {
/>
</Card>
) : null}
{sampleResult?.table.rows.length ? (
<Card title="抽样明细(N-03)" size="small">
<Paragraph type="secondary">{sampleResult.answer}</Paragraph>
<pre style={{ margin: 0, whiteSpace: 'pre-wrap', fontSize: 12 }}>{sampleResult.sql}</pre>
</Card>
) : null}
{result.sql ? (
<Collapse
size="small"
@@ -1,6 +1,9 @@
import { Alert } from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { Table } from 'antd'
import { Space, Table } from 'antd'
import { Link } from 'react-router-dom'
import { useState } from 'react'
import { postCustomerThresholdCheck } from '../../api/customers'
import { AmountText, DataSection, PnlText } from '../../components/dashboard'
import { ApiErrorResult } from '../../components/ApiErrorResult'
import { PageShell } from '../../components/PageShell'
@@ -14,6 +17,18 @@ export function CustomerHoldingsPage() {
const auth = useAppAuth()
const { masked, toggle } = useAmountMask()
const { loading, error, rows, asOf, refresh } = useHoldingsDashboard(auth.accessToken, auth.actorId)
const [thresholdAlert, setThresholdAlert] = useState<string | null>(null)
const [thresholdLoading, setThresholdLoading] = useState(false)
async function onThresholdCheck() {
setThresholdLoading(true)
try {
const resp = await postCustomerThresholdCheck(auth.accessToken, auth.actorId, true)
setThresholdAlert(resp.alert)
} finally {
setThresholdLoading(false)
}
}
const columns: ColumnsType<HoldingRow> = [
{ title: '产品', dataIndex: 'product_name' },
@@ -42,9 +57,14 @@ export function CustomerHoldingsPage() {
{ title: '我的持仓' },
]}
extra={
<Button variant="secondary" onClick={() => void refresh()}>
刷新
</Button>
<Space>
<Button variant="secondary" loading={thresholdLoading} onClick={() => void onThresholdCheck()}>
检查阈值提醒
</Button>
<Button variant="secondary" onClick={() => void refresh()}>
刷新
</Button>
</Space>
}
alert={
<div className="text-sm text-jr-muted">
@@ -53,6 +73,7 @@ export function CustomerHoldingsPage() {
}
>
{error && <ApiErrorResult error={error} onRetry={refresh} />}
{thresholdAlert ? <Alert type="warning" showIcon message={thresholdAlert} /> : null}
<DataSection title="持仓明细">
<Table
rowKey="product_id"