diff --git a/web/features/new-rag/__tests__/quality-page.spec.tsx b/web/features/new-rag/__tests__/quality-page.spec.tsx index e43c1956a43..50e0426393d 100644 --- a/web/features/new-rag/__tests__/quality-page.spec.tsx +++ b/web/features/new-rag/__tests__/quality-page.spec.tsx @@ -1,8 +1,7 @@ -import type { ReactNode } from 'react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { render } from '@/test/console/render' +import { renderWithNuqs as render } from '@/test/nuqs-testing' import { QualityPage } from '../quality/quality-page' vi.mock('../quality/quality-evaluation-panel', () => ({ @@ -40,9 +39,6 @@ vi.mock('../knowledge-space-context', () => ({ vi.mock('@/next/navigation', () => ({ useRouter: () => routerMock, - useSearchParams: () => ({ - get: (key: string) => (key === 'tab' ? navigationMock.tab : undefined), - }), })) vi.mock('@langgenius/dify-ui/toast', () => ({ @@ -137,10 +133,12 @@ function renderPage() { queries: { retry: false }, }, }) - const Wrapper = ({ children }: { children: ReactNode }) => ( - {children} + return render( + + + , + { searchParams: navigationMock.tab ? `?tab=${navigationMock.tab}` : '' }, ) - return render(, { wrapper: Wrapper }) } describe('QualityPage', () => { @@ -268,7 +266,7 @@ describe('QualityPage', () => { it('uses tab primitive relationships and keeps route state in sync', async () => { const user = userEvent.setup() - renderPage() + const { onUrlUpdate } = renderPage() const goldenTab = await screen.findByRole('tab', { name: 'dataset.newKnowledge.qualityPage.goldenTab', @@ -280,7 +278,9 @@ describe('QualityPage', () => { expect(badCasesTab).toHaveAttribute('tabindex', '-1') await user.click(badCasesTab) - expect(routerMock.replace).toHaveBeenCalledWith('/datasets/new/space-1/quality?tab=bad-cases') + await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()) + expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get('tab')).toBe('bad-cases') + expect(onUrlUpdate.mock.calls.at(-1)?.[0].options.history).toBe('replace') }) it('renders an empty golden-question annotation without an empty interactive control', async () => { diff --git a/web/features/new-rag/quality/bad-cases-panel.tsx b/web/features/new-rag/quality/bad-cases-panel.tsx new file mode 100644 index 00000000000..33f2c1b3579 --- /dev/null +++ b/web/features/new-rag/quality/bad-cases-panel.tsx @@ -0,0 +1,399 @@ +'use client' + +import type { KnowledgeFsBadCaseResponse } from '@dify/contracts/api/console/knowledge-fs/types.gen' +import type { GoldenQuestionDraft } from './types' +import { Button } from '@langgenius/dify-ui/button' +import { cn } from '@langgenius/dify-ui/cn' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, +} from '@langgenius/dify-ui/dropdown-menu' +import { toast } from '@langgenius/dify-ui/toast' +import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useRouter } from '@/next/navigation' +import { consoleClient, consoleQuery } from '@/service/client' +import { newKnowledgeRetrievalTestPath } from '../routes' +import { GoldenQuestionDialog } from './golden-question-dialog' +import { + formatQualityUpdatedAt, + goldenQuestionPayload, + qualityPageSize, + visibleQualityTags, +} from './quality-model' +import { QualityQueryState } from './quality-query-state' +import { QualityRowMenuTrigger } from './quality-row-menu-trigger' + +type BadCasesPanelProps = { + canEdit: boolean + knowledgeSpaceId: string +} + +function BadCaseReason({ + question, + reason, + tags, +}: { + question?: string + reason: string + tags: string[] +}) { + const { t } = useTranslation('dataset') + const normalized = reason.toLowerCase() + if (normalized === 'low-score' || (normalized.includes('low') && normalized.includes('score'))) + return t(($) => $['newKnowledge.qualityPage.reasonValues.lowScore']) + if (normalized.includes('outdated')) + return t(($) => $['newKnowledge.qualityPage.reasonValues.outdatedContent']) + if ( + (tags.includes('retrieval-test') && reason.trim() === question?.trim()) || + normalized.includes('retrieval') || + normalized.includes('miss') + ) + return t(($) => $['newKnowledge.qualityPage.reasonValues.retrievalMiss']) + if (normalized.includes('coverage') || normalized.includes('evidence')) + return t(($) => $['newKnowledge.qualityPage.reasonValues.coverageGap']) + return reason +} + +function BadCaseStatus({ status }: { status: KnowledgeFsBadCaseResponse['status'] }) { + const { t } = useTranslation('dataset') + const visibleStatus = status === 'dismissed' ? 'fixed' : status + return ( +
+ + {t(($) => $[`newKnowledge.qualityPage.status.${visibleStatus}`])} +
+ ) +} + +export function BadCasesPanel({ canEdit, knowledgeSpaceId }: BadCasesPanelProps) { + const { t } = useTranslation('dataset') + const router = useRouter() + const queryClient = useQueryClient() + const [pendingId, setPendingId] = useState() + const [dialog, setDialog] = useState<{ + id: string + key: string + value: GoldenQuestionDraft + }>() + const [dialogError, setDialogError] = useState() + const [dialogSubmitting, setDialogSubmitting] = useState(false) + const promotedGoldenQuestionIdsRef = useRef(new Map()) + const badCaseQueryOptions = + consoleQuery.knowledgeFs.spaces.byControlSpaceId.quality.badCases.get.infiniteOptions({ + input: (pageParam) => ({ + params: { control_space_id: knowledgeSpaceId }, + query: { + limit: qualityPageSize, + ...(typeof pageParam === 'string' ? { cursor: pageParam } : {}), + }, + }), + getNextPageParam: (lastPage) => lastPage.next_cursor ?? undefined, + initialPageParam: null as string | null, + }) + const goldenQueryOptions = + consoleQuery.knowledgeFs.spaces.byControlSpaceId.goldenQuestions.get.infiniteOptions({ + input: (pageParam) => ({ + params: { control_space_id: knowledgeSpaceId }, + query: { + limit: qualityPageSize, + ...(typeof pageParam === 'string' ? { cursor: pageParam } : {}), + }, + }), + getNextPageParam: (lastPage) => lastPage.next_cursor ?? undefined, + initialPageParam: null as string | null, + }) + const query = useInfiniteQuery(badCaseQueryOptions) + const createGoldenMutation = useMutation( + consoleQuery.knowledgeFs.spaces.byControlSpaceId.goldenQuestions.post.mutationOptions(), + ) + const deleteGoldenMutation = useMutation( + consoleQuery.knowledgeFs.spaces.byControlSpaceId.goldenQuestions.byQuestionId.delete.mutationOptions(), + ) + const items = (query.data?.pages.flatMap((page) => page.data) ?? []).filter( + (item) => item.status !== 'dismissed', + ) + + const invalidateQuality = async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: goldenQueryOptions.queryKey }), + queryClient.invalidateQueries({ queryKey: badCaseQueryOptions.queryKey }), + ]) + } + const getBadCase = (badCaseId: string) => + consoleClient.knowledgeFs.spaces.byControlSpaceId.quality.badCases.byBadCaseId.get({ + params: { bad_case_id: badCaseId, control_space_id: knowledgeSpaceId }, + }) + const markBadCaseDismissed = async ( + badCase: KnowledgeFsBadCaseResponse, + tags: string[] = badCase.tags, + ) => { + try { + await consoleClient.knowledgeFs.spaces.byControlSpaceId.quality.badCases.byBadCaseId.patch({ + body: { + expected_revision: badCase.revision, + status: 'dismissed', + tags, + }, + params: { bad_case_id: badCase.id, control_space_id: knowledgeSpaceId }, + }) + } catch (error) { + const refreshed = await getBadCase(badCase.id).catch(() => undefined) + if (refreshed?.status !== 'dismissed') throw error + } + } + + const submitPromotion = async (draft: GoldenQuestionDraft) => { + if (!dialog) return + setDialogError(undefined) + setDialogSubmitting(true) + try { + const badCase = await getBadCase(dialog.id) + let goldenQuestionId = promotedGoldenQuestionIdsRef.current.get(badCase.id) + if (!goldenQuestionId) { + const createdGoldenQuestion = await createGoldenMutation.mutateAsync({ + body: { + ...goldenQuestionPayload(draft), + source_bad_case_id: badCase.id, + }, + params: { control_space_id: knowledgeSpaceId }, + }) + goldenQuestionId = createdGoldenQuestion.id + promotedGoldenQuestionIdsRef.current.set(badCase.id, goldenQuestionId) + } + try { + await markBadCaseDismissed(badCase, visibleQualityTags(badCase.tags)) + promotedGoldenQuestionIdsRef.current.delete(badCase.id) + } catch (error) { + try { + await deleteGoldenMutation.mutateAsync({ + params: { control_space_id: knowledgeSpaceId, question_id: goldenQuestionId }, + }) + promotedGoldenQuestionIdsRef.current.delete(badCase.id) + } catch { + // Keep the created ID so retrying resumes dismissal without creating a duplicate. + } + throw error + } + toast.success(t(($) => $['newKnowledge.qualityPage.promotedToast'])) + await invalidateQuality() + setDialog(undefined) + } catch { + setDialogError(t(($) => $.unknownError)) + } finally { + setDialogSubmitting(false) + } + } + + const replayBadCase = async (item: KnowledgeFsBadCaseResponse) => { + setPendingId(item.id) + try { + const reference = + await consoleClient.knowledgeFs.spaces.byControlSpaceId.quality.badCases.byBadCaseId.traceReference.get( + { params: { bad_case_id: item.id, control_space_id: knowledgeSpaceId } }, + ) + const search = new URLSearchParams({ retest: reference.trace_id, trace: reference.trace_id }) + router.push(`${newKnowledgeRetrievalTestPath(knowledgeSpaceId)}?${search.toString()}`) + } catch { + toast.error(t(($) => $.unknownError)) + } finally { + setPendingId(undefined) + } + } + const ignoreBadCase = async (item: KnowledgeFsBadCaseResponse) => { + setPendingId(item.id) + try { + const badCase = await getBadCase(item.id) + await markBadCaseDismissed(badCase) + await queryClient.invalidateQueries({ queryKey: badCaseQueryOptions.queryKey }) + } catch { + toast.error(t(($) => $.unknownError)) + } finally { + setPendingId(undefined) + } + } + const openTrace = async (badCaseId: string) => { + try { + const reference = + await consoleClient.knowledgeFs.spaces.byControlSpaceId.quality.badCases.byBadCaseId.traceReference.get( + { params: { bad_case_id: badCaseId, control_space_id: knowledgeSpaceId } }, + ) + router.push(`${newKnowledgeRetrievalTestPath(knowledgeSpaceId)}?trace=${reference.trace_id}`) + } catch { + toast.error(t(($) => $.unknownError)) + } + } + const openPromotion = (item: KnowledgeFsBadCaseResponse) => + setDialog({ + id: item.id, + key: `promote-${item.id}-${Date.now()}`, + value: { + annotation: '', + expectedEvidenceIds: [], + matchPolicy: 'all', + question: item.question ?? '', + tags: visibleQualityTags(item.tags), + }, + }) + + return ( + <> + void query.refetch()} + > + {items.length ? ( +
+
+ {t(($) => $['newKnowledge.qualityPage.question'])} + {t(($) => $['newKnowledge.qualityPage.statusLabel'])} + {t(($) => $['newKnowledge.qualityPage.reason'])} + {t(($) => $['newKnowledge.qualityPage.updated'])} + +
+ {items.map((item) => ( +
+ {item.question} + + + + + + {formatQualityUpdatedAt(item.updated_at)} + + + $['newKnowledge.qualityPage.questionActions'], { + question: item.question ?? '', + })} + /> + + {canEdit && + (item.status === 'fixed' ? ( + openPromotion(item)} + > + + {t(($) => $['newKnowledge.qualityPage.toGolden'])} + + ) : ( + void replayBadCase(item)} + > + + {t(($) => $['newKnowledge.qualityPage.replay'])} + + ))} + void openTrace(item.id)} + > + + {t(($) => $['newKnowledge.qualityPage.openTrace'])} + + {canEdit && item.status !== 'fixed' && ( + openPromotion(item)} + > + + {t(($) => $['newKnowledge.qualityPage.toGolden'])} + + )} + {canEdit && } + {canEdit && ( + void ignoreBadCase(item)} + > + + {t(($) => $['newKnowledge.qualityPage.ignore'])} + + )} + + +
+ ))} + {query.hasNextPage && ( +
+ +
+ )} +
+ ) : ( +
+ +

+ {t(($) => $['newKnowledge.qualityPage.badCasesEmptyTitle'])} +

+

+ {t(($) => $['newKnowledge.qualityPage.badCasesEmptyDescription'])} +

+ {query.hasNextPage && ( + + )} +
+ )} +
+ + {dialog && ( + { + if (!open) { + setDialog(undefined) + setDialogError(undefined) + } + }} + onSubmit={submitPromotion} + /> + )} + + ) +} diff --git a/web/features/new-rag/quality/golden-questions-panel.tsx b/web/features/new-rag/quality/golden-questions-panel.tsx new file mode 100644 index 00000000000..dc4d7ab3ffe --- /dev/null +++ b/web/features/new-rag/quality/golden-questions-panel.tsx @@ -0,0 +1,475 @@ +'use client' + +import type { KnowledgeFsGoldenQuestionResponse } from '@dify/contracts/api/console/knowledge-fs/types.gen' +import type { GoldenQuestionDraft } from './types' +import { + AlertDialog, + AlertDialogActions, + AlertDialogCancelButton, + AlertDialogConfirmButton, + AlertDialogContent, + AlertDialogDescription, + AlertDialogTitle, +} from '@langgenius/dify-ui/alert-dialog' +import { Button } from '@langgenius/dify-ui/button' +import { Checkbox } from '@langgenius/dify-ui/checkbox' +import { cn } from '@langgenius/dify-ui/cn' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, +} from '@langgenius/dify-ui/dropdown-menu' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' +import { toast } from '@langgenius/dify-ui/toast' +import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { useState } from 'react' +import { createPortal } from 'react-dom' +import { useTranslation } from 'react-i18next' +import Badge from '@/app/components/base/badge' +import { consoleQuery } from '@/service/client' +import { GoldenQuestionDialog } from './golden-question-dialog' +import { GoldenQuestionImportDialog } from './golden-question-import-dialog' +import { + emptyGoldenQuestionDraft, + formatQualityUpdatedAt, + goldenQuestionPayload, + qualityPageSize, + visibleQualityTags, +} from './quality-model' +import { QualityQueryState } from './quality-query-state' +import { QualityRowMenuTrigger } from './quality-row-menu-trigger' + +type GoldenQuestionsPanelProps = { + actionSlot: HTMLDivElement | null + canEdit: boolean + knowledgeSpaceId: string +} + +type GoldenQuestionDialogState = + | { key: string; mode: 'create'; value: GoldenQuestionDraft } + | { id: string; key: string; mode: 'edit'; value: GoldenQuestionDraft } + +function GoldenStatus({ status }: { status: 'active' | 'draft' | 'stale' }) { + const { t } = useTranslation('dataset') + return ( + + {t(($) => $[`newKnowledge.qualityPage.goldenStatus.${status}`])} + + ) +} + +function GoldenAnnotation({ annotation }: { annotation: string }) { + if (!annotation.trim()) return + return ( + + + {annotation} + + } + /> + +

{annotation}

+
+
+ ) +} + +export function GoldenQuestionsPanel({ + actionSlot, + canEdit, + knowledgeSpaceId, +}: GoldenQuestionsPanelProps) { + const { t } = useTranslation('dataset') + const { t: tCommon } = useTranslation('common') + const queryClient = useQueryClient() + const [selected, setSelected] = useState>(() => new Set()) + const [deleteIds, setDeleteIds] = useState>() + const [deleteSubmitting, setDeleteSubmitting] = useState(false) + const [dialog, setDialog] = useState() + const [dialogError, setDialogError] = useState() + const [dialogSubmitting, setDialogSubmitting] = useState(false) + const [importOpen, setImportOpen] = useState(false) + const queryOptions = + consoleQuery.knowledgeFs.spaces.byControlSpaceId.goldenQuestions.get.infiniteOptions({ + input: (pageParam) => ({ + params: { control_space_id: knowledgeSpaceId }, + query: { + limit: qualityPageSize, + ...(typeof pageParam === 'string' ? { cursor: pageParam } : {}), + }, + }), + getNextPageParam: (lastPage) => lastPage.next_cursor ?? undefined, + initialPageParam: null as string | null, + }) + const query = useInfiniteQuery(queryOptions) + const createMutation = useMutation( + consoleQuery.knowledgeFs.spaces.byControlSpaceId.goldenQuestions.post.mutationOptions(), + ) + const updateMutation = useMutation( + consoleQuery.knowledgeFs.spaces.byControlSpaceId.goldenQuestions.byQuestionId.patch.mutationOptions(), + ) + const deleteMutation = useMutation( + consoleQuery.knowledgeFs.spaces.byControlSpaceId.goldenQuestions.byQuestionId.delete.mutationOptions(), + ) + const items = query.data?.pages.flatMap((page) => page.data) ?? [] + const allSelected = items.length > 0 && selected.size === items.length + const partiallySelected = selected.size > 0 && !allSelected + + const invalidate = () => queryClient.invalidateQueries({ queryKey: queryOptions.queryKey }) + const openCreate = () => + setDialog({ + key: `create-${Date.now()}`, + mode: 'create', + value: emptyGoldenQuestionDraft, + }) + const openEdit = (item: KnowledgeFsGoldenQuestionResponse) => + setDialog({ + id: item.id, + key: `edit-${item.id}-${Date.now()}`, + mode: 'edit', + value: { + annotation: item.annotation, + expectedEvidenceIds: item.expected_evidence_ids ?? [], + matchPolicy: item.match_policy ?? 'all', + question: item.question, + tags: item.tags, + }, + }) + const toggleAll = () => + setSelected(allSelected ? new Set() : new Set(items.map((item) => item.id))) + const toggleOne = (id: string) => + setSelected((current) => { + const next = new Set(current) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + }) + + const submitDialog = async (draft: GoldenQuestionDraft) => { + if (!dialog) return + setDialogError(undefined) + setDialogSubmitting(true) + try { + if (dialog.mode === 'edit') { + await updateMutation.mutateAsync({ + body: goldenQuestionPayload(draft), + params: { control_space_id: knowledgeSpaceId, question_id: dialog.id }, + }) + toast.success(t(($) => $['newKnowledge.qualityPage.updatedToast'])) + } else { + await createMutation.mutateAsync({ + body: goldenQuestionPayload(draft), + params: { control_space_id: knowledgeSpaceId }, + }) + toast.success(t(($) => $['newKnowledge.qualityPage.createdToast'])) + } + await invalidate() + setDialog(undefined) + } catch { + setDialogError(t(($) => $.unknownError)) + } finally { + setDialogSubmitting(false) + } + } + + const deleteGolden = async (ids: Set): Promise => { + setDeleteSubmitting(true) + try { + const results = await Promise.allSettled( + [...ids].map((questionId) => + deleteMutation + .mutateAsync({ + params: { control_space_id: knowledgeSpaceId, question_id: questionId }, + }) + .then(() => questionId), + ), + ) + const deletedIds = new Set( + results.flatMap((result) => (result.status === 'fulfilled' ? [result.value] : [])), + ) + const failedIds = new Set([...ids].filter((id) => !deletedIds.has(id))) + setSelected((current) => new Set([...current].filter((id) => !deletedIds.has(id)))) + await invalidate().catch(() => undefined) + if (failedIds.size > 0) { + setDeleteIds(failedIds) + toast.error(t(($) => $.unknownError)) + return false + } + toast.success( + t( + ($) => + $[ + ids.size === 1 + ? 'newKnowledge.qualityPage.deletedToast_one' + : 'newKnowledge.qualityPage.deletedToast_other' + ], + { count: ids.size }, + ), + ) + setDeleteIds(undefined) + return true + } catch { + toast.error(t(($) => $.unknownError)) + return false + } finally { + setDeleteSubmitting(false) + } + } + + return ( + <> + {actionSlot && + canEdit && + items.length > 0 && + createPortal( + <> + + + , + actionSlot, + )} + + void query.refetch()} + > + {items.length ? ( +
+
+ $['newKnowledge.qualityPage.selectAll'])} + checked={allSelected} + disabled={!canEdit} + indeterminate={partiallySelected} + onCheckedChange={toggleAll} + /> + {t(($) => $['newKnowledge.qualityPage.question'])} + {t(($) => $['newKnowledge.qualityPage.statusLabel'])} + {t(($) => $['newKnowledge.qualityPage.tags'])} + {t(($) => $['newKnowledge.qualityPage.annotation'])} + {t(($) => $['newKnowledge.qualityPage.updated'])} + +
+ {items.map((item) => ( +
+ $['newKnowledge.qualityPage.selectQuestion'], { + question: item.question, + })} + checked={selected.has(item.id)} + disabled={!canEdit} + onCheckedChange={() => toggleOne(item.id)} + /> + + {item.question ?? ''} + + 0 ? 'active' : 'draft') + } + /> +
+ {visibleQualityTags(item.tags).map((tag) => ( + + {tag} + + ))} +
+ + + {formatQualityUpdatedAt(item.updated_at)} + + {canEdit ? ( + + $['newKnowledge.qualityPage.questionActions'], { + question: item.question, + })} + /> + + openEdit(item)}> + + {t(($) => $['newKnowledge.qualityPage.edit'])} + + + setDeleteIds(new Set([item.id]))} + > + + {t(($) => $['newKnowledge.qualityPage.delete'])} + + + + ) : ( + + )} +
+ ))} + {query.hasNextPage && ( +
+ +
+ )} +
+ ) : ( +
+ +

+ {t(($) => $['newKnowledge.qualityPage.goldenEmptyTitle'])} +

+

+ {t(($) => $['newKnowledge.qualityPage.goldenEmptyDescription'])} +

+ {canEdit && ( +
+ + +
+ )} +
+ )} +
+ + {canEdit && selected.size > 0 && ( +
+ + {t( + ($) => + $[ + selected.size === 1 + ? 'newKnowledge.qualityPage.selectedCount_one' + : 'newKnowledge.qualityPage.selectedCount_other' + ], + { count: selected.size }, + )} + + + + +
+ )} + + {dialog && ( + { + if (!open) { + setDialog(undefined) + setDialogError(undefined) + } + }} + onSubmit={submitDialog} + /> + )} + {importOpen && ( + + )} + { + if (!open && !deleteSubmitting) setDeleteIds(undefined) + }} + > + +
+ + {tCommon(($) => $['operation.deleteConfirmTitle'])} + + + {tCommon(($) => $['operation.confirmAction'])} + +
+ + + {tCommon(($) => $['operation.cancel'])} + + { + if (deleteIds) void deleteGolden(deleteIds) + }} + > + {tCommon(($) => $['operation.delete'])} + + +
+
+ + ) +} diff --git a/web/features/new-rag/quality/quality-model.ts b/web/features/new-rag/quality/quality-model.ts new file mode 100644 index 00000000000..3379ce37230 --- /dev/null +++ b/web/features/new-rag/quality/quality-model.ts @@ -0,0 +1,38 @@ +import type { GoldenQuestionDraft } from './types' + +const goldenLinkPrefix = 'golden-question:' + +export const qualityPageSize = 50 + +export const emptyGoldenQuestionDraft: GoldenQuestionDraft = { + annotation: '', + expectedEvidenceIds: [], + matchPolicy: 'all', + question: '', + tags: [], +} + +export function goldenQuestionPayload(draft: GoldenQuestionDraft) { + return { + annotation: draft.annotation, + expected_evidence_ids: draft.expectedEvidenceIds, + match_policy: draft.matchPolicy, + question: draft.question, + tags: draft.tags, + } +} + +export function visibleQualityTags(tags: string[]) { + return tags.filter((tag) => !tag.startsWith(goldenLinkPrefix)) +} + +export function formatQualityUpdatedAt(value: string) { + const time = new Date(value) + const elapsedHours = Math.max(0, (Date.now() - time.getTime()) / 3_600_000) + const relativeTime = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }) + if (elapsedHours < 1) return relativeTime.format(0, 'minute') + if (elapsedHours < 24) return relativeTime.format(-Math.floor(elapsedHours), 'hour') + const elapsedDays = Math.floor(elapsedHours / 24) + if (elapsedDays < 7) return relativeTime.format(-elapsedDays, 'day') + return new Intl.DateTimeFormat(undefined, { day: 'numeric', month: 'short' }).format(time) +} diff --git a/web/features/new-rag/quality/quality-page.tsx b/web/features/new-rag/quality/quality-page.tsx index 940be250ba9..7f661082210 100644 --- a/web/features/new-rag/quality/quality-page.tsx +++ b/web/features/new-rag/quality/quality-page.tsx @@ -1,479 +1,35 @@ 'use client' -import type { KnowledgeFsBadCaseResponse } from '@dify/contracts/api/console/knowledge-fs/types.gen' -import type { ReactNode } from 'react' -import type { GoldenQuestionDraft } from './types' -import { - AlertDialog, - AlertDialogActions, - AlertDialogCancelButton, - AlertDialogConfirmButton, - AlertDialogContent, - AlertDialogDescription, - AlertDialogTitle, -} from '@langgenius/dify-ui/alert-dialog' -import { Button } from '@langgenius/dify-ui/button' -import { Checkbox } from '@langgenius/dify-ui/checkbox' import { cn } from '@langgenius/dify-ui/cn' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from '@langgenius/dify-ui/dropdown-menu' -import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { Tabs, TabsPanel } from '@langgenius/dify-ui/tabs' -import { toast } from '@langgenius/dify-ui/toast' -import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import { useRef, useState } from 'react' +import { parseAsStringLiteral, useQueryState } from 'nuqs' +import { useState } from 'react' import { useTranslation } from 'react-i18next' -import Badge from '@/app/components/base/badge' -import Loading from '@/app/components/base/loading' -import { useRouter, useSearchParams } from '@/next/navigation' -import { consoleClient, consoleQuery } from '@/service/client' import { useKnowledgeSpacePermission } from '../knowledge-space-context' -import { newKnowledgeQualityPath, newKnowledgeRetrievalTestPath } from '../routes' -import { GoldenQuestionDialog } from './golden-question-dialog' -import { GoldenQuestionImportDialog } from './golden-question-import-dialog' +import { BadCasesPanel } from './bad-cases-panel' +import { GoldenQuestionsPanel } from './golden-questions-panel' import { QualityEvaluationPanel } from './quality-evaluation-panel' import { QualityTabList } from './quality-tab-list' -const emptyDraft: GoldenQuestionDraft = { - annotation: '', - expectedEvidenceIds: [], - matchPolicy: 'all', - question: '', - tags: [], -} -const goldenLinkPrefix = 'golden-question:' -const pageSize = 50 +type QualityTab = 'bad' | 'evaluation' | 'golden' -function visibleTags(tags: string[]) { - return tags.filter((tag) => !tag.startsWith(goldenLinkPrefix)) -} - -function Reason({ question, reason, tags }: { question?: string; reason: string; tags: string[] }) { - const { t } = useTranslation('dataset') - const normalized = reason.toLowerCase() - if (normalized === 'low-score' || (normalized.includes('low') && normalized.includes('score'))) - return t(($) => $['newKnowledge.qualityPage.reasonValues.lowScore']) - if (normalized.includes('outdated')) - return t(($) => $['newKnowledge.qualityPage.reasonValues.outdatedContent']) - if ( - (tags.includes('retrieval-test') && reason.trim() === question?.trim()) || - normalized.includes('retrieval') || - normalized.includes('miss') - ) - return t(($) => $['newKnowledge.qualityPage.reasonValues.retrievalMiss']) - if (normalized.includes('coverage') || normalized.includes('evidence')) - return t(($) => $['newKnowledge.qualityPage.reasonValues.coverageGap']) - return reason -} - -function RowMenuTrigger({ disabled, label }: { disabled?: boolean; label: string }) { - return ( - - - - ) -} - -function Status({ status }: { status: KnowledgeFsBadCaseResponse['status'] }) { - const { t } = useTranslation('dataset') - const visibleStatus = status === 'dismissed' ? 'fixed' : status - return ( -
- - {t(($) => $[`newKnowledge.qualityPage.status.${visibleStatus}`])} -
- ) -} - -function GoldenStatus({ status }: { status: 'active' | 'draft' | 'stale' }) { - const { t } = useTranslation('dataset') - return ( - - {t(($) => $[`newKnowledge.qualityPage.goldenStatus.${status}`])} - - ) -} - -function GoldenAnnotation({ annotation }: { annotation: string }) { - if (!annotation.trim()) return - - return ( - - - {annotation} - - } - /> - -

{annotation}

-
-
- ) -} - -function QualityQueryState({ - children, - error, - loading, - onRetry, -}: { - children: ReactNode - error: boolean - loading: boolean - onRetry: () => void -}) { - const { t } = useTranslation('dataset') - - if (loading) - return ( -
- -
- ) - - if (error) - return ( -
- -

- {t(($) => $.unknownError)} -

- -
- ) - - return children -} - -function goldenQuestionPayload(draft: GoldenQuestionDraft) { - return { - annotation: draft.annotation, - expected_evidence_ids: draft.expectedEvidenceIds, - match_policy: draft.matchPolicy, - question: draft.question, - tags: draft.tags, - } -} +const qualityTabParser = parseAsStringLiteral(['bad-cases', 'evaluations'] as const).withOptions({ + history: 'replace', +}) export function QualityPage({ knowledgeSpaceId }: { knowledgeSpaceId: string }) { const { t } = useTranslation('dataset') - const { t: tCommon } = useTranslation('common') - const router = useRouter() - const searchParams = useSearchParams() - const queryClient = useQueryClient() const canEdit = useKnowledgeSpacePermission('knowledge_space_edit') - const requestedTab = searchParams.get('tab') - const activeTab = - requestedTab === 'bad-cases' ? 'bad' : requestedTab === 'evaluations' ? 'evaluation' : 'golden' - const [selected, setSelected] = useState>(() => new Set()) - const [deleteIds, setDeleteIds] = useState>() - const [deleteSubmitting, setDeleteSubmitting] = useState(false) - const [dialogError, setDialogError] = useState() - const [dialogSubmitting, setDialogSubmitting] = useState(false) - const [evaluationActionSlot, setEvaluationActionSlot] = useState(null) + const [queryTab, setQueryTab] = useQueryState('tab', qualityTabParser) + const activeTab: QualityTab = + queryTab === 'bad-cases' ? 'bad' : queryTab === 'evaluations' ? 'evaluation' : 'golden' + const [actionSlot, setActionSlot] = useState(null) const [selectedEvaluationRunId, setSelectedEvaluationRunId] = useState() - const [importOpen, setImportOpen] = useState(false) - const [pendingBadCaseId, setPendingBadCaseId] = useState() - const promotedGoldenQuestionIdsRef = useRef(new Map()) - const [dialog, setDialog] = useState< - | { key: string; mode: 'create'; value: GoldenQuestionDraft } - | { id: string; key: string; mode: 'edit'; value: GoldenQuestionDraft } - | { id: string; key: string; mode: 'promote'; value: GoldenQuestionDraft } - >() - const goldenQueryOptions = - consoleQuery.knowledgeFs.spaces.byControlSpaceId.goldenQuestions.get.infiniteOptions({ - input: (pageParam) => ({ - params: { control_space_id: knowledgeSpaceId }, - query: { - limit: pageSize, - ...(typeof pageParam === 'string' ? { cursor: pageParam } : {}), - }, - }), - getNextPageParam: (lastPage) => lastPage.next_cursor ?? undefined, - initialPageParam: null as string | null, - }) - const badCaseQueryOptions = - consoleQuery.knowledgeFs.spaces.byControlSpaceId.quality.badCases.get.infiniteOptions({ - input: (pageParam) => ({ - params: { control_space_id: knowledgeSpaceId }, - query: { - limit: pageSize, - ...(typeof pageParam === 'string' ? { cursor: pageParam } : {}), - }, - }), - getNextPageParam: (lastPage) => lastPage.next_cursor ?? undefined, - initialPageParam: null as string | null, - }) - const goldenQuery = useInfiniteQuery({ - ...goldenQueryOptions, - enabled: activeTab === 'golden', - }) - const badCaseQuery = useInfiniteQuery({ - ...badCaseQueryOptions, - enabled: activeTab === 'bad', - }) - const createGoldenMutation = useMutation( - consoleQuery.knowledgeFs.spaces.byControlSpaceId.goldenQuestions.post.mutationOptions(), - ) - const updateGoldenMutation = useMutation( - consoleQuery.knowledgeFs.spaces.byControlSpaceId.goldenQuestions.byQuestionId.patch.mutationOptions(), - ) - const deleteGoldenMutation = useMutation( - consoleQuery.knowledgeFs.spaces.byControlSpaceId.goldenQuestions.byQuestionId.delete.mutationOptions(), - ) - const goldenQuestions = goldenQuery.data?.pages.flatMap((page) => page.data) ?? [] - const badCases = (badCaseQuery.data?.pages.flatMap((page) => page.data) ?? []).filter( - (item) => item.status !== 'dismissed', - ) - const allSelected = goldenQuestions.length > 0 && selected.size === goldenQuestions.length - const partiallySelected = selected.size > 0 && !allSelected - const setTab = (tab: 'bad' | 'evaluation' | 'golden') => { - if (tab !== 'golden') setSelected(new Set()) + + const setTab = (tab: QualityTab) => { if (tab !== 'evaluation') setSelectedEvaluationRunId(undefined) - router.replace( - tab === 'bad' - ? `${newKnowledgeQualityPath(knowledgeSpaceId)}?tab=bad-cases` - : tab === 'evaluation' - ? `${newKnowledgeQualityPath(knowledgeSpaceId)}?tab=evaluations` - : newKnowledgeQualityPath(knowledgeSpaceId), - ) + void setQueryTab(tab === 'bad' ? 'bad-cases' : tab === 'evaluation' ? 'evaluations' : null) } - const updated = (value: string) => { - const time = new Date(value) - const elapsedHours = Math.max(0, (Date.now() - time.getTime()) / 3_600_000) - const relativeTime = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }) - if (elapsedHours < 1) return relativeTime.format(0, 'minute') - if (elapsedHours < 24) return relativeTime.format(-Math.floor(elapsedHours), 'hour') - const elapsedDays = Math.floor(elapsedHours / 24) - if (elapsedDays < 7) return relativeTime.format(-elapsedDays, 'day') - return new Intl.DateTimeFormat(undefined, { day: 'numeric', month: 'short' }).format(time) - } - - const invalidateQuality = async () => { - await Promise.all([ - queryClient.invalidateQueries({ queryKey: goldenQueryOptions.queryKey }), - queryClient.invalidateQueries({ queryKey: badCaseQueryOptions.queryKey }), - ]) - } - - const getBadCase = (badCaseId: string) => - consoleClient.knowledgeFs.spaces.byControlSpaceId.quality.badCases.byBadCaseId.get({ - params: { bad_case_id: badCaseId, control_space_id: knowledgeSpaceId }, - }) - - const markBadCaseDismissed = async ( - badCase: KnowledgeFsBadCaseResponse, - tags: string[] = badCase.tags, - ) => { - try { - await consoleClient.knowledgeFs.spaces.byControlSpaceId.quality.badCases.byBadCaseId.patch({ - body: { - expected_revision: badCase.revision, - status: 'dismissed', - tags, - }, - params: { bad_case_id: badCase.id, control_space_id: knowledgeSpaceId }, - }) - } catch (error) { - const refreshed = await getBadCase(badCase.id).catch(() => undefined) - if (refreshed?.status !== 'dismissed') throw error - } - } - - const submitDialog = async (draft: GoldenQuestionDraft) => { - if (!dialog) return - setDialogError(undefined) - setDialogSubmitting(true) - try { - if (dialog.mode === 'edit') { - await updateGoldenMutation.mutateAsync({ - body: goldenQuestionPayload(draft), - params: { control_space_id: knowledgeSpaceId, question_id: dialog.id }, - }) - toast.success(t(($) => $['newKnowledge.qualityPage.updatedToast'])) - } else if (dialog.mode === 'create') { - await createGoldenMutation.mutateAsync({ - body: goldenQuestionPayload(draft), - params: { control_space_id: knowledgeSpaceId }, - }) - toast.success(t(($) => $['newKnowledge.qualityPage.createdToast'])) - } else { - const badCase = await getBadCase(dialog.id) - let goldenQuestionId = promotedGoldenQuestionIdsRef.current.get(badCase.id) - if (!goldenQuestionId) { - const createdGoldenQuestion = await createGoldenMutation.mutateAsync({ - body: { - ...goldenQuestionPayload(draft), - source_bad_case_id: badCase.id, - }, - params: { control_space_id: knowledgeSpaceId }, - }) - goldenQuestionId = createdGoldenQuestion.id - promotedGoldenQuestionIdsRef.current.set(badCase.id, goldenQuestionId) - } - try { - await markBadCaseDismissed(badCase, visibleTags(badCase.tags)) - promotedGoldenQuestionIdsRef.current.delete(badCase.id) - } catch (error) { - try { - await deleteGoldenMutation.mutateAsync({ - params: { control_space_id: knowledgeSpaceId, question_id: goldenQuestionId }, - }) - promotedGoldenQuestionIdsRef.current.delete(badCase.id) - } catch { - // Keep the created ID so a retry resumes dismissal instead of creating a duplicate. - } - throw error - } - toast.success(t(($) => $['newKnowledge.qualityPage.promotedToast'])) - } - await invalidateQuality() - setDialog(undefined) - } catch { - setDialogError(t(($) => $.unknownError)) - } finally { - setDialogSubmitting(false) - } - } - - const deleteGolden = async (ids: Set): Promise => { - setDeleteSubmitting(true) - try { - const results = await Promise.allSettled( - [...ids].map((questionId) => - deleteGoldenMutation - .mutateAsync({ - params: { control_space_id: knowledgeSpaceId, question_id: questionId }, - }) - .then(() => questionId), - ), - ) - const deletedIds = new Set( - results.flatMap((result) => (result.status === 'fulfilled' ? [result.value] : [])), - ) - const failedIds = new Set([...ids].filter((id) => !deletedIds.has(id))) - setSelected((current) => new Set([...current].filter((id) => !deletedIds.has(id)))) - await queryClient - .invalidateQueries({ queryKey: goldenQueryOptions.queryKey }) - .catch(() => undefined) - if (failedIds.size > 0) { - setDeleteIds(failedIds) - toast.error(t(($) => $.unknownError)) - return false - } - toast.success( - t( - ($) => - $[ - ids.size === 1 - ? 'newKnowledge.qualityPage.deletedToast_one' - : 'newKnowledge.qualityPage.deletedToast_other' - ], - { count: ids.size }, - ), - ) - setDeleteIds(undefined) - return true - } catch { - toast.error(t(($) => $.unknownError)) - return false - } finally { - setDeleteSubmitting(false) - } - } - - const replayBadCase = async (item: KnowledgeFsBadCaseResponse) => { - setPendingBadCaseId(item.id) - try { - const reference = - await consoleClient.knowledgeFs.spaces.byControlSpaceId.quality.badCases.byBadCaseId.traceReference.get( - { - params: { bad_case_id: item.id, control_space_id: knowledgeSpaceId }, - }, - ) - const search = new URLSearchParams({ - retest: reference.trace_id, - trace: reference.trace_id, - }) - router.push(`${newKnowledgeRetrievalTestPath(knowledgeSpaceId)}?${search.toString()}`) - } catch { - toast.error(t(($) => $.unknownError)) - } finally { - setPendingBadCaseId(undefined) - } - } - - const ignoreBadCase = async (item: KnowledgeFsBadCaseResponse) => { - setPendingBadCaseId(item.id) - try { - const badCase = await getBadCase(item.id) - await markBadCaseDismissed(badCase) - await queryClient.invalidateQueries({ queryKey: badCaseQueryOptions.queryKey }) - } catch { - toast.error(t(($) => $.unknownError)) - } finally { - setPendingBadCaseId(undefined) - } - } - - const openTrace = async (badCaseId: string) => { - try { - const reference = - await consoleClient.knowledgeFs.spaces.byControlSpaceId.quality.badCases.byBadCaseId.traceReference.get( - { - params: { bad_case_id: badCaseId, control_space_id: knowledgeSpaceId }, - }, - ) - router.push(`${newKnowledgeRetrievalTestPath(knowledgeSpaceId)}?trace=${reference.trace_id}`) - } catch { - toast.error(t(($) => $.unknownError)) - } - } - - const toggleAll = () => - setSelected(allSelected ? new Set() : new Set(goldenQuestions.map((item) => item.id))) - const toggleOne = (id: string) => - setSelected((current) => { - const next = new Set(current) - if (next.has(id)) next.delete(id) - else next.add(id) - return next - }) return (
)} - setTab(value as typeof activeTab)}> + + setTab(value as QualityTab)}> {!selectedEvaluationRunId && (
-
- {canEdit && activeTab === 'golden' && goldenQuestions.length > 0 && ( - <> - - - - )} -
+
)} - void goldenQuery.refetch()} - > - {goldenQuestions.length ? ( -
-
- $['newKnowledge.qualityPage.selectAll'])} - checked={allSelected} - disabled={!canEdit} - indeterminate={partiallySelected} - onCheckedChange={toggleAll} - /> - {t(($) => $['newKnowledge.qualityPage.question'])} - {t(($) => $['newKnowledge.qualityPage.statusLabel'])} - {t(($) => $['newKnowledge.qualityPage.tags'])} - {t(($) => $['newKnowledge.qualityPage.annotation'])} - {t(($) => $['newKnowledge.qualityPage.updated'])} - -
- {goldenQuestions.map((item) => ( -
- $['newKnowledge.qualityPage.selectQuestion'], { - question: item.question, - })} - checked={selected.has(item.id)} - disabled={!canEdit} - onCheckedChange={() => toggleOne(item.id)} - /> - - {item.question ?? ''} - - 0 ? 'active' : 'draft') - } - /> -
- {visibleTags(item.tags).map((tag) => ( - - - {tag} - - - ))} -
- - - {updated(item.updated_at)} - - {canEdit ? ( - - $['newKnowledge.qualityPage.questionActions'], { - question: item.question, - })} - /> - - - setDialog({ - id: item.id, - key: `edit-${item.id}-${Date.now()}`, - mode: 'edit', - value: { - annotation: item.annotation, - expectedEvidenceIds: item.expected_evidence_ids ?? [], - matchPolicy: item.match_policy ?? 'all', - question: item.question, - tags: item.tags, - }, - }) - } - > - - {t(($) => $['newKnowledge.qualityPage.edit'])} - - - setDeleteIds(new Set([item.id]))} - > - - {t(($) => $['newKnowledge.qualityPage.delete'])} - - - - ) : ( - - )} -
- ))} - {goldenQuery.hasNextPage && ( -
- -
- )} -
- ) : ( -
- -

- {t(($) => $['newKnowledge.qualityPage.goldenEmptyTitle'])} -

-

- {t(($) => $['newKnowledge.qualityPage.goldenEmptyDescription'])} -

- {canEdit && ( -
- - -
- )} -
- )} -
+ {activeTab === 'golden' && ( + + )}
- void badCaseQuery.refetch()} - > - {badCases.length ? ( -
-
- {t(($) => $['newKnowledge.qualityPage.question'])} - {t(($) => $['newKnowledge.qualityPage.statusLabel'])} - {t(($) => $['newKnowledge.qualityPage.reason'])} - {t(($) => $['newKnowledge.qualityPage.updated'])} - -
- {badCases.map((item) => ( -
- - {item.question} - - - - - - - {updated(item.updated_at)} - - - $['newKnowledge.qualityPage.questionActions'], { - question: item.question ?? '', - })} - /> - - {canEdit && - (item.status === 'fixed' ? ( - - setDialog({ - id: item.id, - key: `promote-${item.id}-${Date.now()}`, - mode: 'promote', - value: { - annotation: '', - expectedEvidenceIds: [], - matchPolicy: 'all', - question: item.question ?? '', - tags: visibleTags(item.tags), - }, - }) - } - > - - {t(($) => $['newKnowledge.qualityPage.toGolden'])} - - ) : ( - void replayBadCase(item)} - > - - {t(($) => $['newKnowledge.qualityPage.replay'])} - - ))} - void openTrace(item.id)} - > - - {t(($) => $['newKnowledge.qualityPage.openTrace'])} - - {canEdit && item.status !== 'fixed' && ( - - setDialog({ - id: item.id, - key: `promote-${item.id}-${Date.now()}`, - mode: 'promote', - value: { - annotation: '', - expectedEvidenceIds: [], - matchPolicy: 'all', - question: item.question ?? '', - tags: visibleTags(item.tags), - }, - }) - } - > - - {t(($) => $['newKnowledge.qualityPage.toGolden'])} - - )} - {canEdit && } - {canEdit && ( - void ignoreBadCase(item)} - > - - {t(($) => $['newKnowledge.qualityPage.ignore'])} - - )} - - -
- ))} - {badCaseQuery.hasNextPage && ( -
- -
- )} -
- ) : ( -
- -

- {t(($) => $['newKnowledge.qualityPage.badCasesEmptyTitle'])} -

-

- {t(($) => $['newKnowledge.qualityPage.badCasesEmptyDescription'])} -

- {badCaseQuery.hasNextPage && ( - - )} -
- )} -
+ {activeTab === 'bad' && ( + + )}
- + {activeTab === 'evaluation' && ( + + )} - - {canEdit && activeTab === 'golden' && selected.size > 0 && ( -
- - {t( - ($) => - $[ - selected.size === 1 - ? 'newKnowledge.qualityPage.selectedCount_one' - : 'newKnowledge.qualityPage.selectedCount_other' - ], - { count: selected.size }, - )} - - - - -
- )} - - {dialog && ( - { - if (!open) { - setDialog(undefined) - setDialogError(undefined) - } - }} - onSubmit={submitDialog} - /> - )} - {importOpen && ( - - )} - { - if (!open && !deleteSubmitting) setDeleteIds(undefined) - }} - > - -
- - {tCommon(($) => $['operation.deleteConfirmTitle'])} - - - {tCommon(($) => $['operation.confirmAction'])} - -
- - - {tCommon(($) => $['operation.cancel'])} - - { - if (deleteIds) void deleteGolden(deleteIds) - }} - > - {tCommon(($) => $['operation.delete'])} - - -
-
) } diff --git a/web/features/new-rag/quality/quality-query-state.tsx b/web/features/new-rag/quality/quality-query-state.tsx new file mode 100644 index 00000000000..c1308c30fd8 --- /dev/null +++ b/web/features/new-rag/quality/quality-query-state.tsx @@ -0,0 +1,38 @@ +import type { ReactNode } from 'react' +import { Button } from '@langgenius/dify-ui/button' +import { useTranslation } from 'react-i18next' +import Loading from '@/app/components/base/loading' + +export function QualityQueryState({ + children, + error, + loading, + onRetry, +}: { + children: ReactNode + error: boolean + loading: boolean + onRetry: () => void +}) { + const { t } = useTranslation('dataset') + + if (loading) + return ( +
+ +
+ ) + + if (error) + return ( +
+ +

+ {t(($) => $.unknownError)} +

+ +
+ ) + + return children +} diff --git a/web/features/new-rag/quality/quality-row-menu-trigger.tsx b/web/features/new-rag/quality/quality-row-menu-trigger.tsx new file mode 100644 index 00000000000..f7a49f1e5d1 --- /dev/null +++ b/web/features/new-rag/quality/quality-row-menu-trigger.tsx @@ -0,0 +1,13 @@ +import { DropdownMenuTrigger } from '@langgenius/dify-ui/dropdown-menu' + +export function QualityRowMenuTrigger({ disabled, label }: { disabled?: boolean; label: string }) { + return ( + + + + ) +}