feat(intelligent-eval): paginate the eval list
All checks were successful
CI / test (push) Successful in 4m5s

GET /api/intelligent-evals 支持 page/page_size(默认不传仍返回全部,向后兼容):
repository 加 count/list_page,lifecycle 加 list_evals_page,router 返回 total。
前端服务端分页:useIntelligentEvalRead 接 page/pageSize,list 存 total,
IntelligentEvals 表格 showSizeChanger + 页码切换重新加载;5s 轮询保持当前页。
测试:+3 后端分页 + hook 页码透传/总数断言,895 passed,vitest 19 passed
This commit is contained in:
sinohqb 2026-08-17 14:09:17 +08:00
parent b4f9c887f4
commit cc2ac8da89
10 changed files with 174 additions and 26 deletions

View File

@ -218,6 +218,12 @@ def list_evals(session: Session) -> list[IntelligentEval]:
return IntelligentEvalRepository(session).list_all()
def list_evals_page(session: Session, offset: int, limit: int) -> tuple[list[IntelligentEval], int]:
"""Return one page of evals (newest first) plus the total count."""
repo = IntelligentEvalRepository(session)
return repo.list_page(offset, limit), repo.count()
def _get_session_or_raise(repo: IntelligentEvalSessionRepository, session_id: str) -> IntelligentEvalSession:
obj = repo.get(session_id)
if obj is None:

View File

@ -101,6 +101,20 @@ class IntelligentEvalRepository:
statement = select(IntelligentEvalDB).order_by(IntelligentEvalDB.created_at.desc())
return [self._from_db(r) for r in self.session.exec(statement).all()]
def list_page(self, offset: int, limit: int) -> list[IntelligentEval]:
"""Return one page of evals (created_at desc) with the same ordering as ``list_all``."""
statement = (
select(IntelligentEvalDB)
.order_by(IntelligentEvalDB.created_at.desc())
.offset(offset)
.limit(limit)
)
return [self._from_db(r) for r in self.session.exec(statement).all()]
def count(self) -> int:
"""Total number of evaluations (for pagination metadata)."""
return self.session.exec(select(func.count()).select_from(IntelligentEvalDB)).one()
def get(self, eval_id: str) -> Optional[IntelligentEval]:
db = self.session.get(IntelligentEvalDB, eval_id)
return self._from_db(db) if db else None

View File

@ -94,10 +94,29 @@ async def create_eval(request: CreateEvalRequest, session: Session = Depends(get
@router.get("")
async def list_evals(session: Session = Depends(get_db)) -> dict:
evals = lifecycle.list_evals(session)
async def list_evals(
page: int | None = None,
page_size: int = 20,
session: Session = Depends(get_db),
) -> dict:
"""List intelligent evaluations, optionally paginated.
不传 ``page`` 时返回全部向后兼容 ``page`` 1 时按
``created_at`` 倒序分页返回 ``total`` 供前端服务端分页
"""
reader = IntelligentEvalReadModel(session)
if page is None:
evals = lifecycle.list_evals(session)
return {"intelligent_evals": [item.model_dump(mode="json") for item in reader.list_items(evals)]}
page_size = max(1, min(page_size, 100))
offset = (max(1, page) - 1) * page_size
evals, total = lifecycle.list_evals_page(session, offset, page_size)
return {
"intelligent_evals": [item.model_dump(mode="json") for item in reader.list_items(evals)],
"total": total,
"page": page,
"page_size": page_size,
}
@router.get("/tasks")

View File

@ -785,7 +785,11 @@ export interface TaskQueueList {
}
export const intelligentEvalsApi = {
list: () => api.get<{ intelligent_evals: IntelligentEval[] }>('/intelligent-evals'),
list: (params?: { page?: number; page_size?: number }) =>
api.get<{ intelligent_evals: IntelligentEval[]; total?: number; page?: number; page_size?: number }>(
'/intelligent-evals',
{ params },
),
listTasks: (params?: { status?: TaskQueueStatus; limit?: number }) =>
api.get<TaskQueueList>('/intelligent-evals/tasks', { params }),
get: (id: string) => api.get<IntelligentEval>(`/intelligent-evals/${id}`),

View File

@ -38,10 +38,12 @@ export default function IntelligentEvalsPage() {
const [selectedId, setSelectedId] = useState<string | null>(null)
const [createOpen, setCreateOpen] = useState(false)
const [taskQueueOpen, setTaskQueueOpen] = useState(false)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [submitting, setSubmitting] = useState(false)
const [form] = Form.useForm<CreateFormValues>()
const { list, detail, reloadList, reloadDetail } = useIntelligentEvalRead(selectedId)
const { list, detail, reloadList, reloadDetail } = useIntelligentEvalRead(selectedId, undefined, page, pageSize)
const evals = list.value
const loading = list.phase === 'loading'
const { data: targets } = useResource(
@ -173,7 +175,18 @@ export default function IntelligentEvalsPage() {
loading={loading}
dataSource={evals ?? []}
columns={columns}
pagination={(evals?.length ?? 0) > 20 ? { pageSize: 20, showTotal: (t) => `${t}` } : false}
pagination={{
current: page,
pageSize,
total: list.total,
showSizeChanger: true,
pageSizeOptions: [10, 20, 50],
showTotal: (t) => `${t}`,
onChange: (p, ps) => {
setPage(p)
setPageSize(ps)
},
}}
locale={{ emptyText: <Empty description="还没有智能评估" /> }}
/>
</div>

View File

@ -12,20 +12,25 @@ describe('intelligent evaluation read state', () => {
const loading = intelligentEvalReadReducer(initialIntelligentEvalReadState, { type: 'list_requested' })
expect(loading.list.phase).toBe('loading')
const ready = intelligentEvalReadReducer(loading, { type: 'list_succeeded', value: [evaluation] })
expect(ready.list).toEqual({ phase: 'ready', value: [evaluation], error: null })
const ready = intelligentEvalReadReducer(loading, {
type: 'list_succeeded',
value: [evaluation],
total: 42,
})
expect(ready.list).toEqual({ phase: 'ready', value: [evaluation], error: null, total: 42 })
})
it('keeps the last list snapshot during silent refresh and failure', () => {
const ready: IntelligentEvalReadState = {
...initialIntelligentEvalReadState,
list: { phase: 'ready', value: [evaluation], error: null },
list: { phase: 'ready', value: [evaluation], error: null, total: 1 },
}
const refreshing = intelligentEvalReadReducer(ready, { type: 'list_requested', silent: true })
expect(refreshing.list.phase).toBe('refreshing')
expect(refreshing.list.total).toBe(1)
const afterFailure = intelligentEvalReadReducer(refreshing, { type: 'list_failed', error: '网络错误' })
expect(afterFailure.list).toEqual({ phase: 'ready', value: [evaluation], error: null })
expect(afterFailure.list).toEqual({ phase: 'ready', value: [evaluation], error: null, total: 1 })
})
it('surfaces an initial detail failure without an existing snapshot', () => {

View File

@ -9,7 +9,7 @@ export interface ReadSlot<T> {
}
export interface IntelligentEvalReadState {
list: ReadSlot<IntelligentEval[]>
list: ReadSlot<IntelligentEval[]> & { total: number }
detail: ReadSlot<IntelligentEval | null> & {
selectedId: string | null
requestId: number
@ -18,7 +18,7 @@ export interface IntelligentEvalReadState {
export type IntelligentEvalReadAction =
| { type: 'list_requested'; silent?: boolean }
| { type: 'list_succeeded'; value: IntelligentEval[] }
| { type: 'list_succeeded'; value: IntelligentEval[]; total: number }
| { type: 'list_failed'; error: string }
| { type: 'detail_cleared'; requestId: number }
| { type: 'detail_requested'; id: string; requestId: number; silent?: boolean }
@ -26,21 +26,28 @@ export type IntelligentEvalReadAction =
| { type: 'detail_failed'; id: string; requestId: number; error: string }
export interface IntelligentEvalReadAdapter {
list: () => Promise<IntelligentEval[]>
list: (page?: number, pageSize?: number) => Promise<{ items: IntelligentEval[]; total: number }>
get: (id: string) => Promise<IntelligentEval>
}
export const intelligentEvalReadAdapter: IntelligentEvalReadAdapter = {
list: () => intelligentEvalsApi.list().then((response) => response.data.intelligent_evals),
list: (page, pageSize) =>
intelligentEvalsApi
.list(page != null ? { page, page_size: pageSize ?? 20 } : undefined)
.then((response) => ({
items: response.data.intelligent_evals,
// 兼容不分页响应:无 total 时以本页长度为总数
total: response.data.total ?? response.data.intelligent_evals.length,
})),
get: (id) => intelligentEvalsApi.get(id).then((response) => response.data),
}
export const initialIntelligentEvalReadState: IntelligentEvalReadState = {
list: { phase: 'idle', value: [], error: null },
list: { phase: 'idle', value: [], error: null, total: 0 },
detail: { phase: 'idle', value: null, error: null, selectedId: null, requestId: 0 },
}
function requestPhase<T>(slot: ReadSlot<T>, silent: boolean | undefined): ReadSlot<T> {
function requestPhase<S extends ReadSlot<unknown>>(slot: S, silent: boolean | undefined): S {
return { ...slot, phase: silent && slot.phase === 'ready' ? 'refreshing' : 'loading', error: null }
}
@ -52,7 +59,7 @@ export function intelligentEvalReadReducer(
case 'list_requested':
return { ...state, list: requestPhase(state.list, action.silent) }
case 'list_succeeded':
return { ...state, list: { phase: 'ready', value: action.value, error: null } }
return { ...state, list: { phase: 'ready', value: action.value, error: null, total: action.total } }
case 'list_failed':
return {
...state,

View File

@ -28,19 +28,20 @@ describe('useIntelligentEvalRead', () => {
it('refreshes active evaluations every five seconds and stops at terminal state', async () => {
vi.useFakeTimers()
const adapter: IntelligentEvalReadAdapter = {
list: vi.fn().mockResolvedValue([evaluation]),
list: vi.fn().mockResolvedValue({ items: [evaluation], total: 1 }),
get: vi.fn().mockResolvedValue(evaluation),
}
renderHook(() => useIntelligentEvalRead(null, adapter))
const { result } = renderHook(() => useIntelligentEvalRead(null, adapter))
await settle()
expect(adapter.list).toHaveBeenCalledTimes(1)
expect(result.current.list.total).toBe(1)
await act(async () => { await vi.advanceTimersByTimeAsync(5000) })
await settle()
expect(adapter.list).toHaveBeenCalledTimes(2)
const terminal = { ...evaluation, status: 'completed' } as IntelligentEval
vi.mocked(adapter.list).mockResolvedValue([terminal])
vi.mocked(adapter.list).mockResolvedValue({ items: [terminal], total: 1 })
await act(async () => { await vi.advanceTimersByTimeAsync(5000) })
await settle()
await act(async () => { await vi.advanceTimersByTimeAsync(10000) })
@ -51,7 +52,7 @@ describe('useIntelligentEvalRead', () => {
vi.useFakeTimers()
const adapter: IntelligentEvalReadAdapter = {
list: vi.fn()
.mockResolvedValueOnce([evaluation])
.mockResolvedValueOnce({ items: [evaluation], total: 1 })
.mockRejectedValueOnce(new Error('网络错误')),
get: vi.fn().mockResolvedValue(evaluation),
}
@ -65,12 +66,29 @@ describe('useIntelligentEvalRead', () => {
expect(result.current.list.error).toBeNull()
})
it('passes page/pageSize to the adapter and reloads on change', async () => {
const adapter: IntelligentEvalReadAdapter = {
list: vi.fn().mockResolvedValue({ items: [], total: 0 }),
get: vi.fn().mockResolvedValue(evaluation),
}
const { rerender } = renderHook(
({ page }) => useIntelligentEvalRead(null, adapter, page, 20),
{ initialProps: { page: 1 } },
)
await settle()
expect(adapter.list).toHaveBeenCalledWith(1, 20)
rerender({ page: 2 })
await settle()
expect(adapter.list).toHaveBeenCalledWith(2, 20)
})
it('clears the old detail and ignores a response for the previous selection', async () => {
const first = deferred<IntelligentEval>()
const second = deferred<IntelligentEval>()
const secondEvaluation = { ...evaluation, id: 'eval-2', name: '评估二' } as IntelligentEval
const adapter: IntelligentEvalReadAdapter = {
list: vi.fn().mockResolvedValue([]),
list: vi.fn().mockResolvedValue({ items: [], total: 0 }),
get: vi.fn((id: string) => (id === 'eval-1' ? first.promise : second.promise)),
}
const { result, rerender } = renderHook(
@ -91,7 +109,7 @@ describe('useIntelligentEvalRead', () => {
it('surfaces failure for a newly selected detail instead of retaining the old snapshot', async () => {
const adapter: IntelligentEvalReadAdapter = {
list: vi.fn().mockResolvedValue([]),
list: vi.fn().mockResolvedValue({ items: [], total: 0 }),
get: vi.fn()
.mockResolvedValueOnce(evaluation)
.mockRejectedValueOnce(new Error('不存在')),

View File

@ -21,6 +21,8 @@ function isActive(status: string | undefined): boolean {
export function useIntelligentEvalRead(
selectedId: string | null,
adapter: IntelligentEvalReadAdapter = intelligentEvalReadAdapter,
page: number = 1,
pageSize: number = 20,
): IntelligentEvalReadState & { reloadList: () => Promise<void>; reloadDetail: () => Promise<void> } {
const [state, dispatch] = useReducer(intelligentEvalReadReducer, initialIntelligentEvalReadState)
const detailRequestId = useRef(0)
@ -28,12 +30,12 @@ export function useIntelligentEvalRead(
const loadList = useCallback(async (silent = false) => {
dispatch({ type: 'list_requested', silent })
try {
const value = await adapter.list()
dispatch({ type: 'list_succeeded', value })
const { items, total } = await adapter.list(page, pageSize)
dispatch({ type: 'list_succeeded', value: items, total })
} catch (error) {
dispatch({ type: 'list_failed', error: errorMessage(error) })
}
}, [adapter])
}, [adapter, page, pageSize])
const loadDetail = useCallback(async (id: string, silent = false) => {
const requestId = ++detailRequestId.current

View File

@ -274,3 +274,63 @@ def test_list_tasks_not_shadowed_by_eval_id(client: TestClient):
response = client.get("/api/intelligent-evals/tasks")
assert response.status_code == 200
assert "tasks" in response.json()
def _make_eval(db_session: Session, name: str, days_ago: int) -> IntelligentEvalDB:
from agenteval.storage.db import utc_now
ev = IntelligentEvalDB(
name=name,
target_id="target1",
status=IntelligentEvalStatus.COMPLETED.value,
created_at=utc_now() - timedelta(days=days_ago),
started_at=utc_now() - timedelta(days=days_ago),
)
db_session.add(ev)
return ev
def test_list_evals_backward_compatible_no_page(client: TestClient, db_session: Session):
"""Without page param the endpoint returns everything (no total key)."""
_make_eval(db_session, "A", days_ago=3)
_make_eval(db_session, "B", days_ago=2)
db_session.commit()
response = client.get("/api/intelligent-evals")
assert response.status_code == 200
data = response.json()
assert len(data["intelligent_evals"]) == 2
assert "total" not in data
def test_list_evals_pagination(client: TestClient, db_session: Session):
"""page/page_size returns one slice (newest first) plus total."""
_make_eval(db_session, "oldest", days_ago=3)
_make_eval(db_session, "middle", days_ago=2)
_make_eval(db_session, "newest", days_ago=1)
db_session.commit()
response = client.get("/api/intelligent-evals?page=1&page_size=2")
assert response.status_code == 200
data = response.json()
assert data["total"] == 3
assert data["page"] == 1
assert data["page_size"] == 2
names = [e["name"] for e in data["intelligent_evals"]]
assert names == ["newest", "middle"] # created_at desc
response = client.get("/api/intelligent-evals?page=2&page_size=2")
names = [e["name"] for e in response.json()["intelligent_evals"]]
assert names == ["oldest"]
def test_list_evals_page_size_clamped(client: TestClient, db_session: Session):
"""page_size is clamped to [1, 100]."""
for i in range(5):
_make_eval(db_session, f"eval{i}", days_ago=10 - i)
db_session.commit()
response = client.get("/api/intelligent-evals?page=1&page_size=0")
data = response.json()
assert data["page_size"] == 1
assert len(data["intelligent_evals"]) == 1