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:
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user