diff --git a/src/apps/admin/src/ai/topscout-rag/IndexedChallengesPanel.tsx b/src/apps/admin/src/ai/topscout-rag/IndexedChallengesPanel.tsx index 43faa5f2b..8427a6107 100644 --- a/src/apps/admin/src/ai/topscout-rag/IndexedChallengesPanel.tsx +++ b/src/apps/admin/src/ai/topscout-rag/IndexedChallengesPanel.tsx @@ -16,7 +16,6 @@ import { Button, IconOutline, InputSelect, - InputSelectOption, InputWrapper, Table, TableColumn, @@ -32,28 +31,13 @@ import { IndexedChallenge, } from '../../lib/services/rag-index.service' +import { INDEXED_TRACK_OPTIONS, INDEXED_TYPE_OPTIONS } from './ingest-options' import styles from './IndexedChallengesPanel.module.scss' const stopPropagation: MouseEventHandler = ev => ev.stopPropagation() const PER_PAGE = 10 -const TRACK_OPTIONS: InputSelectOption[] = [ - { label: 'All tracks', value: '' }, - { label: 'Development', value: 'Development' }, - { label: 'Design', value: 'Design' }, - { label: 'Data Science', value: 'Data Science' }, - { label: 'Quality Assurance', value: 'Quality Assurance' }, -] - -const TYPE_OPTIONS: InputSelectOption[] = [ - { label: 'All types', value: '' }, - { label: 'Challenge', value: 'Challenge' }, - { label: 'First2Finish', value: 'First2Finish' }, - { label: 'Marathon Match', value: 'Marathon Match' }, - { label: 'Task', value: 'Task' }, -] - interface Filters { search: string projectId: string @@ -390,7 +374,7 @@ export const IndexedChallengesPanel: FC = props => = props => = props => { = props => { = props => { ({}), { virtual: true }) + +describe('ingest-options', () => { + describe('BULK_TRACK_OPTIONS', () => { + // The v6 Challenges API resolves `tracks` against challengeTrack.abbreviation, + // and an unmatched value silently widens the filter to every track rather + // than erroring — so these are pinned rather than left to drift. + it.each([ + ['Development', 'Dev'], + ['Data Science', 'DS'], + ['Design', 'Des'], + ['Quality Assurance', 'QA'], + ])('sends the %s abbreviation %s to the search API', (label, abbreviation) => { + expect(BULK_TRACK_OPTIONS.find(option => option.label === label)?.value) + .toBe(abbreviation) + }) + + it('keeps an unfiltered option that sends nothing', () => { + expect(BULK_TRACK_OPTIONS[0]) + .toEqual({ label: 'Any track', value: '' }) + }) + }) + + describe('INDEXED_TRACK_OPTIONS', () => { + // These match stored chunk metadata, which holds the track's full name. + it.each([ + 'Development', + 'Data Science', + 'Design', + 'Quality Assurance', + ])('filters stored metadata by the full name %s', name => { + expect(INDEXED_TRACK_OPTIONS.find(option => option.label === name)?.value) + .toBe(name) + }) + }) + + it('deliberately uses different values for the two surfaces', () => { + // Guards against someone "aligning" the two lists: one talks to the + // search API (abbreviations), the other to the vector index (names). + const bulk = BULK_TRACK_OPTIONS.map(option => option.value) + const indexed = INDEXED_TRACK_OPTIONS.map(option => option.value) + + expect(bulk).not.toEqual(indexed) + }) +}) diff --git a/src/apps/admin/src/ai/topscout-rag/ingest-options.ts b/src/apps/admin/src/ai/topscout-rag/ingest-options.ts new file mode 100644 index 000000000..13d3483c4 --- /dev/null +++ b/src/apps/admin/src/ai/topscout-rag/ingest-options.ts @@ -0,0 +1,68 @@ +import { InputSelectOption } from '~/libs/ui' + +/** + * Track/type vocabularies for this page. + * + * These deliberately differ between the two panels, and the difference is not + * cosmetic: + * + * - **Ingest** filters reach the v6 Challenges API (bulk ingestion paginates + * `searchChallengesTool`), which resolves `tracks`/`types` against the + * `challengeTrack`/`challengeType` tables' **abbreviation** column. Sending + * "Development" there matches no row, so the filter silently widens to + * "every track" instead of erroring. + * - **Indexed Challenges** filters query stored chunk metadata, where + * ingestion recorded the track's full **name** (`toFreeFormName(challenge.track)` + * in challenge-ingestion-workflow.ts). Sending "Dev" there matches nothing. + * + * So: abbreviations going out to the search API, full names coming back from + * the index. Do not "align" the two lists. + */ +export const BULK_TRACK_OPTIONS: InputSelectOption[] = [ + { label: 'Any track', value: '' }, + { label: 'Development', value: 'Dev' }, + { label: 'Design', value: 'Des' }, + { label: 'Data Science', value: 'DS' }, + { label: 'Quality Assurance', value: 'QA' }, +] + +/** + * Challenge types are resolved by abbreviation too, but unlike tracks the + * abbreviations here are unconfirmed — they are rows in `challengeType`, not + * constants in any repo. These values are the type *names*; if bulk ingestion + * ignores a type filter, this list is the first place to look. + */ +export const BULK_TYPE_OPTIONS: InputSelectOption[] = [ + { label: 'Any type', value: '' }, + { label: 'Challenge', value: 'Challenge' }, + { label: 'First2Finish', value: 'First2Finish' }, + { label: 'Marathon Match', value: 'Marathon Match' }, + { label: 'Task', value: 'Task' }, +] + +/** Mirrors the workflow's own default status set. */ +export const BULK_STATUS_OPTIONS: InputSelectOption[] = [ + { label: 'Active + Completed', value: '' }, + { label: 'Active only', value: 'ACTIVE' }, + { label: 'Completed only', value: 'COMPLETED' }, +] + +/** + * Indexed-challenge filters match stored metadata, so these carry the track's + * full name — see the note on BULK_TRACK_OPTIONS. + */ +export const INDEXED_TRACK_OPTIONS: InputSelectOption[] = [ + { label: 'All tracks', value: '' }, + { label: 'Development', value: 'Development' }, + { label: 'Design', value: 'Design' }, + { label: 'Data Science', value: 'Data Science' }, + { label: 'Quality Assurance', value: 'Quality Assurance' }, +] + +export const INDEXED_TYPE_OPTIONS: InputSelectOption[] = [ + { label: 'All types', value: '' }, + { label: 'Challenge', value: 'Challenge' }, + { label: 'First2Finish', value: 'First2Finish' }, + { label: 'Marathon Match', value: 'Marathon Match' }, + { label: 'Task', value: 'Task' }, +]