fix(knowledge-fs): correct quality retest workflow

This commit is contained in:
Jyong 2026-08-19 03:03:20 -04:00
parent b20b6f3c18
commit 02151d0afa
30 changed files with 257 additions and 251 deletions

View File

@ -2141,6 +2141,11 @@ describe("database quality-control repository", () => {
await expect(
repositoryFor().repository.updateBadCase({ ...base, status: "fixed" }),
).rejects.toThrow("Invalid bad-case transition open -> fixed");
await expect(
repositoryFor({ badCase: { ...badCaseRow(), status: "fixed" } }).repository.updateBadCase(
base,
),
).resolves.toMatchObject({ revision: 2, status: "dismissed" });
await expect(
repositoryFor().repository.updateBadCase({ ...base, status: "replaying" }),
).rejects.toThrow("requires a replay run");

View File

@ -2058,7 +2058,7 @@ function mapHistoryEvent(row: DatabaseRow): QualityHistoryEvent {
function validateBadCaseTransition(from: QualityBadCaseState, to: QualityBadCaseState) {
const allowed: Readonly<Record<QualityBadCaseState, readonly QualityBadCaseState[]>> = {
dismissed: ["open"],
fixed: ["open", "replaying"],
fixed: ["dismissed", "open", "replaying"],
open: ["dismissed", "replaying"],
replaying: ["dismissed", "fixed", "open"],
};

View File

@ -8,7 +8,6 @@ import { QualityPage } from '../quality/quality-page'
const serviceMock = vi.hoisted(() => ({
bulkImport: vi.fn(),
createGolden: vi.fn(),
createReplay: vi.fn(),
deleteGolden: vi.fn(),
getBadCase: vi.fn(),
getBadCases: vi.fn(),
@ -52,7 +51,6 @@ vi.mock('@/service/client', () => ({
traceReference: { get: serviceMock.getTraceReference },
},
},
replayRuns: { post: serviceMock.createReplay },
},
},
},
@ -857,71 +855,33 @@ describe('QualityPage', () => {
).not.toBeInTheDocument()
})
it('reuses the replay idempotency key after a partial failure', async () => {
let linked = false
let rejectReplayPatch = true
serviceMock.getBadCase.mockImplementation(async () => ({
created_at: '2026-07-28T00:00:00Z',
id: 'bad-1',
question: 'Refund after activation',
reason: 'coverage gap',
replay_run_id: null,
revision: linked ? 2 : 1,
status: 'open',
tags: linked ? ['billing', 'golden-question:golden-2'] : ['billing'],
updated_at: '2026-07-28T00:00:00Z',
}))
serviceMock.updateBadCase.mockImplementation(
async (input: { body: { status: string; tags?: string[] } }) => {
if (input.body.status === 'open') {
linked = true
return {
...(await serviceMock.getBadCase()),
revision: 2,
tags: input.body.tags,
}
}
if (rejectReplayPatch) {
rejectReplayPatch = false
throw new Error('response lost')
}
return {
...(await serviceMock.getBadCase()),
replay_run_id: 'replay-1',
revision: 3,
status: 'replaying',
}
},
)
serviceMock.createReplay.mockResolvedValue({ id: 'replay-1', revision: 1, state: 'queued' })
it('opens the source trace in retrieval test and requests one retest', async () => {
navigationMock.tab = 'bad-cases'
const user = userEvent.setup()
renderPage()
await screen.findByText('Refund after activation')
const replay = async () => {
await user.click(
screen.getByRole('button', {
name: /dataset\.newKnowledge\.qualityPage\.questionActions/,
}),
)
await user.click(
await screen.findByRole('menuitem', {
name: 'dataset.newKnowledge.qualityPage.replay',
}),
)
}
await replay()
await waitFor(() => expect(serviceMock.createReplay).toHaveBeenCalledTimes(1))
await replay()
await waitFor(() => expect(serviceMock.createReplay).toHaveBeenCalledTimes(2))
await user.click(
screen.getByRole('button', {
name: /dataset\.newKnowledge\.qualityPage\.questionActions/,
}),
)
await user.click(
await screen.findByRole('menuitem', {
name: 'dataset.newKnowledge.qualityPage.replay',
}),
)
expect(serviceMock.createGolden).toHaveBeenCalledTimes(2)
const firstHeaders = serviceMock.createReplay.mock.calls[0]?.[0].headers
expect(serviceMock.createReplay.mock.calls[1]?.[0].headers).toEqual(firstHeaders)
await waitFor(() =>
expect(routerMock.push).toHaveBeenCalledWith(
'/datasets/new/space-1/retrieval?retest=trace-42&trace=trace-42',
),
)
expect(serviceMock.createGolden).not.toHaveBeenCalled()
expect(serviceMock.updateBadCase).not.toHaveBeenCalled()
})
it('replaces a stale golden-question link before replaying a bad case', async () => {
it('creates a golden question and dismisses its source bad case', async () => {
serviceMock.getBadCase.mockResolvedValue({
created_at: '2026-07-28T00:00:00Z',
id: 'bad-1',
@ -941,20 +901,12 @@ describe('QualityPage', () => {
tags: ['billing'],
updated_at: '2026-07-29T00:00:00Z',
})
serviceMock.updateBadCase
.mockResolvedValueOnce({
...(await serviceMock.getBadCase()),
revision: 2,
tags: ['billing', 'golden-question:replacement-golden'],
})
.mockResolvedValueOnce({
...(await serviceMock.getBadCase()),
replay_run_id: 'replay-1',
revision: 3,
status: 'replaying',
tags: ['billing', 'golden-question:replacement-golden'],
})
serviceMock.createReplay.mockResolvedValue({ id: 'replay-1', revision: 1, state: 'queued' })
serviceMock.updateBadCase.mockResolvedValue({
...(await serviceMock.getBadCase()),
revision: 2,
status: 'dismissed',
tags: ['billing'],
})
navigationMock.tab = 'bad-cases'
const user = userEvent.setup()
renderPage()
@ -967,20 +919,21 @@ describe('QualityPage', () => {
)
await user.click(
await screen.findByRole('menuitem', {
name: 'dataset.newKnowledge.qualityPage.replay',
name: 'dataset.newKnowledge.qualityPage.toGolden',
}),
)
await waitFor(() =>
expect(serviceMock.createReplay).toHaveBeenCalledWith(
expect.objectContaining({
body: { golden_question_ids: ['replacement-golden'] },
}),
),
await user.type(
screen.getByPlaceholderText('dataset.newKnowledge.qualityPage.annotationPlaceholder'),
'Expected answer',
)
await user.click(
screen.getByRole('button', { name: 'dataset.newKnowledge.qualityPage.promote' }),
)
await waitFor(() => expect(serviceMock.createGolden).toHaveBeenCalledTimes(1))
expect(serviceMock.createGolden.mock.calls[0]?.[0]).toEqual({
body: {
annotation: 'coverage gap',
annotation: 'Expected answer',
expected_evidence_ids: [],
match_policy: 'all',
question: 'Refund after activation',
@ -992,8 +945,8 @@ describe('QualityPage', () => {
expect(serviceMock.updateBadCase.mock.calls[0]?.[0]).toEqual({
body: {
expected_revision: 1,
status: 'open',
tags: ['billing', 'golden-question:replacement-golden'],
status: 'dismissed',
tags: ['billing'],
},
params: { bad_case_id: 'bad-1', control_space_id: 'space-1' },
})

View File

@ -1488,11 +1488,17 @@ describe('RetrievalTestPage', () => {
})
expect(makeBadCaseButton).toHaveClass('bg-components-button-secondary-bg')
await user.click(makeBadCaseButton)
expect(apiMock.createBadCase).not.toHaveBeenCalled()
await user.click(
await screen.findByRole('menuitem', {
name: 'dataset.newKnowledge.qualityPage.reasonValues.lowScore',
}),
)
await waitFor(() =>
expect(apiMock.createBadCase).toHaveBeenCalledWith({
body: {
reason: 'retrieval-miss',
reason: 'low-score',
tags: ['retrieval-test'],
trace_id: 'trace-1',
},
@ -1609,6 +1615,11 @@ describe('RetrievalTestPage', () => {
name: 'dataset.newKnowledge.retrievalTest.makeBadCase',
}),
)
await user.click(
await screen.findByRole('menuitem', {
name: 'dataset.newKnowledge.qualityPage.reasonValues.retrievalMiss',
}),
)
await waitFor(() =>
expect(apiMock.createBadCase).toHaveBeenCalledWith({
@ -1622,6 +1633,31 @@ describe('RetrievalTestPage', () => {
)
})
it('runs a one-shot retest from a linked production trace', async () => {
apiMock.traceDetail = {
completed: true,
created_at: '2026-07-01T00:00:00.000Z',
id: 'trace-old',
mode: 'deep',
profile: {},
query: 'Retest the refund exception',
scores: {},
stages: [],
}
const { onUrlUpdate } = renderPage({
searchParams: '?trace=trace-old&retest=trace-old',
})
await waitFor(() =>
expect(apiMock.queryAdmission).toHaveBeenCalledWith({
body: { mode: 'deep', query: 'Retest the refund exception' },
params: { control_space_id: 'space-1' },
}),
)
expect(apiMock.queryAdmission).toHaveBeenCalledTimes(1)
expect(onUrlUpdate).toHaveBeenCalled()
})
it('opens retrieval evidence through its logical document instead of its asset', async () => {
apiMock.traces = [
{

View File

@ -24,7 +24,7 @@ import {
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 { useRef, useState } from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Badge from '@/app/components/base/badge'
import Loading from '@/app/components/base/loading'
@ -44,23 +44,15 @@ const emptyDraft: GoldenQuestionDraft = {
const goldenLinkPrefix = 'golden-question:'
const pageSize = 50
function createIdempotencyKey() {
return `quality-replay-${
globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`
}`
}
function visibleTags(tags: string[]) {
return tags.filter((tag) => !tag.startsWith(goldenLinkPrefix))
}
function linkedGoldenQuestionId(tags: string[]) {
return tags.find((tag) => tag.startsWith(goldenLinkPrefix))?.slice(goldenLinkPrefix.length)
}
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 (
@ -148,8 +140,6 @@ export function QualityPage({ knowledgeSpaceId }: { knowledgeSpaceId: string })
const [dialogSubmitting, setDialogSubmitting] = useState(false)
const [importOpen, setImportOpen] = useState(false)
const [pendingBadCaseId, setPendingBadCaseId] = useState<string>()
const replayIdempotencyKeysRef = useRef(new Map<string, string>())
const pendingGoldenQuestionIdsRef = useRef(new Map<string, string>())
const [dialog, setDialog] = useState<
| { key: string; mode: 'create'; value: GoldenQuestionDraft }
| { id: string; key: string; mode: 'edit'; value: GoldenQuestionDraft }
@ -227,52 +217,6 @@ export function QualityPage({ knowledgeSpaceId }: { knowledgeSpaceId: string })
params: { bad_case_id: badCaseId, control_space_id: knowledgeSpaceId },
})
const ensureLinkedGoldenQuestion = async (
item: KnowledgeFsBadCaseResponse,
draft: GoldenQuestionDraft,
) => {
const current = await getBadCase(item.id)
const linkedId = linkedGoldenQuestionId(current.tags)
let goldenQuestionId = pendingGoldenQuestionIdsRef.current.get(item.id)
if (!goldenQuestionId) {
const created = await createGoldenMutation.mutateAsync({
body: {
...goldenQuestionPayload(draft),
source_bad_case_id: item.id,
},
params: { control_space_id: knowledgeSpaceId },
})
goldenQuestionId = created.id
pendingGoldenQuestionIdsRef.current.set(item.id, goldenQuestionId)
}
if (linkedId === goldenQuestionId) {
pendingGoldenQuestionIdsRef.current.delete(item.id)
return { badCase: current, goldenQuestionId }
}
try {
const badCase =
await consoleClient.knowledgeFs.spaces.byControlSpaceId.quality.badCases.byBadCaseId.patch({
body: {
expected_revision: current.revision,
status: current.status,
tags: [...visibleTags(current.tags), `${goldenLinkPrefix}${goldenQuestionId}`],
},
params: { bad_case_id: current.id, control_space_id: knowledgeSpaceId },
})
pendingGoldenQuestionIdsRef.current.delete(item.id)
return { badCase, goldenQuestionId }
} catch (error) {
const refreshed = await getBadCase(item.id).catch(() => undefined)
if (refreshed && linkedGoldenQuestionId(refreshed.tags) === goldenQuestionId) {
pendingGoldenQuestionIdsRef.current.delete(item.id)
return { badCase: refreshed, goldenQuestionId }
}
throw error
}
}
const submitDialog = async (draft: GoldenQuestionDraft) => {
if (!dialog) return
setDialogError(undefined)
@ -291,9 +235,29 @@ export function QualityPage({ knowledgeSpaceId }: { knowledgeSpaceId: string })
})
toast.success(t(($) => $['newKnowledge.qualityPage.createdToast']))
} else {
const badCase = badCases.find((item) => item.id === dialog.id)
if (!badCase) throw new Error('Bad case is unavailable')
await ensureLinkedGoldenQuestion(badCase, draft)
const badCase = await getBadCase(dialog.id)
await createGoldenMutation.mutateAsync({
body: {
...goldenQuestionPayload(draft),
source_bad_case_id: badCase.id,
},
params: { control_space_id: knowledgeSpaceId },
})
try {
await consoleClient.knowledgeFs.spaces.byControlSpaceId.quality.badCases.byBadCaseId.patch(
{
body: {
expected_revision: badCase.revision,
status: 'dismissed',
tags: visibleTags(badCase.tags),
},
params: { bad_case_id: badCase.id, control_space_id: knowledgeSpaceId },
},
)
} catch (error) {
const refreshed = await getBadCase(dialog.id).catch(() => undefined)
if (refreshed?.status !== 'dismissed') throw error
}
toast.success(t(($) => $['newKnowledge.qualityPage.promotedToast']))
}
await invalidateQuality()
@ -341,43 +305,18 @@ export function QualityPage({ knowledgeSpaceId }: { knowledgeSpaceId: string })
const replayBadCase = async (item: KnowledgeFsBadCaseResponse) => {
setPendingBadCaseId(item.id)
try {
const { badCase, goldenQuestionId } = await ensureLinkedGoldenQuestion(item, {
annotation: item.reason,
expectedEvidenceIds: [],
matchPolicy: 'all',
question: item.question ?? '',
tags: visibleTags(item.tags),
})
let idempotencyKey = replayIdempotencyKeysRef.current.get(item.id)
if (!idempotencyKey) {
idempotencyKey = createIdempotencyKey()
replayIdempotencyKeysRef.current.set(item.id, idempotencyKey)
}
const replay =
await consoleClient.knowledgeFs.spaces.byControlSpaceId.quality.replayRuns.post({
body: { golden_question_ids: [goldenQuestionId] },
headers: { 'Idempotency-Key': idempotencyKey },
params: { control_space_id: knowledgeSpaceId },
})
try {
await consoleClient.knowledgeFs.spaces.byControlSpaceId.quality.badCases.byBadCaseId.patch({
body: {
expected_revision: badCase.revision,
replay_run_id: replay.id,
status: 'replaying',
tags: badCase.tags,
const reference =
await consoleClient.knowledgeFs.spaces.byControlSpaceId.quality.badCases.byBadCaseId.traceReference.get(
{
params: { bad_case_id: item.id, control_space_id: knowledgeSpaceId },
},
params: { bad_case_id: badCase.id, control_space_id: knowledgeSpaceId },
})
} catch (error) {
const current = await getBadCase(item.id).catch(() => undefined)
if (current?.replay_run_id !== replay.id) throw error
}
replayIdempotencyKeysRef.current.delete(item.id)
await invalidateQuality()
toast.success(t(($) => $['newKnowledge.qualityPage.replayStartedToast']))
)
const search = new URLSearchParams({
retest: reference.trace_id,
trace: reference.trace_id,
})
router.push(`${newKnowledgeRetrievalTestPath(knowledgeSpaceId)}?${search.toString()}`)
} catch {
await invalidateQuality()
toast.error(t(($) => $.unknownError))
} finally {
setPendingBadCaseId(undefined)

View File

@ -17,11 +17,17 @@ import type { ResearchTaskProgressEvent } from './services/research-task-events'
import type { MarkdownProps } from '@/app/components/base/markdown'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { toast } from '@langgenius/dify-ui/toast'
import { matchesKeyboardEvent } from '@tanstack/react-hotkeys'
import { skipToken, useInfiniteQuery, useQuery, useQueryClient } from '@tanstack/react-query'
import { parseAsString, useQueryStates } from 'nuqs'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Markdown } from '@/app/components/base/markdown'
import { Link as MarkdownLink } from '@/app/components/base/markdown-blocks'
@ -75,6 +81,8 @@ type ComposerDraft = {
type QualityDecision = 'bad-case' | 'golden'
type BadCaseReason = 'low-score' | 'retrieval-miss'
type ResearchExpansionState = Partial<Record<'active' | 'terminal', boolean>>
type GoldenQuestionPromotion = {
@ -83,8 +91,6 @@ type GoldenQuestionPromotion = {
value: GoldenQuestionDraft
}
const retrievalTestBadCaseReason = 'retrieval-miss'
const researchStageOrder = ['planning', 'retrieving', 'analyzing', 'generating'] as const
type ResearchStage = (typeof researchStageOrder)[number]
const runRetrievalHotkey = 'Mod+Enter' satisfies Hotkey
@ -114,6 +120,11 @@ function timeValue(value: number) {
return value < 10_000_000_000 ? value * 1000 : value
}
function normalizedRetrievalTestMode(mode?: string): RetrievalTestMode {
if (mode === 'deep' || mode === 'research') return mode
return 'fast'
}
function formatRecordTime(value: number) {
return new Intl.DateTimeFormat(undefined, {
day: 'numeric',
@ -736,14 +747,16 @@ function QualityActions({
badCaseAvailable,
decision,
noResults,
onDecision,
onBadCase,
onGolden,
pending,
qualityHref,
}: {
badCaseAvailable: boolean
decision?: QualityDecision
noResults?: boolean
onDecision: (decision: QualityDecision) => Promise<void>
onBadCase: (reason: BadCaseReason) => Promise<void>
onGolden: () => void
pending?: boolean
qualityHref: string
}) {
@ -776,22 +789,30 @@ function QualityActions({
return (
<div className="flex shrink-0 items-center justify-end gap-3 border-t border-divider-regular pt-4 pb-1">
{badCaseAvailable && (
<Button
disabled={pending}
loading={pending}
variant={noResults ? 'secondary' : 'ghost'}
onClick={() => void onDecision('bad-case')}
>
<span aria-hidden className="i-ri-thumb-down-line size-4" />
{t(($) => $['newKnowledge.retrievalTest.makeBadCase'])}
</Button>
<DropdownMenu>
<DropdownMenuTrigger
disabled={pending}
render={<Button loading={pending} variant={noResults ? 'secondary' : 'ghost'} />}
>
<span aria-hidden className="i-ri-thumb-down-line size-4" />
{t(($) => $['newKnowledge.retrievalTest.makeBadCase'])}
</DropdownMenuTrigger>
<DropdownMenuContent placement="top-end" sideOffset={4} popupClassName="w-44">
<DropdownMenuItem onClick={() => void onBadCase('low-score')}>
{t(($) => $['newKnowledge.qualityPage.reasonValues.lowScore'])}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => void onBadCase('retrieval-miss')}>
{t(($) => $['newKnowledge.qualityPage.reasonValues.retrievalMiss'])}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
{!noResults && (
<Button
disabled={pending}
loading={pending}
variant="secondary"
onClick={() => void onDecision('golden')}
onClick={() => void onGolden()}
>
<span aria-hidden className="i-ri-thumb-up-line size-4" />
{t(($) => $['newKnowledge.retrievalTest.keepGoldenQuestion'])}
@ -1089,9 +1110,14 @@ export function RetrievalTestPage({ knowledgeSpaceId }: { knowledgeSpaceId: stri
} = useKnowledgeModelSetupGuard(knowledgeSpaceId)
const [linkedSelection, setLinkedSelection] = useQueryStates({
research: parseAsString,
retest: parseAsString,
trace: parseAsString,
})
const { research: linkedResearchId, trace: linkedTraceId } = linkedSelection
const {
research: linkedResearchId,
retest: linkedRetestTraceId,
trace: linkedTraceId,
} = linkedSelection
const [composerDraft, setComposerDraft] = useState<ComposerDraft>({ mode: 'fast', query: '' })
const [localRun, setLocalRun] = useState<LocalQueryRun>()
const [localSelected, setLocalSelected] = useState<SelectedRun>()
@ -1118,6 +1144,7 @@ export function RetrievalTestPage({ knowledgeSpaceId }: { knowledgeSpaceId: stri
taskId: string
}>()
const queryAbortControllerRef = useRef<AbortController>(undefined)
const consumedRetestTraceIdRef = useRef<string | undefined>(undefined)
const runInFlightRef = useRef(false)
useEffect(
@ -1237,37 +1264,12 @@ export function RetrievalTestPage({ knowledgeSpaceId }: { knowledgeSpaceId: stri
? (selectedResearchTaskFromHistory ?? researchDetailQuery.data)
: undefined
const selectedHistoryRecord = selected?.kind === 'local' ? undefined : selectedRecord
const query =
composerDraft.selectionKey === selectedHistoryKey
? composerDraft.query
: (selectedHistoryRecord?.query ?? selectedResearchTask?.query ?? '')
const mode =
composerDraft.selectionKey === selectedHistoryKey
? composerDraft.mode
: (selectedHistoryRecord?.mode ??
(selectedResearchTask?.mode === 'research'
? 'research'
: selectedResearchTask?.mode === 'deep'
? 'deep'
: 'fast'))
const selectedResearchActive = researchTaskIsActive(selectedResearchTask)
const selectedResearchActiveRef = useRef(selectedResearchActive)
useEffect(() => {
selectedResearchActiveRef.current = selectedResearchActive
}, [selectedResearchActive])
const selectedResearchDefaultExpanded = researchTaskIsActive(selectedResearchTask)
const selectedResearchExpansionPhase = selectedResearchDefaultExpanded ? 'active' : 'terminal'
const selectedResearchExpanded = selectedResearchTask
? (researchExpanded[selectedResearchTask.id]?.[selectedResearchExpansionPhase] ??
selectedResearchDefaultExpanded)
: false
const selectedTraceId =
selected?.kind === 'trace'
? selected.id
: selected?.kind === 'local'
? localRun?.traceId
: undefined
const traceDetailQuery = useQuery({
...consoleQuery.knowledgeFs.spaces.byControlSpaceId.traces.byTraceId.get.queryOptions({
input: {
@ -1279,6 +1281,30 @@ export function RetrievalTestPage({ knowledgeSpaceId }: { knowledgeSpaceId: stri
}),
enabled: Boolean(selectedTraceId) && !selectedFailed,
})
const query =
composerDraft.selectionKey === selectedHistoryKey
? composerDraft.query
: (selectedHistoryRecord?.query ??
selectedResearchTask?.query ??
traceDetailQuery.data?.query ??
'')
const mode: RetrievalTestMode =
composerDraft.selectionKey === selectedHistoryKey
? composerDraft.mode
: normalizedRetrievalTestMode(
selectedHistoryRecord?.mode ?? selectedResearchTask?.mode ?? traceDetailQuery.data?.mode,
)
const selectedResearchActive = researchTaskIsActive(selectedResearchTask)
const selectedResearchActiveRef = useRef(selectedResearchActive)
useEffect(() => {
selectedResearchActiveRef.current = selectedResearchActive
}, [selectedResearchActive])
const selectedResearchDefaultExpanded = researchTaskIsActive(selectedResearchTask)
const selectedResearchExpansionPhase = selectedResearchDefaultExpanded ? 'active' : 'terminal'
const selectedResearchExpanded = selectedResearchTask
? (researchExpanded[selectedResearchTask.id]?.[selectedResearchExpansionPhase] ??
selectedResearchDefaultExpanded)
: false
const traceEvidenceQuery = useInfiniteQuery({
...consoleQuery.knowledgeFs.spaces.byControlSpaceId.traces.byTraceId.evidence.get.infiniteOptions(
{
@ -1573,12 +1599,13 @@ export function RetrievalTestPage({ knowledgeSpaceId }: { knowledgeSpaceId: stri
const selectRecord = (record: RetrievalTestRecord) => {
if (record.kind === 'local') {
setLocalSelected({ id: record.id, kind: record.kind })
void setLinkedSelection({ research: null, trace: null }, { history: 'push' })
void setLinkedSelection({ research: null, retest: null, trace: null }, { history: 'push' })
} else {
setLocalSelected(undefined)
void setLinkedSelection(
{
research: record.kind === 'research' ? record.id : null,
retest: null,
trace: record.kind === 'trace' ? record.id : null,
},
{ history: 'push', shallow: false },
@ -1592,23 +1619,24 @@ export function RetrievalTestPage({ knowledgeSpaceId }: { knowledgeSpaceId: stri
setShowAll(false)
}
const saveQualityDecision = async (decision: QualityDecision) => {
const startGoldenPromotion = () => {
if (!resultKey || !selectedQuery) return
setGoldenPromotionError(undefined)
setGoldenPromotion({
evidenceOptions: goldenQuestionEvidenceOptions(currentEvidence),
resultKey,
value: {
annotation: '',
expectedEvidenceIds: [],
matchPolicy: 'all',
question: selectedQuery,
tags: ['retrieval-test'],
},
})
}
const saveBadCase = async (reason: BadCaseReason) => {
if (!resultKey || !selectedQuery) return
if (decision === 'golden') {
setGoldenPromotionError(undefined)
setGoldenPromotion({
evidenceOptions: goldenQuestionEvidenceOptions(currentEvidence),
resultKey,
value: {
annotation: '',
expectedEvidenceIds: [],
matchPolicy: 'all',
question: selectedQuery,
tags: ['retrieval-test'],
},
})
return
}
setQualityPendingKey(resultKey)
try {
if (!selectedTraceId) {
@ -1617,13 +1645,13 @@ export function RetrievalTestPage({ knowledgeSpaceId }: { knowledgeSpaceId: stri
}
await consoleClient.knowledgeFs.spaces.byControlSpaceId.quality.badCases.post({
body: {
reason: retrievalTestBadCaseReason,
reason,
tags: ['retrieval-test'],
trace_id: selectedTraceId,
},
params: { control_space_id: knowledgeSpaceId },
})
setQualityDecisions((current) => ({ ...current, [resultKey]: decision }))
setQualityDecisions((current) => ({ ...current, [resultKey]: 'bad-case' }))
} catch {
toast.error(t(($) => $.unknownError))
} finally {
@ -1662,11 +1690,12 @@ export function RetrievalTestPage({ knowledgeSpaceId }: { knowledgeSpaceId: stri
}
}
const runFastQuery = async () => {
const cleanQuery = query.trim()
const runFastQuery = async (input?: { mode: RetrievalTestMode; query: string }) => {
const cleanQuery = (input?.query ?? query).trim()
if (!cleanQuery || runInFlightRef.current) return
runInFlightRef.current = true
const runMode = mode === 'deep' ? 'deep' : 'fast'
const requestedMode = input?.mode ?? mode
const runMode = requestedMode === 'deep' ? 'deep' : 'fast'
if (
(
await ensureModelReady({
@ -1693,7 +1722,7 @@ export function RetrievalTestPage({ knowledgeSpaceId }: { knowledgeSpaceId: stri
status: 'running',
})
setLocalSelected({ id, kind: 'local' })
void setLinkedSelection({ research: null, trace: null }, { history: 'replace' })
void setLinkedSelection({ research: null, retest: null, trace: null }, { history: 'replace' })
setShowAll(false)
const events: KnowledgeQueryEvent[] = []
try {
@ -1776,8 +1805,8 @@ export function RetrievalTestPage({ knowledgeSpaceId }: { knowledgeSpaceId: stri
}
}
const startResearch = async () => {
const cleanQuery = query.trim()
const startResearch = async (input?: { query: string }) => {
const cleanQuery = (input?.query ?? query).trim()
if (!cleanQuery || runInFlightRef.current) return
runInFlightRef.current = true
try {
@ -1808,7 +1837,7 @@ export function RetrievalTestPage({ knowledgeSpaceId }: { knowledgeSpaceId: stri
})
setLocalSelected(undefined)
void setLinkedSelection(
{ research: task.id, trace: null },
{ research: task.id, retest: null, trace: null },
{ history: 'push', shallow: false },
)
setShowAll(false)
@ -1837,6 +1866,25 @@ export function RetrievalTestPage({ knowledgeSpaceId }: { knowledgeSpaceId: stri
else void runFastQuery()
}
const runRetest = useEffectEvent((command: { mode: RetrievalTestMode; query: string }) => {
if (command.mode === 'research') void startResearch({ query: command.query })
else void runFastQuery(command)
})
useEffect(() => {
if (
!linkedRetestTraceId ||
linkedTraceId !== linkedRetestTraceId ||
selected?.kind !== 'trace' ||
selected.id !== linkedRetestTraceId ||
!query.trim() ||
consumedRetestTraceIdRef.current === linkedRetestTraceId
)
return
consumedRetestTraceIdRef.current = linkedRetestTraceId
runRetest({ mode, query })
}, [linkedRetestTraceId, linkedTraceId, mode, query, selected?.id, selected?.kind])
const toggleSelectedResearchProcess = () => {
if (!selectedResearchTask) return
setResearchExpanded((current) => ({
@ -2145,7 +2193,8 @@ export function RetrievalTestPage({ knowledgeSpaceId }: { knowledgeSpaceId: stri
badCaseAvailable={Boolean(selectedTraceId)}
noResults={currentEvidence.length === 0}
decision={qualityDecisions[resultKey]}
onDecision={saveQualityDecision}
onBadCase={saveBadCase}
onGolden={startGoldenPromotion}
pending={qualityPendingKey === resultKey}
qualityHref={newKnowledgeQualityPath(knowledgeSpaceId)}
/>

View File

@ -522,6 +522,7 @@
"newKnowledge.qualityPage.questionRequired": "سؤال مطلوب .",
"newKnowledge.qualityPage.reason": "السبب",
"newKnowledge.qualityPage.reasonValues.coverageGap": "فجوة التغطية",
"newKnowledge.qualityPage.reasonValues.lowScore": "Low score",
"newKnowledge.qualityPage.reasonValues.outdatedContent": "محتوى قديم",
"newKnowledge.qualityPage.reasonValues.retrievalMiss": "ملكة جمال الاسترجاع",
"newKnowledge.qualityPage.replay": "إعادة الاختبار",

View File

@ -522,6 +522,7 @@
"newKnowledge.qualityPage.questionRequired": "Die Frage ist erforderlich.",
"newKnowledge.qualityPage.reason": "Grund",
"newKnowledge.qualityPage.reasonValues.coverageGap": "Deckungslücke",
"newKnowledge.qualityPage.reasonValues.lowScore": "Low score",
"newKnowledge.qualityPage.reasonValues.outdatedContent": "Veralteter Inhalt",
"newKnowledge.qualityPage.reasonValues.retrievalMiss": "Abholfehler",
"newKnowledge.qualityPage.replay": "Erneut testen",

View File

@ -526,6 +526,7 @@
"newKnowledge.qualityPage.questionRequired": "Question is required.",
"newKnowledge.qualityPage.reason": "Reason",
"newKnowledge.qualityPage.reasonValues.coverageGap": "Coverage gap",
"newKnowledge.qualityPage.reasonValues.lowScore": "Low score",
"newKnowledge.qualityPage.reasonValues.outdatedContent": "Outdated content",
"newKnowledge.qualityPage.reasonValues.retrievalMiss": "Retrieval miss",
"newKnowledge.qualityPage.replay": "Replay",

View File

@ -522,6 +522,7 @@
"newKnowledge.qualityPage.questionRequired": "Se requiere pregunta.",
"newKnowledge.qualityPage.reason": "Razón",
"newKnowledge.qualityPage.reasonValues.coverageGap": "Brecha de cobertura",
"newKnowledge.qualityPage.reasonValues.lowScore": "Low score",
"newKnowledge.qualityPage.reasonValues.outdatedContent": "Contenido obsoleto",
"newKnowledge.qualityPage.reasonValues.retrievalMiss": "Error de recuperación",
"newKnowledge.qualityPage.replay": "Volver a probar",

View File

@ -522,6 +522,7 @@
"newKnowledge.qualityPage.questionRequired": "سوال مورد نیاز است.",
"newKnowledge.qualityPage.reason": "دلیل",
"newKnowledge.qualityPage.reasonValues.coverageGap": "شکاف پوشش",
"newKnowledge.qualityPage.reasonValues.lowScore": "Low score",
"newKnowledge.qualityPage.reasonValues.outdatedContent": "محتوای قدیمی",
"newKnowledge.qualityPage.reasonValues.retrievalMiss": "از دست دادن بازیابی",
"newKnowledge.qualityPage.replay": "آزمایش دوباره",

View File

@ -522,6 +522,7 @@
"newKnowledge.qualityPage.questionRequired": "Une question est requise.",
"newKnowledge.qualityPage.reason": "Raison",
"newKnowledge.qualityPage.reasonValues.coverageGap": "Lacune de couverture",
"newKnowledge.qualityPage.reasonValues.lowScore": "Low score",
"newKnowledge.qualityPage.reasonValues.outdatedContent": "Contenu obsolète",
"newKnowledge.qualityPage.reasonValues.retrievalMiss": "Échec de récupération",
"newKnowledge.qualityPage.replay": "Tester à nouveau",

View File

@ -522,6 +522,7 @@
"newKnowledge.qualityPage.questionRequired": "प्रश्न आवश्यक है।",
"newKnowledge.qualityPage.reason": "कारण",
"newKnowledge.qualityPage.reasonValues.coverageGap": "कवरेज गैप",
"newKnowledge.qualityPage.reasonValues.lowScore": "Low score",
"newKnowledge.qualityPage.reasonValues.outdatedContent": "पुरानी सामग्री",
"newKnowledge.qualityPage.reasonValues.retrievalMiss": "पुनर्प्राप्ति मिस",
"newKnowledge.qualityPage.replay": "फिर से जाँचें",

View File

@ -522,6 +522,7 @@
"newKnowledge.qualityPage.questionRequired": "Pertanyaan wajib diisi.",
"newKnowledge.qualityPage.reason": "Alasan",
"newKnowledge.qualityPage.reasonValues.coverageGap": "Kesenjangan cakupan",
"newKnowledge.qualityPage.reasonValues.lowScore": "Low score",
"newKnowledge.qualityPage.reasonValues.outdatedContent": "Konten ketinggalan jaman",
"newKnowledge.qualityPage.reasonValues.retrievalMiss": "Pengambilan gagal",
"newKnowledge.qualityPage.replay": "Uji kembali",

View File

@ -522,6 +522,7 @@
"newKnowledge.qualityPage.questionRequired": "La domanda è obbligatoria.",
"newKnowledge.qualityPage.reason": "Motivo",
"newKnowledge.qualityPage.reasonValues.coverageGap": "Divario di copertura",
"newKnowledge.qualityPage.reasonValues.lowScore": "Low score",
"newKnowledge.qualityPage.reasonValues.outdatedContent": "Contenuti obsoleti",
"newKnowledge.qualityPage.reasonValues.retrievalMiss": "Recupero mancato",
"newKnowledge.qualityPage.replay": "Verifica di nuovo",

View File

@ -522,6 +522,7 @@
"newKnowledge.qualityPage.questionRequired": "質問は必須です。",
"newKnowledge.qualityPage.reason": "理由",
"newKnowledge.qualityPage.reasonValues.coverageGap": "カバレッジギャップ",
"newKnowledge.qualityPage.reasonValues.lowScore": "Low score",
"newKnowledge.qualityPage.reasonValues.outdatedContent": "内容が古いです",
"newKnowledge.qualityPage.reasonValues.retrievalMiss": "取得ミス",
"newKnowledge.qualityPage.replay": "再テスト",

View File

@ -522,6 +522,7 @@
"newKnowledge.qualityPage.questionRequired": "질문이 필요합니다.",
"newKnowledge.qualityPage.reason": "이유",
"newKnowledge.qualityPage.reasonValues.coverageGap": "보장 공백",
"newKnowledge.qualityPage.reasonValues.lowScore": "Low score",
"newKnowledge.qualityPage.reasonValues.outdatedContent": "오래된 콘텐츠",
"newKnowledge.qualityPage.reasonValues.retrievalMiss": "검색 미스",
"newKnowledge.qualityPage.replay": "다시 테스트",

View File

@ -367,6 +367,7 @@
"newKnowledge.providerNotConfiguredDescription": "ເພີ່ມລະຫັດ API ຫຼື URL ຈຸດສິ້ນສຸດເພື່ອລວບລວມຂໍ້ມູນເວັບໄຊທ໌ດ້ວຍ {{provider}}.",
"newKnowledge.providerUnavailable": "ຜູ້ໃຫ້ບໍລິການນີ້ບໍ່ສາມາດໃຊ້ໄດ້.",
"newKnowledge.quality": "ຄຸນະພາບ",
"newKnowledge.qualityPage.reasonValues.lowScore": "ຄະແນນຕໍ່າ",
"newKnowledge.reCrawl": "ກວາດຄືນ",
"newKnowledge.readOnlyEmpty": "ຂໍໃຫ້ຜູ້ແກ້ໄຂພື້ນທີ່ເຮັດວຽກສ້າງຖານຄວາມຮູ້ທຳອິດ.",
"newKnowledge.refreshConnectionStatus": "ໂຫຼດສະຖານະຄືນໃໝ່",

View File

@ -522,6 +522,7 @@
"newKnowledge.qualityPage.questionRequired": "Vraag is verplicht.",
"newKnowledge.qualityPage.reason": "Reden",
"newKnowledge.qualityPage.reasonValues.coverageGap": "Dekkingstekort",
"newKnowledge.qualityPage.reasonValues.lowScore": "Low score",
"newKnowledge.qualityPage.reasonValues.outdatedContent": "Verouderde inhoud",
"newKnowledge.qualityPage.reasonValues.retrievalMiss": "Ophaalfout",
"newKnowledge.qualityPage.replay": "Opnieuw testen",

View File

@ -522,6 +522,7 @@
"newKnowledge.qualityPage.questionRequired": "Pytanie jest wymagane.",
"newKnowledge.qualityPage.reason": "Powód",
"newKnowledge.qualityPage.reasonValues.coverageGap": "Luka w pokryciu",
"newKnowledge.qualityPage.reasonValues.lowScore": "Low score",
"newKnowledge.qualityPage.reasonValues.outdatedContent": "Nieaktualna treść",
"newKnowledge.qualityPage.reasonValues.retrievalMiss": "Panna odzyskania",
"newKnowledge.qualityPage.replay": "Przetestuj ponownie",

View File

@ -522,6 +522,7 @@
"newKnowledge.qualityPage.questionRequired": "A pergunta é obrigatória.",
"newKnowledge.qualityPage.reason": "Razão",
"newKnowledge.qualityPage.reasonValues.coverageGap": "Lacuna de cobertura",
"newKnowledge.qualityPage.reasonValues.lowScore": "Low score",
"newKnowledge.qualityPage.reasonValues.outdatedContent": "Conteúdo desatualizado",
"newKnowledge.qualityPage.reasonValues.retrievalMiss": "Falha de recuperação",
"newKnowledge.qualityPage.replay": "Testar novamente",

View File

@ -522,6 +522,7 @@
"newKnowledge.qualityPage.questionRequired": "Este necesară întrebarea.",
"newKnowledge.qualityPage.reason": "Motivul",
"newKnowledge.qualityPage.reasonValues.coverageGap": "Decalaj de acoperire",
"newKnowledge.qualityPage.reasonValues.lowScore": "Low score",
"newKnowledge.qualityPage.reasonValues.outdatedContent": "Conținut învechit",
"newKnowledge.qualityPage.reasonValues.retrievalMiss": "Rata de recuperare",
"newKnowledge.qualityPage.replay": "Testează din nou",

View File

@ -522,6 +522,7 @@
"newKnowledge.qualityPage.questionRequired": "Требуется вопрос.",
"newKnowledge.qualityPage.reason": "Причина",
"newKnowledge.qualityPage.reasonValues.coverageGap": "Пробел в покрытии",
"newKnowledge.qualityPage.reasonValues.lowScore": "Low score",
"newKnowledge.qualityPage.reasonValues.outdatedContent": "Устаревший контент",
"newKnowledge.qualityPage.reasonValues.retrievalMiss": "Промах при поиске",
"newKnowledge.qualityPage.replay": "Проверить повторно",

View File

@ -522,6 +522,7 @@
"newKnowledge.qualityPage.questionRequired": "Vprašanje je obvezno.",
"newKnowledge.qualityPage.reason": "Razlog",
"newKnowledge.qualityPage.reasonValues.coverageGap": "Vrzel v kritju",
"newKnowledge.qualityPage.reasonValues.lowScore": "Low score",
"newKnowledge.qualityPage.reasonValues.outdatedContent": "Zastarela vsebina",
"newKnowledge.qualityPage.reasonValues.retrievalMiss": "Pogrešana vrnitev",
"newKnowledge.qualityPage.replay": "Znova preizkusi",

View File

@ -522,6 +522,7 @@
"newKnowledge.qualityPage.questionRequired": "ต้องมีคำถาม",
"newKnowledge.qualityPage.reason": "เหตุผล",
"newKnowledge.qualityPage.reasonValues.coverageGap": "ช่องว่างความครอบคลุม",
"newKnowledge.qualityPage.reasonValues.lowScore": "Low score",
"newKnowledge.qualityPage.reasonValues.outdatedContent": "เนื้อหาที่ล้าสมัย",
"newKnowledge.qualityPage.reasonValues.retrievalMiss": "การเรียกค้นที่พลาด",
"newKnowledge.qualityPage.replay": "ทดสอบอีกครั้ง",

View File

@ -522,6 +522,7 @@
"newKnowledge.qualityPage.questionRequired": "Soru zorunludur.",
"newKnowledge.qualityPage.reason": "Sebep",
"newKnowledge.qualityPage.reasonValues.coverageGap": "Kapsama açığı",
"newKnowledge.qualityPage.reasonValues.lowScore": "Low score",
"newKnowledge.qualityPage.reasonValues.outdatedContent": "Güncel olmayan içerik",
"newKnowledge.qualityPage.reasonValues.retrievalMiss": "Geri alma hatası",
"newKnowledge.qualityPage.replay": "Yeniden test et",

View File

@ -522,6 +522,7 @@
"newKnowledge.qualityPage.questionRequired": "Питання обов’язкове.",
"newKnowledge.qualityPage.reason": "Причина",
"newKnowledge.qualityPage.reasonValues.coverageGap": "Розрив покриття",
"newKnowledge.qualityPage.reasonValues.lowScore": "Low score",
"newKnowledge.qualityPage.reasonValues.outdatedContent": "Застарілий вміст",
"newKnowledge.qualityPage.reasonValues.retrievalMiss": "Промах повернення",
"newKnowledge.qualityPage.replay": "Перевірити повторно",

View File

@ -522,6 +522,7 @@
"newKnowledge.qualityPage.questionRequired": "Câu hỏi là bắt buộc.",
"newKnowledge.qualityPage.reason": "Lý do",
"newKnowledge.qualityPage.reasonValues.coverageGap": "Khoảng cách phủ sóng",
"newKnowledge.qualityPage.reasonValues.lowScore": "Low score",
"newKnowledge.qualityPage.reasonValues.outdatedContent": "Nội dung đã lỗi thời",
"newKnowledge.qualityPage.reasonValues.retrievalMiss": "Cuộc truy tìm thất bại",
"newKnowledge.qualityPage.replay": "Kiểm tra lại",

View File

@ -526,6 +526,7 @@
"newKnowledge.qualityPage.questionRequired": "必须填写问题。",
"newKnowledge.qualityPage.reason": "原因",
"newKnowledge.qualityPage.reasonValues.coverageGap": "内容覆盖不足",
"newKnowledge.qualityPage.reasonValues.lowScore": "分数值低",
"newKnowledge.qualityPage.reasonValues.outdatedContent": "内容已过时",
"newKnowledge.qualityPage.reasonValues.retrievalMiss": "检索未命中",
"newKnowledge.qualityPage.replay": "重新测试",

View File

@ -525,6 +525,7 @@
"newKnowledge.qualityPage.questionRequired": "必問問題。",
"newKnowledge.qualityPage.reason": "原因",
"newKnowledge.qualityPage.reasonValues.coverageGap": "覆蓋差距",
"newKnowledge.qualityPage.reasonValues.lowScore": "分數值低",
"newKnowledge.qualityPage.reasonValues.outdatedContent": "過時的內容",
"newKnowledge.qualityPage.reasonValues.retrievalMiss": "檢索失敗",
"newKnowledge.qualityPage.replay": "重新測試",