Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 133 additions & 0 deletions core/rag/indexResult.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
export type RagIndexDebugChunkPayload = {
chunkIndex: number
charOffset: number
sectionHeading: string | null
title: string | null
content: string
bodyContent: string
overlapPrefix: string | null
}

export type RagIndexSkippedReason = "too_short"

export type RagIndexIndexedResult = {
outcome: "indexed"
chunkCount: number
droppedChunks: number
message: null
debugChunks: RagIndexDebugChunkPayload[]
}

export type RagIndexDeletedResult = {
outcome: "deleted"
message: null
debugChunks: []
}

export type RagIndexSkippedResult = {
outcome: "skipped"
reason: RagIndexSkippedReason | null
chunkCount: 0
message: string
debugChunks: RagIndexDebugChunkPayload[]
}

export type RagIndexUnknownResult = {
outcome: "unknown"
message: string
debugChunks: RagIndexDebugChunkPayload[]
}

export type NormalizedRagIndexResult =
| RagIndexIndexedResult
| RagIndexDeletedResult
| RagIndexSkippedResult
| RagIndexUnknownResult

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null
}

function parseFiniteNumber(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null
}

function normalizeMessage(value: unknown, fallback: string) {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : fallback
}

function parseSkippedReason(data: Record<string, unknown>) {
if (typeof data.reason === "string") return data.reason
if (typeof data.skipped === "string") return data.skipped
return null
}

export function parseRagIndexDebugChunks(data: unknown): RagIndexDebugChunkPayload[] {
if (!isRecord(data)) return []

const value = data.debugChunks
if (!Array.isArray(value)) return []

return value.filter((chunk): chunk is RagIndexDebugChunkPayload => {
if (!isRecord(chunk)) return false
return typeof chunk.chunkIndex === "number"
&& typeof chunk.charOffset === "number"
&& typeof chunk.content === "string"
&& typeof chunk.bodyContent === "string"
&& (typeof chunk.overlapPrefix === "string" || chunk.overlapPrefix === null)
&& (typeof chunk.sectionHeading === "string" || chunk.sectionHeading === null)
&& (typeof chunk.title === "string" || chunk.title === null)
})
}

export function parseRagIndexResult(data: unknown): NormalizedRagIndexResult {
if (!isRecord(data)) {
return {
outcome: "unknown",
message: "Indexing returned an empty response.",
debugChunks: [],
}
}

if (data.outcome === "deleted" || data.deleted === true) {
return { outcome: "deleted", message: null, debugChunks: [] }
}

const debugChunks = parseRagIndexDebugChunks(data)
const rawReason = parseSkippedReason(data)

if (data.outcome === "skipped" || rawReason !== null) {
return {
outcome: "skipped",
reason: rawReason === "too_short" ? "too_short" : null,
chunkCount: 0,
message: normalizeMessage(data.message, "Indexing was skipped."),
debugChunks,
}
}

const chunkCount = parseFiniteNumber(data.chunkCount)
if ((data.outcome === "indexed" || chunkCount !== null) && (chunkCount ?? 0) > 0) {
return {
outcome: "indexed",
chunkCount: chunkCount ?? 0,
droppedChunks: Math.max(0, parseFiniteNumber(data.droppedChunks) ?? 0),
message: null,
debugChunks,
}
}

if (chunkCount === 0) {
return {
outcome: "unknown",
message: normalizeMessage(data.message, "Indexing completed without creating any chunks."),
debugChunks,
}
}

return {
outcome: "unknown",
message: normalizeMessage(data.message, "Indexing returned an unexpected response."),
debugChunks,
}
}
92 changes: 92 additions & 0 deletions core/tests/unit/core-rag-indexResult.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { parseRagIndexResult } from "@core/rag/indexResult"

describe("parseRagIndexResult", () => {
it("normalizes successful index payloads", () => {
expect(parseRagIndexResult({
outcome: "indexed",
chunkCount: 3,
droppedChunks: 1,
})).toEqual({
outcome: "indexed",
chunkCount: 3,
droppedChunks: 1,
message: null,
debugChunks: [],
})
})

it("normalizes skipped too-short payloads", () => {
expect(parseRagIndexResult({
outcome: "skipped",
reason: "too_short",
chunkCount: 0,
message: "Note is too short for indexing",
})).toEqual({
outcome: "skipped",
reason: "too_short",
chunkCount: 0,
message: "Note is too short for indexing",
debugChunks: [],
})
})

it("supports legacy skipped payloads", () => {
expect(parseRagIndexResult({
chunkCount: 0,
skipped: "too_short",
message: "Too short",
})).toEqual({
outcome: "skipped",
reason: "too_short",
chunkCount: 0,
message: "Too short",
debugChunks: [],
})
})

it("keeps the full debug chunk shape returned by the edge function", () => {
expect(parseRagIndexResult({
outcome: "indexed",
chunkCount: 1,
debugChunks: [{
chunkIndex: 0,
charOffset: 12,
sectionHeading: "Heading",
title: "Title",
content: "Chunk content",
bodyContent: "Body content",
overlapPrefix: "Overlap",
}],
})).toEqual({
outcome: "indexed",
chunkCount: 1,
droppedChunks: 0,
message: null,
debugChunks: [{
chunkIndex: 0,
charOffset: 12,
sectionHeading: "Heading",
title: "Title",
content: "Chunk content",
bodyContent: "Body content",
overlapPrefix: "Overlap",
}],
})
})

it("supports delete payloads", () => {
expect(parseRagIndexResult({ deleted: true })).toEqual({
outcome: "deleted",
message: null,
debugChunks: [],
})
})

it("treats zero chunks without skipped reason as unknown", () => {
expect(parseRagIndexResult({ chunkCount: 0, message: "No chunks" })).toEqual({
outcome: "unknown",
message: "No chunks",
debugChunks: [],
})
})
})
6 changes: 6 additions & 0 deletions core/types/aiIndex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@ export type AIIndexStatus = "indexed" | "not_indexed" | "outdated"

export type AIIndexFilter = "all" | AIIndexStatus

export interface AIIndexMutationResult {
noteId: string
previousStatus: AIIndexStatus
nextStatus: AIIndexStatus
}

export interface AIIndexNoteRow {
id: string
title: string
Expand Down
99 changes: 99 additions & 0 deletions cypress/component/features/settings/AIIndexList.cy.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import React from 'react'
import type { SupabaseClient } from '@supabase/supabase-js'

import { AIIndexList } from '../../../../ui/web/components/features/settings/AIIndexList'
import type { AIIndexMutationResult, AIIndexNoteRow } from '../../../../core/types/aiIndex'
import { SupabaseTestProvider } from '../../../../ui/web/providers/SupabaseProvider'

const notes: AIIndexNoteRow[] = [
{
id: 'note-1',
title: 'Outdated note',
updatedAt: '2026-03-29T10:00:00Z',
lastIndexedAt: '2026-03-29T09:00:00Z',
status: 'outdated',
},
{
id: 'note-2',
title: 'Indexed note',
updatedAt: '2026-03-29T11:00:00Z',
lastIndexedAt: '2026-03-29T11:05:00Z',
status: 'indexed',
},
]

describe('features/settings/AIIndexList', () => {
function mountList({
exitingNoteIds = [],
onMutated = cy.stub().as('onMutated'),
}: {
exitingNoteIds?: string[]
onMutated?: Cypress.Agent<sinon.SinonStub>
} = {}) {
const invoke = cy.stub().callsFake((name: string) => {
if (name === 'rag-index') {
return Promise.resolve({ data: { deleted: true }, error: null })
}
return Promise.resolve({ data: null, error: null })
}).as('invoke')

const supabase = {
functions: { invoke },
} as unknown as SupabaseClient

cy.mount(
<div style={{ height: '480px', width: '720px' }}>
<SupabaseTestProvider supabase={supabase}>
<AIIndexList
notes={notes}
exitingNoteIds={exitingNoteIds}
isLoading={false}
hasMore={false}
isFetchingNextPage={false}
onLoadMore={cy.stub()}
onMutated={onMutated}
onOpenNote={cy.stub()}
emptyState={<div>Nothing to review here yet</div>}
height={480}
width={720}
/>
</SupabaseTestProvider>
</div>
)

return { invoke, onMutated }
}

it('passes row mutations through the list without extra confirmation UI', () => {
mountList()

cy.contains('Indexed note').should('be.visible')
cy.contains('Indexed note')
.parents('article')
.first()
.within(() => {
cy.contains('button', 'Remove index').click()
})

cy.get('@invoke').should('have.been.calledWith', 'rag-index', {
body: {
noteId: 'note-2',
action: 'delete',
},
})
cy.get('@onMutated').should('have.been.calledWith', {
noteId: 'note-2',
previousStatus: 'indexed',
nextStatus: 'not_indexed',
} satisfies AIIndexMutationResult)
})

it('marks exiting rows as hidden so filtered removals can animate out', () => {
mountList({ exitingNoteIds: ['note-1'] })

cy.contains('Outdated note')
.parents('article')
.first()
.should('have.attr', 'aria-hidden', 'true')
})
})
Loading
Loading