From bda61c7a2c0fbcf4c508c30bae7717cf12d5bd54 Mon Sep 17 00:00:00 2001 From: Krzysztof Faracik Date: Tue, 30 Jun 2026 17:35:42 +0200 Subject: [PATCH 01/42] fix: prevent first PDF send foreign key race --- components/chat-screen/ChatBar.tsx | 27 +++++++++++++++++++++------ components/chat-screen/ChatScreen.tsx | 20 ++++++++++++++------ hooks/useAttachment.ts | 22 +++++++++++++++------- 3 files changed, 50 insertions(+), 19 deletions(-) diff --git a/components/chat-screen/ChatBar.tsx b/components/chat-screen/ChatBar.tsx index 1ee8b66f..0d0bb739 100644 --- a/components/chat-screen/ChatBar.tsx +++ b/components/chat-screen/ChatBar.tsx @@ -39,7 +39,7 @@ interface Props { userInput: string, imagePath?: string, attachments?: Attachment[] - ) => void; + ) => void | Promise; onSelectModel: () => void; onSelectPrompt: (prompt: string) => void; ref: Ref<{ @@ -142,7 +142,7 @@ const ChatBar = ({ onBarGrow?.(); } }, - [extraContentPadding, onHeightChange, hasMessages] + [extraContentPadding, onHeightChange, onBarGrow, hasMessages] ); const { @@ -169,8 +169,17 @@ const ChatBar = ({ const handleSend = useCallback(() => { if (hasLoadingAttachment) return; - onSend(userInput, imageAttachment?.uri, attachments); - clearAll(); + const attachmentsToSend = attachments; + const imageUriToSend = imageAttachment?.uri; + const inputToSend = userInput; + + setUserInput(''); + clearAll({ cleanupSources: false }); + Promise.resolve( + onSend(inputToSend, imageUriToSend, attachmentsToSend) + ).catch((error) => { + console.error('Failed to send message:', error); + }); }, [ onSend, userInput, @@ -235,8 +244,14 @@ const ChatBar = ({ const handleSubmit = (transcript: string) => { setShowSpeechInput(false); if (transcript) { - onSend(transcript, imageAttachment?.uri, attachments); - clearAll(); + const attachmentsToSend = attachments; + const imageUriToSend = imageAttachment?.uri; + clearAll({ cleanupSources: false }); + Promise.resolve( + onSend(transcript, imageUriToSend, attachmentsToSend) + ).catch((error) => { + console.error('Failed to send transcript:', error); + }); } }; diff --git a/components/chat-screen/ChatScreen.tsx b/components/chat-screen/ChatScreen.tsx index d3eceac7..a17f455d 100644 --- a/components/chat-screen/ChatScreen.tsx +++ b/components/chat-screen/ChatScreen.tsx @@ -9,6 +9,7 @@ import { Keyboard, StyleSheet, useWindowDimensions, View } from 'react-native'; import { LinearGradient } from 'expo-linear-gradient'; import { BottomSheetModal } from '@gorhom/bottom-sheet'; import { KeyboardStickyView } from 'react-native-keyboard-controller'; +import { router } from 'expo-router'; import Animated, { useAnimatedStyle, useSharedValue, @@ -94,6 +95,7 @@ export default function ChatScreen({ isGenerating, sendChatMessage, loadModel, + setActiveChatId, model: loadedModel, } = useLLMStore(); const { getModelById } = useModelStore(); @@ -153,14 +155,21 @@ export default function ChatScreen({ const hasDocuments = attachments?.some((a) => a.type === 'document'); if ((!userInput.trim() && !imagePath && !hasDocuments) || isGenerating) return; - if (!(await checkIfChatExists(db, chatId!))) { + + let targetChatId = chatId!; + if (!(await checkIfChatExists(db, targetChatId))) { const docName = attachments?.find((a) => a.type === 'document')?.name; const titleSource = userInput.trim() || docName || 'New chat'; const newChatTitle = titleSource.length > 25 ? titleSource.slice(0, 25) + '...' : titleSource; - await addChat(newChatTitle, model!.id); + const newChatId = await addChat(newChatTitle, model!.id); + if (!newChatId) return; + + targetChatId = newChatId; + await setActiveChatId(targetChatId); + router.replace(`/chat/${targetChatId}`); } let persistedImagePath: string | undefined = imagePath; @@ -177,9 +186,8 @@ export default function ChatScreen({ } } - inputRef.current?.clear(); Keyboard.dismiss(); - updateLastUsed(chatId!); + updateLastUsed(targetChatId); // Notify Messages that a send is in flight. It will seed blankSpace to // the full container height, then derive the final value from measured @@ -221,7 +229,7 @@ export default function ChatScreen({ // Enable new sources for this chat (persists for future messages) for (const sourceId of attachmentSourceIds) { if (!enabledSources.includes(sourceId)) { - await enableSource(chatId!, sourceId); + await enableSource(targetChatId, sourceId); } } @@ -239,7 +247,7 @@ export default function ChatScreen({ .join(', ') || undefined; await sendChatMessage( userInput, - chatId!, + targetChatId, context, settings, persistedImagePath, diff --git a/hooks/useAttachment.ts b/hooks/useAttachment.ts index af1ca74b..63f94c28 100644 --- a/hooks/useAttachment.ts +++ b/hooks/useAttachment.ts @@ -16,6 +16,10 @@ export interface Attachment { sourceId?: number; } +interface ClearAllOptions { + cleanupSources?: boolean; +} + const requestAndroidGalleryPermission = async (): Promise => { if (Platform.OS !== 'android') return true; @@ -175,13 +179,17 @@ export const useAttachment = () => { [vectorStore] ); - const clearAll = useCallback(() => { - const hadDocuments = attachmentsRef.current.some((a) => a.sourceId); - setAttachments([]); - if (hadDocuments && vectorStore) { - useSourceStore.getState().cleanupOrphanedSources(vectorStore); - } - }, [vectorStore]); + const clearAll = useCallback( + (options: ClearAllOptions = {}) => { + const cleanupSources = options.cleanupSources ?? true; + const hadDocuments = attachmentsRef.current.some((a) => a.sourceId); + setAttachments([]); + if (cleanupSources && hadDocuments && vectorStore) { + useSourceStore.getState().cleanupOrphanedSources(vectorStore); + } + }, + [vectorStore] + ); const openSheet = useCallback(() => { sheetRef.current?.present(); From 1734d688bad906124b4e8b4b22e750e77c29336c Mon Sep 17 00:00:00 2001 From: Krzysztof Faracik Date: Mon, 6 Jul 2026 12:04:06 +0200 Subject: [PATCH 02/42] feat(rag): download embedding model on demand instead of bundling --- ...native-executorch-expo-resource-fetcher.ts | 2 + .../bottomSheets/EmbeddingDownloadSheet.tsx | 162 ++++++++++++++++++ constants/embedding-model.ts | 32 ++++ store/embeddingModelStore.ts | 59 +++++++ utils/embeddingModel.ts | 20 +++ utils/embeddingModelMigration.ts | 31 ++++ utils/lfmEmbeddings.ts | 15 ++ utils/modelConfig.ts | 127 -------------- 8 files changed, 321 insertions(+), 127 deletions(-) create mode 100644 components/bottomSheets/EmbeddingDownloadSheet.tsx create mode 100644 constants/embedding-model.ts create mode 100644 store/embeddingModelStore.ts create mode 100644 utils/embeddingModel.ts create mode 100644 utils/embeddingModelMigration.ts create mode 100644 utils/lfmEmbeddings.ts delete mode 100644 utils/modelConfig.ts diff --git a/__mocks__/react-native-executorch-expo-resource-fetcher.ts b/__mocks__/react-native-executorch-expo-resource-fetcher.ts index 0dc7c1be..5cdcc098 100644 --- a/__mocks__/react-native-executorch-expo-resource-fetcher.ts +++ b/__mocks__/react-native-executorch-expo-resource-fetcher.ts @@ -1,4 +1,6 @@ export const ExpoResourceFetcher = { cancelFetching: jest.fn(), deleteResources: jest.fn(), + listDownloadedFiles: jest.fn(async () => [] as string[]), + getFilesTotalSize: jest.fn(async () => 0), }; diff --git a/components/bottomSheets/EmbeddingDownloadSheet.tsx b/components/bottomSheets/EmbeddingDownloadSheet.tsx new file mode 100644 index 00000000..2a7fb8de --- /dev/null +++ b/components/bottomSheets/EmbeddingDownloadSheet.tsx @@ -0,0 +1,162 @@ +import React, { RefObject, useCallback, useMemo } from 'react'; +import { + BottomSheetModal, + BottomSheetView, + BottomSheetBackdrop, + type BottomSheetBackdropProps, +} from '@gorhom/bottom-sheet'; +import { StyleSheet, Text, View } from 'react-native'; +import { fontFamily, fontSizes } from '../../styles/fontStyles'; +import { useTheme } from '../../context/ThemeContext'; +import { Theme } from '../../styles/colors'; +import PrimaryButton from '../PrimaryButton'; +import SecondaryButton from '../SecondaryButton'; +import { useEmbeddingModelStore } from '../../store/embeddingModelStore'; +import { embeddingModelDownloadSizeLabel } from '../../utils/embeddingModel'; + +type Props = { + bottomSheetModalRef: RefObject; + onDownload: () => void; + onDismiss?: () => void; +}; + +const EmbeddingDownloadSheet = ({ + bottomSheetModalRef, + onDownload, + onDismiss, +}: Props) => { + const { theme } = useTheme(); + const styles = useMemo(() => createStyles(theme), [theme]); + const status = useEmbeddingModelStore((state) => state.status); + const progress = useEmbeddingModelStore((state) => state.progress); + + const isDownloading = status === 'downloading'; + const isError = status === 'error'; + + const renderBackdrop = useCallback( + (props: BottomSheetBackdropProps) => ( + + ), + [styles.backdrop] + ); + + const handleCancel = useCallback( + () => bottomSheetModalRef.current?.dismiss(), + [bottomSheetModalRef] + ); + + return ( + + + Download document model + + {isError + ? 'The document model could not be downloaded. Check your connection and try again.' + : isDownloading + ? 'You can close this sheet — the download keeps going in the background and resumes when you reopen it.' + : `To attach documents, Private Mind needs to download the on-device embedding model once (~${embeddingModelDownloadSizeLabel()}). It is then reused for every future document.`} + + + {isDownloading ? ( + + + + + + {Math.floor(progress * 100)}% + + + ) : ( + + + + + )} + + + ); +}; + +export default EmbeddingDownloadSheet; + +const createStyles = (theme: Theme) => + StyleSheet.create({ + sheet: { + paddingVertical: 24, + paddingHorizontal: 16, + paddingBottom: theme.insets.bottom + 16, + gap: 24, + backgroundColor: theme.bg.softPrimary, + }, + backdrop: { + backgroundColor: theme.bg.overlay, + }, + handleStyle: { + backgroundColor: theme.bg.softPrimary, + borderRadius: 18, + }, + handleIndicator: { + width: 64, + height: 4, + borderRadius: 9999, + backgroundColor: theme.text.primary, + }, + background: { + backgroundColor: theme.bg.softPrimary, + }, + title: { + fontSize: fontSizes.lg, + fontFamily: fontFamily.medium, + color: theme.text.primary, + }, + subText: { + fontSize: fontSizes.md, + fontFamily: fontFamily.regular, + color: theme.text.defaultSecondary, + }, + buttonGroup: { + gap: 8, + }, + progressRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + }, + progressBarContainer: { + flex: 1, + height: 8, + borderRadius: 9999, + overflow: 'hidden', + backgroundColor: theme.bg.softSecondary, + }, + progressBar: { + height: '100%', + borderRadius: 9999, + backgroundColor: theme.bg.strongPrimary, + }, + progressText: { + fontSize: fontSizes.sm, + fontFamily: fontFamily.medium, + color: theme.text.primary, + minWidth: 40, + textAlign: 'right', + }, + }); diff --git a/constants/embedding-model.ts b/constants/embedding-model.ts new file mode 100644 index 00000000..d3cf646e --- /dev/null +++ b/constants/embedding-model.ts @@ -0,0 +1,32 @@ +export interface ModelSourceConfig { + decoderSource?: string; + encoderSource?: string; + tokenizerSource?: string; + modelSource?: string; +} + +const LFM_2_5_EMBEDDING_HF_BASE = + 'https://huggingface.co/software-mansion/react-native-executorch-lfm2.5-embedding-350m/resolve/main'; + +export const LFM_2_5_EMBEDDING_MODEL_FILE = + 'lfm_2_5_embedding_350m_xnnpack_8da4w.pte'; + +export const LFM_2_5_EMBEDDING_MODEL_ID = 'lfm-2-5'; + +export const LFM_2_5_EMBEDDING_SOURCES: ModelSourceConfig = { + modelSource: `${LFM_2_5_EMBEDDING_HF_BASE}/xnnpack/${LFM_2_5_EMBEDDING_MODEL_FILE}`, + tokenizerSource: `${LFM_2_5_EMBEDDING_HF_BASE}/tokenizer.json`, +}; + +const LFM_2_5_EMBEDDING_MODEL_BYTES = 430_609_152; +const LFM_2_5_EMBEDDING_TOKENIZER_BYTES = 4_733_275; + +export const LFM_2_5_EMBEDDING_DOWNLOAD_SIZE_BYTES = + LFM_2_5_EMBEDDING_MODEL_BYTES + LFM_2_5_EMBEDDING_TOKENIZER_BYTES; + +export const LFM_2_5_EMBEDDING_DIM = 1024; + +export const EMBEDDING_QUERY_PREFIX = 'query: '; +export const EMBEDDING_DOCUMENT_PREFIX = 'document: '; + +export const ACTIVE_EMBEDDING_MODEL_KEY = 'active_embedding_model_id'; diff --git a/store/embeddingModelStore.ts b/store/embeddingModelStore.ts new file mode 100644 index 00000000..2adc692f --- /dev/null +++ b/store/embeddingModelStore.ts @@ -0,0 +1,59 @@ +import { create } from 'zustand'; +import { OPSQLiteVectorStore } from '@react-native-rag/op-sqlite'; + +export type EmbeddingModelStatus = + | 'unknown' + | 'not_downloaded' + | 'downloading' + | 'ready' + | 'error'; + +type EmbeddingModelStore = { + status: EmbeddingModelStatus; + progress: number; + setProgress: (progress: number) => void; + setStatus: (status: EmbeddingModelStatus) => void; + markReady: () => void; + ensureReady: (vectorStore: OPSQLiteVectorStore) => Promise; +}; + +let inFlightLoad: Promise | null = null; + +export const useEmbeddingModelStore = create( + (set, get) => ({ + status: 'unknown', + progress: 0, + + setProgress: (progress) => + set({ + progress: Number.isFinite(progress) + ? Math.min(1, Math.max(0, progress)) + : 0, + }), + setStatus: (status) => set({ status }), + markReady: () => set({ status: 'ready', progress: 1 }), + + ensureReady: async (vectorStore) => { + if (get().status === 'ready') return true; + if (inFlightLoad) return inFlightLoad; + + set({ status: 'downloading', progress: 0 }); + + inFlightLoad = (async () => { + try { + await vectorStore.load(); + set({ status: 'ready', progress: 1 }); + return true; + } catch (error) { + console.error('Failed to download/load embedding model', error); + set({ status: 'error', progress: 0 }); + return false; + } finally { + inFlightLoad = null; + } + })(); + + return inFlightLoad; + }, + }) +); diff --git a/utils/embeddingModel.ts b/utils/embeddingModel.ts new file mode 100644 index 00000000..db296d69 --- /dev/null +++ b/utils/embeddingModel.ts @@ -0,0 +1,20 @@ +import { ExpoResourceFetcher } from 'react-native-executorch-expo-resource-fetcher'; +import { + LFM_2_5_EMBEDDING_DOWNLOAD_SIZE_BYTES, + LFM_2_5_EMBEDDING_MODEL_FILE, +} from '../constants/embedding-model'; + +export const isEmbeddingModelDownloaded = async (): Promise => { + try { + const files = await ExpoResourceFetcher.listDownloadedFiles(); + return files.some((file) => file.endsWith(LFM_2_5_EMBEDDING_MODEL_FILE)); + } catch (error) { + console.warn('Failed to check embedding model download status', error); + return false; + } +}; + +export const embeddingModelDownloadSizeLabel = (): string => { + const megabytes = LFM_2_5_EMBEDDING_DOWNLOAD_SIZE_BYTES / (1024 * 1024); + return `${Math.round(megabytes)} MB`; +}; diff --git a/utils/embeddingModelMigration.ts b/utils/embeddingModelMigration.ts new file mode 100644 index 00000000..c5dc0275 --- /dev/null +++ b/utils/embeddingModelMigration.ts @@ -0,0 +1,31 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { type SQLiteDatabase } from 'expo-sqlite'; +import { OPSQLiteVectorStore } from '@react-native-rag/op-sqlite'; +import { dropKeywordIndex } from '../database/keywordIndex'; +import { ACTIVE_EMBEDDING_MODEL_KEY } from '../constants/embedding-model'; + +export const migrateEmbeddingModelIfNeeded = async ( + vectorStore: OPSQLiteVectorStore, + db: SQLiteDatabase, + currentModelId: string +): Promise => { + const storedModelId = await AsyncStorage.getItem(ACTIVE_EMBEDDING_MODEL_KEY); + + if (storedModelId === currentModelId) return false; + + await vectorStore.deleteVectorStore(); + await dropKeywordIndex(vectorStore.db); + + try { + await db.runAsync(`DELETE FROM chatSources`); + await db.runAsync(`DELETE FROM sources`); + } catch (error) { + console.warn( + 'Failed to clear source metadata during embedding migration', + error + ); + } + + await AsyncStorage.setItem(ACTIVE_EMBEDDING_MODEL_KEY, currentModelId); + return true; +}; diff --git a/utils/lfmEmbeddings.ts b/utils/lfmEmbeddings.ts new file mode 100644 index 00000000..643e500e --- /dev/null +++ b/utils/lfmEmbeddings.ts @@ -0,0 +1,15 @@ +import { ExecuTorchEmbeddings } from '@react-native-rag/executorch'; +import { + EMBEDDING_DOCUMENT_PREFIX, + EMBEDDING_QUERY_PREFIX, +} from '../constants/embedding-model'; + +export class LFMEmbeddings extends ExecuTorchEmbeddings { + embedQuery(text: string): Promise { + return this.embed(`${EMBEDDING_QUERY_PREFIX}${text}`); + } + + embedDocument(text: string): Promise { + return this.embed(`${EMBEDDING_DOCUMENT_PREFIX}${text}`); + } +} diff --git a/utils/modelConfig.ts b/utils/modelConfig.ts deleted file mode 100644 index e665b8dd..00000000 --- a/utils/modelConfig.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { Platform } from 'react-native'; -import { - copyFileAssets, - DocumentDirectoryPath, - exists, -} from '@dr.pogodin/react-native-fs'; - -export interface ModelSourceConfig { - decoderSource?: string; - encoderSource?: string; - tokenizerSource?: string; - modelSource?: string; -} - -export interface ModelDefinition { - assetPackName: string; - bundledAssets: ModelSourceConfig; - assetPackFiles: ModelSourceConfig; -} - -export enum ModelSourceStrategy { - BUNDLED_ASSETS = 'bundled_assets', - ASSET_PACK_COPY = 'asset_pack_copy', -} - -export const ALL_MINI_LM_MODEL: ModelDefinition = { - assetPackName: 'all-mini-lm', - bundledAssets: { - modelSource: require('../assets/models/all-mini-lm/all-MiniLM-L6-v2_xnnpack.pte'), - tokenizerSource: require('../assets/models/all-mini-lm/tokenizer.json'), - }, - assetPackFiles: { - modelSource: 'all-MiniLM-L6-v2_xnnpack.pte', - tokenizerSource: 'tokenizer.json', - }, -}; - -export function getModelSourceStrategy(): ModelSourceStrategy { - if (Platform.OS === 'ios') { - return ModelSourceStrategy.BUNDLED_ASSETS; - } - - if (Platform.OS === 'android') { - return __DEV__ - ? ModelSourceStrategy.BUNDLED_ASSETS - : ModelSourceStrategy.ASSET_PACK_COPY; - } - - return ModelSourceStrategy.BUNDLED_ASSETS; -} - -export async function getModelConfig( - modelDef: ModelDefinition -): Promise { - const strategy = getModelSourceStrategy(); - - switch (strategy) { - case ModelSourceStrategy.BUNDLED_ASSETS: - return getBundledAssetConfig(modelDef); - - case ModelSourceStrategy.ASSET_PACK_COPY: - return getAssetPackConfig(modelDef); - - default: - throw new Error(`Unsupported model source strategy: ${strategy}`); - } -} - -function getBundledAssetConfig(modelDef: ModelDefinition): ModelSourceConfig { - const config: ModelSourceConfig = { - tokenizerSource: modelDef.bundledAssets.tokenizerSource, - }; - - if (modelDef.bundledAssets.decoderSource) { - config.decoderSource = modelDef.bundledAssets.decoderSource; - } - - if (modelDef.bundledAssets.encoderSource) { - config.encoderSource = modelDef.bundledAssets.encoderSource; - } - - if (modelDef.bundledAssets.modelSource) { - config.modelSource = modelDef.bundledAssets.modelSource; - } - - return config; -} - -async function getAssetPackConfig( - modelDef: ModelDefinition -): Promise { - const modelDir = `${DocumentDirectoryPath}/${modelDef.assetPackName}_models`; - - const filePaths: Record = {}; - const existsPromises: Array> = []; - - Object.entries(modelDef.assetPackFiles).forEach(([key, filename]) => { - if (filename) { - filePaths[key] = `${modelDir}/${filename}`; - existsPromises.push(exists(filePaths[key])); - } - }); - - const filesExist = await Promise.all(existsPromises); - const allFilesExist = filesExist.every(Boolean); - if (!allFilesExist) { - await copyFileAssets(modelDef.assetPackName, modelDir); - } - - const config: ModelSourceConfig = { - tokenizerSource: `file://${filePaths.tokenizerSource}`, - }; - - if (filePaths.decoderSource) { - config.decoderSource = `file://${filePaths.decoderSource}`; - } - - if (filePaths.encoderSource) { - config.encoderSource = `file://${filePaths.encoderSource}`; - } - - if (filePaths.modelSource) { - config.modelSource = `file://${filePaths.modelSource}`; - } - - return config; -} From 1f69c2fbe63e5e9e1f40239af8361f34f23af9d7 Mon Sep 17 00:00:00 2001 From: Krzysztof Faracik Date: Mon, 6 Jul 2026 15:46:27 +0200 Subject: [PATCH 03/42] feat(rag): hybrid retrieval with keyword (FTS5/BM25) + vector fusion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a hybrid retriever that fuses semantic vector search with exact keyword search and re-ranks the result on-device, replacing the plain vector-only path. No extra ML model is loaded — fusion and re-ranking are pure arithmetic. - keywordIndex: FTS5/BM25 index mirroring the vector-store chunks in the same op-sqlite DB; degrades to a no-op when FTS5 is absent in the build - rankFusion: Reciprocal Rank Fusion, cosine similarity, term coverage and Maximal Marginal Relevance primitives - hybridRetrieve: run vector + keyword search concurrently, hydrate keyword-only hits, fuse, gate out noise-floor filler, MMR-diversify, and float freshly-attached sources to the front (deterministic "Source N" / citation order) - Polish morphology: stem-prefix matching so "pliku" finds "plików", and manual ł/Ł folding the tokenizer's remove_diacritics misses - retrieval/keyword-index constants extracted to constants/ - VectorStoreContext: serialize init/teardown across effect runs, build the keyword index eagerly, lazy-load the embedding model, log unload failures Covered by unit tests for queryTerms, keywordIndex, rankFusion, hybridRetrieve and the context formatters. Co-Authored-By: Claude Opus 4.8 --- __tests__/hybridRetrieval.test.ts | 229 ++++++++++++++++++++++++++++++ __tests__/keywordIndex.test.ts | 61 ++++++++ __tests__/prepareContext.test.ts | 86 +++++------ __tests__/queryTerms.test.ts | 45 ++++++ __tests__/rankFusion.test.ts | 125 ++++++++++++++++ constants/keyword-index.ts | 4 + constants/retrieval.ts | 27 ++++ context/VectorStoreContext.tsx | 93 +++++++++--- database/keywordIndex.ts | 144 +++++++++++++++++++ utils/contextUtils.ts | 119 +++++++++++----- utils/hybridRetrieval.ts | 223 +++++++++++++++++++++++++++++ utils/queryTerms.ts | 120 ++++++++++++++++ utils/rankFusion.ts | 107 ++++++++++++++ 13 files changed, 1288 insertions(+), 95 deletions(-) create mode 100644 __tests__/hybridRetrieval.test.ts create mode 100644 __tests__/keywordIndex.test.ts create mode 100644 __tests__/queryTerms.test.ts create mode 100644 __tests__/rankFusion.test.ts create mode 100644 constants/keyword-index.ts create mode 100644 constants/retrieval.ts create mode 100644 database/keywordIndex.ts create mode 100644 utils/hybridRetrieval.ts create mode 100644 utils/queryTerms.ts create mode 100644 utils/rankFusion.ts diff --git a/__tests__/hybridRetrieval.test.ts b/__tests__/hybridRetrieval.test.ts new file mode 100644 index 00000000..cf549913 --- /dev/null +++ b/__tests__/hybridRetrieval.test.ts @@ -0,0 +1,229 @@ +import { hybridRetrieve } from '../utils/hybridRetrieval'; +import * as keywordIndex from '../database/keywordIndex'; + +jest.mock('../database/keywordIndex', () => ({ + keywordSearch: jest.fn(), +})); + +const mockKeywordSearch = keywordIndex.keywordSearch as jest.Mock; + +const makeVectorStore = ( + queryResults: any[], + vectorsById: Record +) => + ({ + query: jest.fn().mockResolvedValue(queryResults), + db: { + execute: jest + .fn() + .mockImplementation(async (_sql: string, ids: string[]) => ({ + rows: ids.map((id) => vectorsById[id]).filter(Boolean), + })), + }, + }) as any; + +describe('hybridRetrieve', () => { + beforeEach(() => { + mockKeywordSearch.mockReset(); + }); + + it('recovers a keyword-only chunk that vector search missed', async () => { + const vectorResults = [ + { + id: '1:0', + document: 'a semantic passage about felines', + embedding: [1, 0], + similarity: 0.8, + metadata: { documentId: 1, name: 'A' }, + }, + ]; + const vectorsById = { + '2:5': { + id: '2:5', + document: 'the exact code E4021 is documented here', + embedding: [0, 1], + metadata: JSON.stringify({ documentId: 2, name: 'B' }), + }, + }; + mockKeywordSearch.mockResolvedValue([ + { chunkId: '2:5', documentId: 2, score: -1.2 }, + ]); + + const result = await hybridRetrieve({ + prompt: 'E4021', + enabledSourceIds: [1, 2], + vectorStore: makeVectorStore(vectorResults, vectorsById), + sourceNamesById: new Map(), + embeddings: null, + }); + + const names = result.map((c) => c.metadata?.name); + expect(names).toContain('B'); + expect(names).toContain('A'); + }); + + it('gates out low-similarity vector filler with no keyword overlap', async () => { + const vectorResults = [ + { + id: '1:0', + document: 'exact code E4021 explained', + embedding: [1, 0], + similarity: 0.7, + metadata: { documentId: 1, name: 'Relevant' }, + }, + { + id: '1:9', + document: 'completely unrelated boilerplate text', + embedding: [0, 1], + similarity: 0.05, + metadata: { documentId: 1, name: 'Filler' }, + }, + ]; + mockKeywordSearch.mockResolvedValue([]); + + const result = await hybridRetrieve({ + prompt: 'E4021', + enabledSourceIds: [1], + vectorStore: makeVectorStore(vectorResults, {}), + sourceNamesById: new Map(), + embeddings: null, + }); + + const names = result.map((c) => c.metadata?.name); + expect(names).toContain('Relevant'); + expect(names).not.toContain('Filler'); + }); + + it('returns an empty list when nothing qualifies', async () => { + mockKeywordSearch.mockResolvedValue([]); + + const result = await hybridRetrieve({ + prompt: 'xyz', + enabledSourceIds: [1], + vectorStore: makeVectorStore( + [ + { + id: '1:0', + document: 'irrelevant', + embedding: [1, 0], + similarity: 0.02, + metadata: { documentId: 1, name: 'A' }, + }, + ], + {} + ), + sourceNamesById: new Map(), + embeddings: null, + }); + + expect(result).toEqual([]); + }); + + it('falls back to sourceNamesById when a chunk has no name in metadata', async () => { + mockKeywordSearch.mockResolvedValue([]); + + const result = await hybridRetrieve({ + prompt: 'anything relevant', + enabledSourceIds: [7], + vectorStore: makeVectorStore( + [ + { + id: '7:0', + document: 'relevant content', + embedding: [1, 0], + similarity: 0.9, + metadata: { documentId: 7 }, + }, + ], + {} + ), + sourceNamesById: new Map([[7, 'Resolved Name']]), + embeddings: null, + }); + + expect(result[0]?.metadata?.name).toBe('Resolved Name'); + }); + + it('ranks a freshly attached source first even when it would otherwise be gated out', async () => { + const vectorResults = [ + { + id: '1:0', + document: 'older enabled document that mentions the pdf keyword', + embedding: [1, 0], + similarity: 0.2, + metadata: { documentId: 1, name: 'Old' }, + }, + { + id: '2:0', + document: 'brand new attachment about an espresso machine', + embedding: [0, 1], + similarity: 0.05, + metadata: { documentId: 2, name: 'Attachment' }, + }, + ]; + mockKeywordSearch.mockResolvedValue([ + { chunkId: '1:0', documentId: 1, score: -1 }, + ]); + + const result = await hybridRetrieve({ + prompt: 'what is the pdf about', + enabledSourceIds: [1, 2], + attachmentSourceIds: [2], + vectorStore: makeVectorStore(vectorResults, {}), + sourceNamesById: new Map(), + embeddings: null, + }); + + const names = result.map((c) => c.metadata?.name); + expect(names[0]).toBe('Attachment'); + expect(names).toContain('Old'); + }); + + it('gates out a mid-similarity chunk with no lexical overlap (embedding noise floor)', async () => { + mockKeywordSearch.mockResolvedValue([]); + const result = await hybridRetrieve({ + prompt: 'napisz wiersz o jesieni', + enabledSourceIds: [1], + vectorStore: makeVectorStore( + [ + { + id: '1:0', + document: 'privacy policy: data never leaves the device', + embedding: [1, 0], + similarity: 0.45, + metadata: { documentId: 1, name: 'FAQ' }, + }, + ], + {} + ), + sourceNamesById: new Map(), + embeddings: null, + }); + + expect(result).toEqual([]); + }); + + it('keeps a mid-similarity chunk when the query shares terms with it', async () => { + mockKeywordSearch.mockResolvedValue([]); + const result = await hybridRetrieve({ + prompt: 'does data leave the device', + enabledSourceIds: [1], + vectorStore: makeVectorStore( + [ + { + id: '1:0', + document: 'privacy policy: data never leaves the device', + embedding: [1, 0], + similarity: 0.45, + metadata: { documentId: 1, name: 'FAQ' }, + }, + ], + {} + ), + sourceNamesById: new Map(), + embeddings: null, + }); + + expect(result.map((c) => c.metadata?.name)).toContain('FAQ'); + }); +}); diff --git a/__tests__/keywordIndex.test.ts b/__tests__/keywordIndex.test.ts new file mode 100644 index 00000000..1e678fd0 --- /dev/null +++ b/__tests__/keywordIndex.test.ts @@ -0,0 +1,61 @@ +import { + buildKeywordMatchExpression, + foldForKeywordIndex, +} from '../database/keywordIndex'; + +describe('foldForKeywordIndex', () => { + it('folds the Polish stroke letter ł/Ł to l/L (leaving other letters intact)', () => { + expect(foldForKeywordIndex('płatność')).toBe('platność'); + expect(foldForKeywordIndex('usługę')).toBe('uslugę'); + expect(foldForKeywordIndex('Łódź')).toBe('Lódź'); + }); + + it('leaves decomposable diacritics for the FTS tokenizer to fold', () => { + expect(foldForKeywordIndex('księgową')).toBe('księgową'); + }); + + it('leaves plain ASCII untouched', () => { + expect(foldForKeywordIndex('invoice E4021')).toBe('invoice E4021'); + }); +}); + +describe('buildKeywordMatchExpression', () => { + it('prefix-matches the stem of an inflected word so "pliku" finds "plików"', () => { + expect(buildKeywordMatchExpression(['pliku'])).toBe('"plik"*'); + expect(buildKeywordMatchExpression(['plików'])).toBe('"plik"*'); + }); + + it('folds ł in terms and prefix-matches the stem', () => { + expect(buildKeywordMatchExpression(['płatność'])).toBe('"platno"*'); + }); + + it('OR-joins the stemmed terms', () => { + expect(buildKeywordMatchExpression(['invoice', 'total'])).toBe( + '"invoi"* OR "tota"*' + ); + }); + + it('matches identifiers exactly (no stemming, no prefix)', () => { + expect(buildKeywordMatchExpression(['219039'])).toBe('"219039"'); + expect(buildKeywordMatchExpression(['e-4021'])).toBe('"e-4021"'); + }); + + it('quotes terms so FTS5 operators are treated as literals', () => { + expect(buildKeywordMatchExpression(['e-4021', 'OR'])).toBe( + '"e-4021" OR "OR"' + ); + }); + + it('escapes embedded double quotes by doubling them', () => { + expect(buildKeywordMatchExpression(['22"'])).toBe('"22"""'); + }); + + it('drops blank terms', () => { + expect(buildKeywordMatchExpression([' ', 'ok'])).toBe('"ok"'); + }); + + it('returns null when there is nothing to search', () => { + expect(buildKeywordMatchExpression([])).toBeNull(); + expect(buildKeywordMatchExpression([' '])).toBeNull(); + }); +}); diff --git a/__tests__/prepareContext.test.ts b/__tests__/prepareContext.test.ts index a92cc64a..8f59f60f 100644 --- a/__tests__/prepareContext.test.ts +++ b/__tests__/prepareContext.test.ts @@ -1,65 +1,54 @@ import { - filterAndFormatContext, + formatContextChunks, formatFirstChunks, + getSourceDocumentsFromChunks, } from '../utils/contextUtils'; -describe('filterAndFormatContext', () => { +describe('formatContextChunks / getSourceDocumentsFromChunks', () => { const makeChunk = ( document: string, similarity: number, - documentId: number + documentId: number, + name?: string ) => ({ document, similarity, - metadata: { documentId }, + metadata: { documentId, ...(name ? { name } : {}) }, }); - it('includes chunks above 0.3 similarity threshold', () => { - const chunks = [ - makeChunk('Relevant', 0.5, 1), - makeChunk('Irrelevant', 0.2, 1), - ]; - const result = filterAndFormatContext(chunks); - expect(result).toHaveLength(1); - expect(result[0]).toContain('Relevant'); - }); - - it('limits to max 3 chunks', () => { - const chunks = [ - makeChunk('High 1', 0.9, 1), - makeChunk('High 2', 0.8, 1), - makeChunk('High 3', 0.7, 1), - makeChunk('High 4', 0.6, 1), - ]; - const result = filterAndFormatContext(chunks); - expect(result).toHaveLength(3); - }); - - it('returns empty array for no chunks', () => { - expect(filterAndFormatContext([])).toEqual([]); - }); - - it('returns empty when all below threshold', () => { - const chunks = [makeChunk('Low 1', 0.2, 1), makeChunk('Low 2', 0.1, 1)]; - expect(filterAndFormatContext(chunks)).toEqual([]); + it('returns empty output for no chunks', () => { + expect(formatContextChunks([])).toEqual([]); + expect(getSourceDocumentsFromChunks([])).toEqual([]); }); - it('includes relevance score in formatted output', () => { + it('does not leak the relevance score into the LLM context', () => { const chunks = [makeChunk('Content', 0.85, 1)]; - const result = filterAndFormatContext(chunks); - expect(result[0]).toContain('85.0%'); + const result = formatContextChunks(chunks); + expect(result[0]).toContain('Content'); + expect(result[0]).not.toMatch(/%|Relevance/); }); - it('sorts by similarity descending', () => { + it('groups chunks of one document into a single source, preserving input order', () => { const chunks = [ - makeChunk('Low', 0.6, 1), - makeChunk('High', 0.9, 1), - makeChunk('Mid', 0.75, 1), + makeChunk('a-1', 0.9, 1, 'doc-a.pdf'), + makeChunk('a-2', 0.85, 1, 'doc-a.pdf'), + makeChunk('b-1', 0.8, 2, 'doc-b.pdf'), ]; - const result = filterAndFormatContext(chunks); - expect(result[0]).toContain('High'); - expect(result[1]).toContain('Mid'); - expect(result[2]).toContain('Low'); + const context = formatContextChunks(chunks); + const sources = getSourceDocumentsFromChunks(chunks); + + expect(sources).toHaveLength(2); + expect(sources[0].name).toBe('doc-a.pdf'); + expect(sources[1].name).toBe('doc-b.pdf'); + expect(context[0]).toContain('Source 1'); + expect(context[0]).toContain(sources[0].name); + expect(context[1]).toContain('Source 2'); + expect(context[1]).toContain(sources[1].name); + expect(context[0]).toContain('a-1'); + expect(context[0]).toContain('a-2'); + expect(sources[0].passage).toContain('a-1'); + expect(sources[0].passage).toContain('a-2'); + expect(sources[0].similarity).toBe(0.9); }); }); @@ -97,4 +86,15 @@ describe('formatFirstChunks', () => { it('returns empty for empty input', () => { expect(formatFirstChunks([])).toEqual([]); }); + + it('supports a custom source label for current attachments', () => { + const result = formatFirstChunks( + [{ id: 1, name: 'latest.pdf', firstChunk: 'Fresh context' }], + 'Current Attachment Source' + ); + + expect(result[0]).toContain('Current Attachment Source: latest.pdf'); + expect(result[0]).toContain('End of Current Attachment Source'); + }); }); + diff --git a/__tests__/queryTerms.test.ts b/__tests__/queryTerms.test.ts new file mode 100644 index 00000000..ee8dbc97 --- /dev/null +++ b/__tests__/queryTerms.test.ts @@ -0,0 +1,45 @@ +import { extractQueryTerms, stemPrefix } from '../utils/queryTerms'; + +describe('extractQueryTerms', () => { + it('drops short bare numbers and codes that caused false highlights', () => { + const terms = extractQueryTerms('L4 100% for the first 5 of 30 days'); + expect(terms.has('l4')).toBe(false); + expect(terms.has('5')).toBe(false); + expect(terms.has('30')).toBe(false); + expect(terms.has('100')).toBe(true); + expect(terms.has('first')).toBe(true); + expect(terms.has('days')).toBe(true); + }); + + it('keeps longer identifiers and years', () => { + const terms = extractQueryTerms('What changed in invoice FS-219039 during 2020?'); + expect(terms.has('219039')).toBe(true); + expect(terms.has('2020')).toBe(true); + expect(terms.has('invoice')).toBe(true); + expect(terms.has('fs')).toBe(false); + }); + + it('ignores stopwords and empty input', () => { + expect(extractQueryTerms('what is the').size).toBe(0); + expect(extractQueryTerms('').size).toBe(0); + }); +}); + +describe('stemPrefix', () => { + it('reduces inflected Polish words to a shared stem', () => { + expect(stemPrefix('pliku')).toBe('plik'); + expect(stemPrefix('plików')).toBe('plik'); + expect(stemPrefix('linijce')).toBe('linij'); + }); + + it('never truncates below 4 characters', () => { + expect(stemPrefix('kotek')).toBe('kote'); + expect(stemPrefix('rok')).toBe('rok'); + }); + + it('leaves identifiers, codes and years untouched', () => { + expect(stemPrefix('219039')).toBe('219039'); + expect(stemPrefix('e-4021')).toBe('e-4021'); + expect(stemPrefix('2026')).toBe('2026'); + }); +}); diff --git a/__tests__/rankFusion.test.ts b/__tests__/rankFusion.test.ts new file mode 100644 index 00000000..ae2c85d5 --- /dev/null +++ b/__tests__/rankFusion.test.ts @@ -0,0 +1,125 @@ +import { + reciprocalRankFusion, + cosineSimilarity, + termCoverage, + maximalMarginalRelevance, +} from '../utils/rankFusion'; + +describe('reciprocalRankFusion', () => { + it('ranks an item that appears near the top of both lists above single-list items', () => { + const scores = reciprocalRankFusion([ + { ids: ['a', 'b', 'c'] }, + { ids: ['b', 'd', 'a'] }, + ]); + + expect(scores.get('b')!).toBeGreaterThan(scores.get('c')!); + expect(scores.get('a')!).toBeGreaterThan(scores.get('d')!); + }); + + it('sums contributions for an item present in multiple lists', () => { + const k = 60; + const scores = reciprocalRankFusion([{ ids: ['x'] }, { ids: ['x'] }], k); + + expect(scores.get('x')!).toBeCloseTo(2 / (k + 1)); + }); + + it('respects per-list weights', () => { + const scores = reciprocalRankFusion([ + { ids: ['a'], weight: 3 }, + { ids: ['b'], weight: 1 }, + ]); + + expect(scores.get('a')!).toBeGreaterThan(scores.get('b')!); + }); + + it('returns an empty map for no lists', () => { + expect(reciprocalRankFusion([]).size).toBe(0); + }); +}); + +describe('cosineSimilarity', () => { + it('is 1 for identical direction vectors', () => { + expect(cosineSimilarity([1, 2, 3], [2, 4, 6])).toBeCloseTo(1); + }); + + it('is 0 for orthogonal vectors', () => { + expect(cosineSimilarity([1, 0], [0, 1])).toBeCloseTo(0); + }); + + it('is 0 when a vector is zero-length', () => { + expect(cosineSimilarity([0, 0], [1, 1])).toBe(0); + }); + + it('is 0 for empty vectors', () => { + expect(cosineSimilarity([], [])).toBe(0); + }); +}); + +describe('termCoverage', () => { + it('returns the fraction of query terms present in the text', () => { + const terms = new Set(['invoice', 'total', 'missing']); + expect(termCoverage('the invoice total was paid', terms)).toBeCloseTo( + 2 / 3 + ); + }); + + it('is case-insensitive', () => { + expect(termCoverage('ERROR E4021 raised', new Set(['e4021']))).toBe(1); + }); + + it('returns 0 for no terms', () => { + expect(termCoverage('anything', new Set())).toBe(0); + }); +}); + +describe('maximalMarginalRelevance', () => { + const embed = (v: number[]) => v; + + it('picks the most relevant item first', () => { + const selected = maximalMarginalRelevance( + [ + { id: 'low', relevance: 0.2, embedding: embed([1, 0]) }, + { id: 'high', relevance: 0.9, embedding: embed([0, 1]) }, + ], + 1 + ); + + expect(selected.map((s) => s.id)).toEqual(['high']); + }); + + it('prefers a diverse second pick over a near-duplicate of the first', () => { + const selected = maximalMarginalRelevance( + [ + { id: 'first', relevance: 1.0, embedding: embed([1, 0, 0]) }, + { id: 'duplicate', relevance: 0.95, embedding: embed([0.99, 0.01, 0]) }, + { id: 'diverse', relevance: 0.8, embedding: embed([0, 1, 0]) }, + ], + 2, + 0.7 + ); + + expect(selected.map((s) => s.id)).toEqual(['first', 'diverse']); + }); + + it('never returns more than the requested count', () => { + const selected = maximalMarginalRelevance( + [ + { id: 'a', relevance: 1, embedding: [1, 0] }, + { id: 'b', relevance: 1, embedding: [0, 1] }, + { id: 'c', relevance: 1, embedding: [1, 1] }, + ], + 2 + ); + + expect(selected).toHaveLength(2); + }); + + it('returns everything when count exceeds pool size', () => { + const selected = maximalMarginalRelevance( + [{ id: 'a', relevance: 1, embedding: [1, 0] }], + 5 + ); + + expect(selected).toHaveLength(1); + }); +}); diff --git a/constants/keyword-index.ts b/constants/keyword-index.ts new file mode 100644 index 00000000..f0566e38 --- /dev/null +++ b/constants/keyword-index.ts @@ -0,0 +1,4 @@ +export const KEYWORD_TABLE = 'chunk_fts'; + +// unicode61 folds decomposable Polish letters but not ł (see foldForKeywordIndex). +export const FTS_TOKENIZER = 'unicode61 remove_diacritics 2'; diff --git a/constants/retrieval.ts b/constants/retrieval.ts new file mode 100644 index 00000000..b883bf4d --- /dev/null +++ b/constants/retrieval.ts @@ -0,0 +1,27 @@ +/** Candidates each retriever contributes before fusion. */ +export const CANDIDATE_POOL = 20; + +/** Final chunks kept after re-ranking (MMR selection size). */ +export const MAX_RELEVANT_CHUNKS = 5; + +/** RRF constant in `weight / (k + rank)`; 60 is the paper default. */ +export const RRF_K = 60; + +/** Per-retriever RRF weights (KEYWORD = exact-match recall, VECTOR = semantic). */ +export const VECTOR_WEIGHT = 1; +export const KEYWORD_WEIGHT = 1; + +/** MMR trade-off: higher = relevance, lower = diversity. */ +export const MMR_LAMBDA = 0.7; + +/** Coverage boost: `relevance *= 1 + COVERAGE_ALPHA * coverage`. */ +export const COVERAGE_ALPHA = 0.5; + +/** Bonus that floats a freshly-attached chunk past the gate to the pool's front. */ +export const ATTACHMENT_RELEVANCE_BONUS = 10; + +/** Cosine floor to qualify on semantics alone; above LFM2.5's ~0.35–0.45 noise floor. */ +export const STRONG_SEMANTIC_THRESHOLD = 0.55; + +/** Min cosine to qualify via lexical overlap (paired with non-zero term coverage). */ +export const LEXICAL_MATCH_MIN_SIMILARITY = 0.1; diff --git a/context/VectorStoreContext.tsx b/context/VectorStoreContext.tsx index c9453913..a90e0db1 100644 --- a/context/VectorStoreContext.tsx +++ b/context/VectorStoreContext.tsx @@ -1,23 +1,27 @@ import React, { createContext, useState, useEffect, useContext } from 'react'; +import { useSQLiteContext } from 'expo-sqlite'; import { OPSQLiteVectorStore } from '@react-native-rag/op-sqlite'; -import { ExecuTorchEmbeddings } from '@react-native-rag/executorch'; -import { getModelConfig, ALL_MINI_LM_MODEL } from '../utils/modelConfig'; - -const getAllMiniLMAssets = async () => { - const config = await getModelConfig(ALL_MINI_LM_MODEL); - - return { - modelSource: config.modelSource!, - tokenizerSource: config.tokenizerSource!, - }; -}; +import { + LFM_2_5_EMBEDDING_MODEL_ID, + LFM_2_5_EMBEDDING_SOURCES, +} from '../constants/embedding-model'; +import { migrateEmbeddingModelIfNeeded } from '../utils/embeddingModelMigration'; +import { LFMEmbeddings } from '../utils/lfmEmbeddings'; +import { ensureKeywordIndex } from '../database/keywordIndex'; +import { isEmbeddingModelDownloaded } from '../utils/embeddingModel'; +import { useEmbeddingModelStore } from '../store/embeddingModelStore'; const VectorStoreContext = createContext<{ vectorStore: OPSQLiteVectorStore | null; + embeddings: LFMEmbeddings | null; }>({ vectorStore: null, + embeddings: null, }); +// Serializes init/teardown so overlapping effect runs don't race the shared DB. +let vectorStoreInitChain: Promise = Promise.resolve(); + export const VectorStoreProvider = ({ children, }: { @@ -26,35 +30,86 @@ export const VectorStoreProvider = ({ const [vectorStore, setVectorStore] = useState( null ); + const [embeddings, setEmbeddings] = useState(null); + const db = useSQLiteContext(); useEffect(() => { + let cancelled = false; + let localStore: OPSQLiteVectorStore | null = null; + const initialize = async () => { + if (cancelled) return; try { - const assets = await getAllMiniLMAssets(); - const embeddings = new ExecuTorchEmbeddings(assets); + const lfmEmbeddings = new LFMEmbeddings({ + modelSource: LFM_2_5_EMBEDDING_SOURCES.modelSource!, + tokenizerSource: LFM_2_5_EMBEDDING_SOURCES.tokenizerSource!, + onDownloadProgress: (progress) => + useEmbeddingModelStore.getState().setProgress(progress), + }); const store = new OPSQLiteVectorStore({ name: 'private-mind-rag', - embeddings, + embeddings: lfmEmbeddings, }); + localStore = store; + + await migrateEmbeddingModelIfNeeded( + store, + db, + LFM_2_5_EMBEDDING_MODEL_ID + ); + + await ensureKeywordIndex(store.db); + + const downloaded = await isEmbeddingModelDownloaded(); + if (downloaded) { + await store.load(); + } - await store.load(); + if (cancelled) { + await store + .unload() + .catch((error) => + console.error('Failed to unload superseded vector store:', error) + ); + return; + } setVectorStore(store); + setEmbeddings(lfmEmbeddings); + if (downloaded) { + useEmbeddingModelStore.getState().markReady(); + } else { + useEmbeddingModelStore.getState().setStatus('not_downloaded'); + } } catch (error) { - console.error('Failed to initialize vector store:', error); + if (!cancelled) { + console.error('Failed to initialize vector store:', error); + } } }; - initialize(); + vectorStoreInitChain = vectorStoreInitChain.then(initialize); return () => { + cancelled = true; setVectorStore(null); + setEmbeddings(null); + useEmbeddingModelStore.getState().setStatus('unknown'); + vectorStoreInitChain = vectorStoreInitChain.then(() => + localStore + ? localStore + .unload() + .catch((error) => + console.error('Failed to unload vector store:', error) + ) + : undefined + ); }; - }, []); + }, [db]); return ( - + {children} ); diff --git a/database/keywordIndex.ts b/database/keywordIndex.ts new file mode 100644 index 00000000..1b937c8a --- /dev/null +++ b/database/keywordIndex.ts @@ -0,0 +1,144 @@ +import { type DB, type Scalar } from '@op-engineering/op-sqlite'; +import { stemPrefix } from '../utils/queryTerms'; +import { KEYWORD_TABLE, FTS_TOKENIZER } from '../constants/keyword-index'; + +// FTS5 keyword index paralleling the vector store's chunks (same op-sqlite DB, +// same chunk id) for BM25 retrieval. FTS5 depends on the native build; when it's +// absent every op below no-ops and hybrid search degrades to vector-only. ł/Ł is +// folded by hand because the tokenizer's remove_diacritics leaves that stroke +// letter alone, so "platnosc" would otherwise never match "płatność". + +export const foldForKeywordIndex = (text: string): string => + text.replace(/Ł/g, 'L').replace(/ł/g, 'l'); + +let ftsAvailable = false; + +export const isKeywordIndexAvailable = (): boolean => ftsAvailable; + +export const ensureKeywordIndex = async (db: DB): Promise => { + try { + await db.execute( + `CREATE VIRTUAL TABLE IF NOT EXISTS ${KEYWORD_TABLE} USING fts5( + chunk_id UNINDEXED, + document_id UNINDEXED, + content, + tokenize = '${FTS_TOKENIZER}' + );` + ); + ftsAvailable = true; + } catch (error) { + console.warn( + 'FTS5 keyword index unavailable; hybrid search falls back to vector-only', + error + ); + ftsAvailable = false; + } + + return ftsAvailable; +}; + +export const addChunkToKeywordIndex = async ( + db: DB, + chunkId: string, + documentId: number, + content: string +): Promise => { + if (!ftsAvailable) return; + + try { + await db.execute( + `INSERT INTO ${KEYWORD_TABLE} (chunk_id, document_id, content) VALUES (?, ?, ?)`, + [chunkId, documentId, foldForKeywordIndex(content)] + ); + } catch (error) { + console.warn('Failed to index chunk for keyword search', { + chunkId, + documentId, + error, + }); + } +}; + +export const removeDocumentFromKeywordIndex = async ( + db: DB, + documentId: number +): Promise => { + if (!ftsAvailable) return; + + try { + await db.execute(`DELETE FROM ${KEYWORD_TABLE} WHERE document_id = ?`, [ + documentId, + ]); + } catch (error) { + console.warn('Failed to remove document from keyword index', { + documentId, + error, + }); + } +}; + +export const dropKeywordIndex = async (db: DB): Promise => { + try { + await db.execute(`DROP TABLE IF EXISTS ${KEYWORD_TABLE};`); + } catch (error) { + console.warn('Failed to drop keyword index', error); + } + ftsAvailable = false; +}; + +export const buildKeywordMatchExpression = (terms: string[]): string | null => { + const tokens = new Set(); + for (const term of terms) { + const folded = foldForKeywordIndex(term.trim()); + if (!folded) continue; + const stem = stemPrefix(folded); + const escaped = stem.replace(/"/g, '""'); + tokens.add(stem === folded ? `"${escaped}"` : `"${escaped}"*`); + } + + if (tokens.size === 0) return null; + return [...tokens].join(' OR '); +}; + +export type KeywordHit = { + chunkId: string; + documentId?: number; + score: number; +}; + +export const keywordSearch = async ( + db: DB, + terms: string[], + enabledDocumentIds: number[], + limit: number +): Promise => { + if (!ftsAvailable || enabledDocumentIds.length === 0) return []; + + const matchExpression = buildKeywordMatchExpression(terms); + if (!matchExpression) return []; + + const placeholders = enabledDocumentIds.map(() => '?').join(', '); + + try { + const result = await db.execute( + `SELECT chunk_id AS chunkId, document_id AS documentId, bm25(${KEYWORD_TABLE}) AS score + FROM ${KEYWORD_TABLE} + WHERE ${KEYWORD_TABLE} MATCH ? AND document_id IN (${placeholders}) + ORDER BY score + LIMIT ?`, + [matchExpression, ...enabledDocumentIds, limit] + ); + + return result.rows.map((row: Record) => ({ + chunkId: String(row.chunkId), + documentId: + typeof row.documentId === 'number' + ? row.documentId + : Number(row.documentId), + score: row.score as number, + })); + } catch (error) { + console.warn('Keyword search failed', { matchExpression, error }); + return []; + } +}; diff --git a/utils/contextUtils.ts b/utils/contextUtils.ts index 570d9563..861cc9cd 100644 --- a/utils/contextUtils.ts +++ b/utils/contextUtils.ts @@ -1,50 +1,103 @@ -const SIMILARITY_THRESHOLD = 0.3; -const MAX_RELEVANT_CHUNKS = 3; - -interface ContextChunk { - document: string; +export type ContextChunk = { + document?: string; similarity: number; metadata?: { documentId?: number; name?: string; }; -} +}; + +export type SourceDocument = { + documentId?: number; + name: string; + passage?: string; + similarity?: number; +}; -interface FirstChunkSource { +type FirstChunkSource = { id: number; name: string; firstChunk?: string; -} - -export const filterAndFormatContext = (chunks: ContextChunk[]): string[] => { - if (chunks.length === 0) return []; - - const relevant = chunks - .filter((c) => c.similarity >= SIMILARITY_THRESHOLD) - .sort((a, b) => b.similarity - a.similarity) - .slice(0, MAX_RELEVANT_CHUNKS); - - return relevant.map((item, index) => { - const documentName = - item.metadata?.name || - `Document ${item.metadata?.documentId || 'Unknown'}`; - const relevanceScore = item.similarity - ? `(Relevance: ${(item.similarity * 100).toFixed(1)}%)` - : ''; - - return `\n --- Source ${ - index + 1 - }: ${documentName} ${relevanceScore} --- \n ${item.document?.trim()} \n --- End of Source ${ - index + 1 - } ---`; - }); }; -export const formatFirstChunks = (sources: FirstChunkSource[]): string[] => { +export const sourceKey = ( + documentId: number | undefined, + name: string +): string => `${documentId ?? 'unknown'}:${name}`; + +type DocumentGroup = { + documentId?: number; + name: string; + chunks: ContextChunk[]; + maxSimilarity: number; +}; + +const chunkDocumentName = (item: ContextChunk): string => + item.metadata?.name || `Document ${item.metadata?.documentId || 'Unknown'}`; + +const groupChunksByDocument = (chunks: ContextChunk[]): DocumentGroup[] => { + const groups = new Map(); + const order: string[] = []; + + for (const item of chunks) { + const name = chunkDocumentName(item); + const key = sourceKey(item.metadata?.documentId, name); + const existing = groups.get(key); + + if (existing) { + existing.chunks.push(item); + existing.maxSimilarity = Math.max( + existing.maxSimilarity, + item.similarity + ); + } else { + groups.set(key, { + documentId: item.metadata?.documentId, + name, + chunks: [item], + maxSimilarity: item.similarity, + }); + order.push(key); + } + } + + return order.map((key) => groups.get(key)!); +}; + +const joinGroupPassages = (group: DocumentGroup): string => + group.chunks + .map((chunk) => chunk.document?.trim() ?? '') + .filter(Boolean) + .join('\n\n'); + +export const formatContextChunks = (chunks: ContextChunk[]): string[] => + groupChunksByDocument(chunks).map( + (group, index) => + `\n --- Source ${index + 1}: ${ + group.name + } --- \n ${joinGroupPassages(group)} \n --- End of Source ${ + index + 1 + } ---` + ); + +export const getSourceDocumentsFromChunks = ( + chunks: ContextChunk[] +): SourceDocument[] => + groupChunksByDocument(chunks).map((group) => ({ + documentId: group.documentId, + name: group.name, + passage: joinGroupPassages(group), + similarity: group.maxSimilarity, + })); + +export const formatFirstChunks = ( + sources: FirstChunkSource[], + label = 'Source' +): string[] => { return sources .filter((s) => s.firstChunk) .map( (s) => - `\n --- Source: ${s.name} (Overview) --- \n ${s.firstChunk!.trim()} \n --- End of Source ---` + `\n --- ${label}: ${s.name} (Overview) --- \n ${s.firstChunk!.trim()} \n --- End of ${label} ---` ); }; diff --git a/utils/hybridRetrieval.ts b/utils/hybridRetrieval.ts new file mode 100644 index 00000000..f981d0c7 --- /dev/null +++ b/utils/hybridRetrieval.ts @@ -0,0 +1,223 @@ +import { OPSQLiteVectorStore } from '@react-native-rag/op-sqlite'; +import { type Scalar } from '@op-engineering/op-sqlite'; +import { LFMEmbeddings } from './lfmEmbeddings'; +import { extractQueryTerms, stemPrefix } from './queryTerms'; +import { keywordSearch } from '../database/keywordIndex'; +import { type ContextChunk } from './contextUtils'; +import { + cosineSimilarity, + maximalMarginalRelevance, + reciprocalRankFusion, + termCoverage, + type MMRCandidate, +} from './rankFusion'; +import { + ATTACHMENT_RELEVANCE_BONUS, + CANDIDATE_POOL, + COVERAGE_ALPHA, + KEYWORD_WEIGHT, + LEXICAL_MATCH_MIN_SIMILARITY, + MAX_RELEVANT_CHUNKS, + STRONG_SEMANTIC_THRESHOLD, + VECTOR_WEIGHT, +} from '../constants/retrieval'; + +// Hybrid retrieval: fuse semantic vector + keyword (BM25/FTS5) search, then +// re-rank (RRF + term-coverage boost + MMR) down to the final chunks. No +// cross-encoder — see rankFusion.ts. Output feeds formatContextChunks / +// getSourceDocumentsFromChunks unchanged, preserving the "Source N" ↔ citation map. + +type Candidate = { + id: string; + document?: string; + embedding: number[]; + documentId?: number; + name?: string; + similarity: number; +}; + +type HydratedRow = Omit; + +const resolveName = ( + name: string | undefined, + documentId: number | undefined, + sourceNamesById: Map +): string | undefined => + name || + (typeof documentId === 'number' + ? sourceNamesById.get(documentId) + : undefined); + +// Loads full chunk rows by id to hydrate keyword-only hits absent from the +// vector pool, so they can be re-ranked on equal footing. +const hydrateChunksByIds = async ( + vectorStore: OPSQLiteVectorStore, + ids: string[] +): Promise => { + if (ids.length === 0) return []; + + const placeholders = ids.map(() => '?').join(', '); + const result = await vectorStore.db.execute( + `SELECT id, document, embedding, metadata FROM vectors WHERE id IN (${placeholders})`, + ids + ); + + return result.rows.map((row: Record) => { + const metadata = row.metadata + ? JSON.parse(row.metadata as string) + : undefined; + return { + id: String(row.id), + document: (row.document as string | null) ?? undefined, + embedding: Array.from(new Float32Array(row.embedding as ArrayBuffer)), + documentId: metadata?.documentId, + name: metadata?.name, + }; + }); +}; + +export type HybridRetrieveParams = { + prompt: string; + enabledSourceIds: number[]; + vectorStore: OPSQLiteVectorStore; + sourceNamesById: Map; + embeddings?: LFMEmbeddings | null; + attachmentSourceIds?: number[]; +}; + +export const hybridRetrieve = async ({ + prompt, + enabledSourceIds, + vectorStore, + sourceNamesById, + embeddings, + attachmentSourceIds = [], +}: HybridRetrieveParams): Promise => { + const attachmentSet = new Set(attachmentSourceIds); + const enabledSet = new Set(enabledSourceIds); + const isAttachment = (documentId?: number): boolean => + typeof documentId === 'number' && attachmentSet.has(documentId); + let queryEmbedding: number[] | undefined; + if (embeddings) { + try { + queryEmbedding = await embeddings.embedQuery(prompt); + } catch (error) { + console.warn('Query embedding failed; using text-query fallback', error); + } + } + + const terms = extractQueryTerms(prompt); + const coverageTerms = new Set([...terms].map(stemPrefix)); + + const [vectorResults, keywordHits] = await Promise.all([ + vectorStore.query({ + ...(queryEmbedding ? { queryEmbedding } : { queryText: prompt }), + predicate: (r) => enabledSet.has(r.metadata?.documentId), + nResults: CANDIDATE_POOL, + }), + keywordSearch(vectorStore.db, [...terms], enabledSourceIds, CANDIDATE_POOL), + ]); + const keywordIds = new Set(keywordHits.map((hit) => hit.chunkId)); + + const byId = new Map(); + for (const result of vectorResults) { + byId.set(result.id, { + id: result.id, + document: result.document, + embedding: result.embedding, + documentId: result.metadata?.documentId, + name: resolveName( + result.metadata?.name, + result.metadata?.documentId, + sourceNamesById + ), + similarity: result.similarity, + }); + } + + const missingIds = keywordHits + .map((hit) => hit.chunkId) + .filter((id) => !byId.has(id)); + const hydrated = await hydrateChunksByIds(vectorStore, missingIds); + for (const row of hydrated) { + byId.set(row.id, { + id: row.id, + document: row.document, + embedding: row.embedding, + documentId: row.documentId, + name: resolveName(row.name, row.documentId, sourceNamesById), + similarity: queryEmbedding + ? cosineSimilarity(queryEmbedding, row.embedding) + : 0, + }); + } + + const fused = reciprocalRankFusion([ + { ids: vectorResults.map((r) => r.id), weight: VECTOR_WEIGHT }, + { ids: keywordHits.map((h) => h.chunkId), weight: KEYWORD_WEIGHT }, + ]); + + const coverageById = new Map(); + const coverageOf = (candidate: Candidate): number => { + let coverage = coverageById.get(candidate.id); + if (coverage === undefined) { + coverage = termCoverage( + `${candidate.name ?? ''} ${candidate.document ?? ''}`, + coverageTerms + ); + coverageById.set(candidate.id, coverage); + } + return coverage; + }; + + const qualified = [...byId.values()].filter((candidate) => { + if (isAttachment(candidate.documentId)) return true; + if (keywordIds.has(candidate.id)) return true; + if (candidate.similarity >= STRONG_SEMANTIC_THRESHOLD) return true; + return ( + candidate.similarity >= LEXICAL_MATCH_MIN_SIMILARITY && + coverageOf(candidate) > 0 + ); + }); + + if (qualified.length === 0) return []; + + const maxFused = Math.max( + ...qualified.map((c) => fused.get(c.id) ?? 0), + Number.EPSILON + ); + + const mmrCandidates: MMRCandidate[] = qualified.map((candidate) => { + const base = (fused.get(candidate.id) ?? 0) / maxFused; + const coverage = coverageOf(candidate); + return { + id: candidate.id, + relevance: + base * (1 + COVERAGE_ALPHA * coverage) + + (isAttachment(candidate.documentId) ? ATTACHMENT_RELEVANCE_BONUS : 0), + embedding: candidate.embedding, + }; + }); + + const selected = maximalMarginalRelevance(mmrCandidates, MAX_RELEVANT_CHUNKS); + + const ordered = attachmentSet.size + ? [...selected].sort( + (a, b) => + Number(isAttachment(byId.get(b.id)?.documentId)) - + Number(isAttachment(byId.get(a.id)?.documentId)) + ) + : selected; + + return ordered + .map((item) => byId.get(item.id)) + .filter((candidate): candidate is Candidate => candidate !== undefined) + .map((candidate) => ({ + document: candidate.document, + similarity: candidate.similarity, + metadata: { + documentId: candidate.documentId, + name: candidate.name, + }, + })); +}; diff --git a/utils/queryTerms.ts b/utils/queryTerms.ts new file mode 100644 index 00000000..6939c6bb --- /dev/null +++ b/utils/queryTerms.ts @@ -0,0 +1,120 @@ +// Query tokenization shared by hybrid retrieval and citation highlighting so +// both tokenize the prompt the same way. + +// Deliberately narrow per-language stopword lists (function words only, not a +// full corpus): they keep question words from becoming content terms during +// coverage scoring / highlighting. FTS keyword search leaves this to BM25's IDF. +const EN_STOPWORDS = [ + 'the', + 'and', + 'for', + 'are', + 'was', + 'were', + 'this', + 'that', + 'with', + 'from', + 'what', + 'which', + 'who', + 'whom', + 'how', + 'why', + 'when', + 'where', + 'does', + 'did', + 'has', + 'have', + 'had', + 'you', + 'your', + 'about', + 'into', + 'their', + 'they', + 'them', + 'can', + 'could', + 'would', + 'should', + 'will', + 'shall', + 'not', + 'but', + 'all', + 'any', +]; + +const PL_STOPWORDS = [ + 'jak', + 'jest', + 'czy', + 'oraz', + 'lub', + 'albo', + 'dla', + 'nie', + 'tak', + 'sie', + 'się', + 'jego', + 'jej', + 'tego', + 'tej', + 'ten', + 'tym', + 'moze', + 'może', + 'jakie', + 'jaki', + 'jaka', + 'gdzie', + 'kiedy', + 'dlaczego', + 'kto', + 'kogo', + 'ktore', + 'które', + 'ich', + 'przez', + 'przy', + 'aby', + 'zeby', + 'żeby', +]; + +const STOPWORDS = new Set([...EN_STOPWORDS, ...PL_STOPWORDS]); + +// Non-ASCII letters the tokenizer recognises — Polish only today. To add a +// language, extend this and add its stopword group above. +const PL_LETTERS = 'ąćęłńóśźż'; + +// Drop tokens shorter than this so bare numbers/short codes ("5", "L4") can't +// match unrelated passages; real identifiers ("219039") are longer. +const MIN_TERM_LENGTH = 3; + +export const TOKEN_PATTERN = new RegExp(`[a-z0-9${PL_LETTERS}]+`, 'gi'); + +export const extractQueryTerms = (query: string): Set => { + const terms = new Set(); + const matches = query.toLowerCase().match(TOKEN_PATTERN); + if (!matches) return terms; + + for (const token of matches) { + if (token.length >= MIN_TERM_LENGTH && !STOPWORDS.has(token)) { + terms.add(token); + } + } + + return terms; +}; + +const STEM_MIN_TERM_LENGTH = 5; +const WORD_PATTERN = new RegExp(`^[a-z${PL_LETTERS}]+$`, 'i'); + +export const stemPrefix = (term: string): string => + WORD_PATTERN.test(term) && term.length >= STEM_MIN_TERM_LENGTH + ? term.slice(0, Math.max(4, term.length - 2)) + : term; diff --git a/utils/rankFusion.ts b/utils/rankFusion.ts new file mode 100644 index 00000000..1d11f2e7 --- /dev/null +++ b/utils/rankFusion.ts @@ -0,0 +1,107 @@ +import { RRF_K, MMR_LAMBDA } from '../constants/retrieval'; + +// Pure scoring primitives for hybrid retrieval — plain arithmetic, no model/IO. + +export type RankedList = { + ids: string[]; + weight?: number; +}; + +// Fuses rank-ordered lists via `Σ weight / (k + rank)` — uses rank position, not +// raw scores, so it mixes incomparable scales (cosine vs BM25). `ids` run +// best-first; each list's `weight` defaults to 1; `k` dampens the top ranks. +export const reciprocalRankFusion = ( + lists: RankedList[], + k = RRF_K +): Map => { + const scores = new Map(); + + for (const { ids, weight = 1 } of lists) { + ids.forEach((id, index) => { + const contribution = weight / (k + index + 1); + scores.set(id, (scores.get(id) ?? 0) + contribution); + }); + } + + return scores; +}; + +// Cosine similarity; normalises internally (LFM2.5 embeddings are non-unit-length). +// Returns 0 when either vector is empty or zero-length. +export const cosineSimilarity = (a: number[], b: number[]): number => { + const len = Math.min(a.length, b.length); + if (len === 0) return 0; + + let dot = 0; + let normA = 0; + let normB = 0; + for (let i = 0; i < len; i++) { + dot += a[i]! * b[i]!; + normA += a[i]! * a[i]!; + normB += b[i]! * b[i]!; + } + + if (normA === 0 || normB === 0) return 0; + return dot / (Math.sqrt(normA) * Math.sqrt(normB)); +}; + +// Fraction (0..1) of `terms` present as substrings of `text` — rewards exact +// keyword coverage during re-ranking. +export const termCoverage = (text: string, terms: Set): number => { + if (terms.size === 0) return 0; + + const lower = text.toLowerCase(); + let hits = 0; + for (const term of terms) { + if (lower.includes(term)) hits += 1; + } + + return hits / terms.size; +}; + +export type MMRCandidate = { + id: string; + relevance: number; + embedding: number[]; +}; + +// Maximal Marginal Relevance: greedily picks `count` candidates, each maximising +// `λ·relevance − (1−λ)·maxSimilarityToPicked` — relevant but non-duplicate. +// `relevance` may be any scale (only order matters); `lambda` trades relevance +// vs diversity. O(count·pool·dim), no cross-encoder. +export const maximalMarginalRelevance = ( + candidates: MMRCandidate[], + count: number, + lambda = MMR_LAMBDA +): MMRCandidate[] => { + const remaining = [...candidates]; + const selected: MMRCandidate[] = []; + + while (selected.length < count && remaining.length > 0) { + let bestIndex = 0; + let bestScore = -Infinity; + + for (let i = 0; i < remaining.length; i++) { + const candidate = remaining[i]!; + + let maxSimilarity = 0; + for (const picked of selected) { + const similarity = cosineSimilarity( + candidate.embedding, + picked.embedding + ); + if (similarity > maxSimilarity) maxSimilarity = similarity; + } + + const score = lambda * candidate.relevance - (1 - lambda) * maxSimilarity; + if (score > bestScore) { + bestScore = score; + bestIndex = i; + } + } + + selected.push(remaining.splice(bestIndex, 1)[0]!); + } + + return selected; +}; From 244df357b7eb3edb8e14e3a8453d00fe7500d5d5 Mon Sep 17 00:00:00 2001 From: Krzysztof Faracik Date: Mon, 6 Jul 2026 17:30:50 +0200 Subject: [PATCH 04/42] feat(rag): persist per-message citations and migrate legacy sources --- __tests__/chatRepository.test.ts | 30 +++++- __tests__/chatStore.test.ts | 24 +++-- __tests__/dbMigration.test.ts | 69 ++++++++++++++ __tests__/legacyChat.test.ts | 120 +++++++++++++++++++++++ __tests__/messageSources.test.ts | 44 +++++++++ __tests__/sourceStore.test.ts | 73 +++++++++++--- database/chatRepository.ts | 63 +++++++++++-- database/db.ts | 25 +++-- database/sourcesRepository.ts | 29 +++++- database/vectorStoreMigration.ts | 21 +++++ store/chatStore.ts | 3 +- store/sourceStore.ts | 32 ++++++- utils/legacyChat.ts | 31 ++++++ utils/messageSources.ts | 157 +++++++++++++++++++++++++++++++ utils/sourceLinkingBoundary.ts | 37 ++++++++ 15 files changed, 715 insertions(+), 43 deletions(-) create mode 100644 __tests__/dbMigration.test.ts create mode 100644 __tests__/legacyChat.test.ts create mode 100644 __tests__/messageSources.test.ts create mode 100644 database/vectorStoreMigration.ts create mode 100644 utils/legacyChat.ts create mode 100644 utils/messageSources.ts create mode 100644 utils/sourceLinkingBoundary.ts diff --git a/__tests__/chatRepository.test.ts b/__tests__/chatRepository.test.ts index 76ecfb53..6f362381 100644 --- a/__tests__/chatRepository.test.ts +++ b/__tests__/chatRepository.test.ts @@ -1,4 +1,5 @@ import { persistMessage } from '../database/chatRepository'; +import type { SQLiteDatabase } from 'expo-sqlite'; jest.mock('expo-sqlite', () => ({ useSQLiteContext: jest.fn(() => ({})), @@ -10,7 +11,7 @@ jest.mock('@react-native-async-storage/async-storage', () => ({ describe('persistMessage with imagePath', () => { it('includes imagePath in INSERT when provided', async () => { const runAsync = jest.fn().mockResolvedValue({ lastInsertRowId: 1 }); - const mockDb = { runAsync } as any; + const mockDb = { runAsync } as Partial as SQLiteDatabase; await persistMessage(mockDb, { role: 'user', @@ -27,7 +28,7 @@ describe('persistMessage with imagePath', () => { it('passes null imagePath when not provided', async () => { const runAsync = jest.fn().mockResolvedValue({ lastInsertRowId: 2 }); - const mockDb = { runAsync } as any; + const mockDb = { runAsync } as Partial as SQLiteDatabase; await persistMessage(mockDb, { role: 'user', @@ -40,4 +41,29 @@ describe('persistMessage with imagePath', () => { expect.arrayContaining([null]) ); }); + + it('serializes sourceDocuments when provided', async () => { + const runAsync = jest.fn().mockResolvedValue({ lastInsertRowId: 3 }); + const mockDb = { runAsync } as Partial as SQLiteDatabase; + const sourceDocuments = [ + { + documentId: 7, + name: 'financial_report.pdf', + passage: 'Revenue increased.', + similarity: 0.82, + }, + ]; + + await persistMessage(mockDb, { + role: 'assistant', + content: 'Revenue increased.', + chatId: 1, + sourceDocuments, + }); + + expect(runAsync).toHaveBeenCalledWith( + expect.stringContaining('sourceDocuments'), + expect.arrayContaining([JSON.stringify(sourceDocuments)]) + ); + }); }); diff --git a/__tests__/chatStore.test.ts b/__tests__/chatStore.test.ts index 3c7ce6b1..a9065926 100644 --- a/__tests__/chatStore.test.ts +++ b/__tests__/chatStore.test.ts @@ -1,12 +1,14 @@ import { useChatStore } from '../store/chatStore'; import * as chatRepository from '../database/chatRepository'; import * as sourcesRepository from '../database/sourcesRepository'; +import type { SQLiteDatabase } from 'expo-sqlite'; +import type { Model } from '../database/modelRepository'; // Mock all DB interactions jest.mock('../database/chatRepository'); jest.mock('../database/sourcesRepository'); -const mockDb = {} as any; +const mockDb = {} as Partial as SQLiteDatabase; const mockChat = (id: number, lastUsed = Date.now()) => ({ id, @@ -207,9 +209,7 @@ describe('enableSource', () => { }); it('calls activateSource and updates state for a real chat', async () => { - (sourcesRepository.activateSource as jest.Mock).mockResolvedValue( - undefined - ); + (sourcesRepository.activateSource as jest.Mock).mockResolvedValue(true); useChatStore.setState({ chats: [{ ...mockChat(1), enabledSources: [2] }], phantomChat: null, @@ -220,6 +220,18 @@ describe('enableSource', () => { expect(sourcesRepository.activateSource).toHaveBeenCalledWith(mockDb, 1, 7); expect(useChatStore.getState().chats[0].enabledSources).toEqual([2, 7]); }); + + it('does not update state when source activation is skipped', async () => { + (sourcesRepository.activateSource as jest.Mock).mockResolvedValue(false); + useChatStore.setState({ + chats: [{ ...mockChat(1), enabledSources: [2] }], + phantomChat: null, + }); + + await useChatStore.getState().enableSource(1, 7); + + expect(useChatStore.getState().chats[0].enabledSources).toEqual([2]); + }); }); describe('setPhantomChatSettings', () => { @@ -257,7 +269,7 @@ describe('initPhantomChat with model system prompt', () => { await useChatStore .getState() - .initPhantomChat(99, { systemPrompt: modelPrompt } as any); + .initPhantomChat(99, { systemPrompt: modelPrompt } as Partial as Model); const phantom = useChatStore.getState().phantomChat; expect(phantom?.settings?.systemPrompt).toBe(modelPrompt); @@ -270,7 +282,7 @@ describe('initPhantomChat with model system prompt', () => { await useChatStore .getState() - .initPhantomChat(99, { systemPrompt: null } as any); + .initPhantomChat(99, { systemPrompt: null } as Partial as Model); const phantom = useChatStore.getState().phantomChat; expect(phantom?.settings?.systemPrompt).toBe('global default'); diff --git a/__tests__/dbMigration.test.ts b/__tests__/dbMigration.test.ts new file mode 100644 index 00000000..9f03254a --- /dev/null +++ b/__tests__/dbMigration.test.ts @@ -0,0 +1,69 @@ +import type { SQLiteDatabase } from 'expo-sqlite'; +import { migrateLegacyVectorStore } from '../database/vectorStoreMigration'; + +const makeDb = (opts: { + hasLegacyVectorsTable: boolean; + documentColumnMissing?: boolean; +}) => { + const getFirstAsync: jest.Mock = jest.fn(async (sql: string) => { + if (sql.includes('sqlite_master')) { + return opts.hasLegacyVectorsTable ? { 1: 1 } : null; + } + return null; + }); + const execAsync: jest.Mock = jest.fn(async (sql: string) => { + if (sql.includes('SELECT document FROM vectors') && opts.documentColumnMissing) { + throw new Error('no such column: document'); + } + }); + const runAsync: jest.Mock = jest.fn(async () => ({})); + + return { + db: { + getFirstAsync, + execAsync, + runAsync, + } as Partial as SQLiteDatabase, + getFirstAsync, + execAsync, + runAsync, + }; +}; + +const deleteCalls = (runAsync: jest.Mock): string[] => + runAsync.mock.calls.map((call) => call[0] as string); + +describe('migrateLegacyVectorStore', () => { + it('does NOT wipe sources when no legacy vectors table exists (current dual-db setup)', async () => { + const { db, execAsync, runAsync } = makeDb({ hasLegacyVectorsTable: false }); + + await migrateLegacyVectorStore(db); + + expect(execAsync).not.toHaveBeenCalled(); + expect(runAsync).not.toHaveBeenCalled(); + }); + + it('wipes the legacy vectors table and orphaned sources when the table lacks a document column', async () => { + const { db, runAsync } = makeDb({ + hasLegacyVectorsTable: true, + documentColumnMissing: true, + }); + + await migrateLegacyVectorStore(db); + + const calls = deleteCalls(runAsync); + expect(calls.some((sql) => /DELETE FROM chatSources/.test(sql))).toBe(true); + expect(calls.some((sql) => /DELETE FROM sources/.test(sql))).toBe(true); + }); + + it('leaves data intact when a legacy vectors table already has the document column', async () => { + const { db, runAsync } = makeDb({ + hasLegacyVectorsTable: true, + documentColumnMissing: false, + }); + + await migrateLegacyVectorStore(db); + + expect(runAsync).not.toHaveBeenCalled(); + }); +}); diff --git a/__tests__/legacyChat.test.ts b/__tests__/legacyChat.test.ts new file mode 100644 index 00000000..4ce5b7b4 --- /dev/null +++ b/__tests__/legacyChat.test.ts @@ -0,0 +1,120 @@ +import { + chatPredatesSourceLinking, + buildLegacyChatWarningMessage, + LEGACY_CHAT_WARNING_MESSAGE_ID, +} from '../utils/legacyChat'; +import { setSourceLinkingBoundary } from '../utils/sourceLinkingBoundary'; +import { Message } from '../database/chatRepository'; + +const BOUNDARY = 100; + +const message = (overrides: Partial): Message => ({ + id: 1, + chatId: 7, + role: 'user', + content: 'hi', + timestamp: 0, + ...overrides, +}); + +describe('chatPredatesSourceLinking', () => { + it('returns false for a chat with no attached documents', () => { + expect( + chatPredatesSourceLinking( + [ + message({ role: 'user', content: 'hello' }), + message({ id: 2, role: 'assistant', content: 'hi there' }), + ], + BOUNDARY + ) + ).toBe(false); + }); + + it('flags a legacy chat that attached a document but has no source linking', () => { + expect( + chatPredatesSourceLinking( + [ + message({ id: 10, role: 'user', documentName: 'report.pdf' }), + message({ id: 11, role: 'assistant', content: 'summary' }), + ], + BOUNDARY + ) + ).toBe(true); + }); + + it('does not flag a chat where the document is linked via sourceDocuments', () => { + expect( + chatPredatesSourceLinking( + [ + message({ id: 10, role: 'user', documentName: 'report.pdf' }), + message({ + id: 11, + role: 'assistant', + content: 'summary [1]', + sourceDocuments: [{ name: 'report.pdf', documentId: 3 }], + }), + ], + BOUNDARY + ) + ).toBe(false); + }); + + it('does NOT flag a new-era chat whose upload was interrupted before sourceDocuments', () => { + expect( + chatPredatesSourceLinking( + [ + message({ id: 201, role: 'user', documentName: 'report.pdf' }), + message({ id: 202, role: 'assistant', content: '' }), + ], + BOUNDARY + ) + ).toBe(false); + }); + + it('treats an empty sourceDocuments array as no linking (legacy id)', () => { + expect( + chatPredatesSourceLinking( + [ + message({ id: 10, role: 'user', documentName: 'report.pdf' }), + message({ id: 11, role: 'assistant', sourceDocuments: [] }), + ], + BOUNDARY + ) + ).toBe(true); + }); + + it('returns false for an empty conversation', () => { + expect(chatPredatesSourceLinking([], BOUNDARY)).toBe(false); + }); + + it('reads the module boundary when none is passed', () => { + setSourceLinkingBoundary(BOUNDARY); + expect( + chatPredatesSourceLinking([ + message({ id: 10, role: 'user', documentName: 'report.pdf' }), + message({ id: 11, role: 'assistant', content: 'summary' }), + ]) + ).toBe(true); + setSourceLinkingBoundary(0); + }); + + it('flags nothing when the boundary is 0 (fresh install / not yet loaded)', () => { + expect( + chatPredatesSourceLinking( + [message({ id: 10, role: 'user', documentName: 'report.pdf' })], + 0 + ) + ).toBe(false); + }); +}); + +describe('buildLegacyChatWarningMessage', () => { + it('builds a transient event message carrying the chat id', () => { + const warning = buildLegacyChatWarningMessage(42); + expect(warning.id).toBe(LEGACY_CHAT_WARNING_MESSAGE_ID); + expect(warning.id).toBeLessThan(0); + expect(warning.chatId).toBe(42); + expect(warning.role).toBe('event'); + expect(warning.content.length).toBeGreaterThan(0); + }); +}); diff --git a/__tests__/messageSources.test.ts b/__tests__/messageSources.test.ts new file mode 100644 index 00000000..b843277d --- /dev/null +++ b/__tests__/messageSources.test.ts @@ -0,0 +1,44 @@ +import { mergeAttachmentFirst } from '../utils/messageSources'; +import { SourceDocument } from '../database/chatRepository'; + +const doc = (documentId: number | undefined, name: string): SourceDocument => ({ + documentId, + name, +}); + +describe('mergeAttachmentFirst', () => { + it('leads with retrieved attachment docs, then the rest', () => { + const retrieved = [doc(1, 'old.pdf'), doc(2, 'attachment.txt')]; + const result = mergeAttachmentFirst(retrieved, [doc(2, 'attachment.txt')], [ + 2, + ]); + + expect(result.map((d) => d.documentId)).toEqual([2, 1]); + }); + + it('cites an attachment that produced no retrieved chunk, using its overview', () => { + const retrieved = [doc(1, 'old.pdf')]; + const preferred = [doc(2, 'attachment.txt')]; + const result = mergeAttachmentFirst(retrieved, preferred, [2]); + + expect(result.map((d) => d.documentId)).toEqual([2, 1]); + expect(result).toHaveLength(2); + }); + + it('does not duplicate an attachment that was both retrieved and preferred', () => { + const retrieved = [doc(2, 'attachment.txt')]; + const preferred = [doc(2, 'attachment.txt')]; + const result = mergeAttachmentFirst(retrieved, preferred, [2]); + + expect(result).toHaveLength(1); + expect(result[0].documentId).toBe(2); + }); + + it('does not collide two undefined-id sources onto one slot', () => { + const retrieved = [doc(undefined, 'a.pdf'), doc(undefined, 'b.pdf')]; + const result = mergeAttachmentFirst(retrieved, [], [7]); + + expect(result).toHaveLength(2); + expect(result.map((d) => d.name)).toEqual(['a.pdf', 'b.pdf']); + }); +}); diff --git a/__tests__/sourceStore.test.ts b/__tests__/sourceStore.test.ts index b0c71b5c..8c058764 100644 --- a/__tests__/sourceStore.test.ts +++ b/__tests__/sourceStore.test.ts @@ -1,7 +1,11 @@ import { useSourceStore } from '../store/sourceStore'; import * as sourcesRepository from '../database/sourcesRepository'; +import type { Source } from '../database/sourcesRepository'; import * as fileReaders from '../utils/fileReaders'; import { useLLMStore } from '../store/llmStore'; +import type { SQLiteDatabase } from 'expo-sqlite'; +import type { OPSQLiteVectorStore } from '@react-native-rag/op-sqlite'; +import type { LFMEmbeddings } from '../utils/lfmEmbeddings'; jest.mock('../database/sourcesRepository'); jest.mock('../utils/fileReaders'); @@ -19,8 +23,11 @@ jest.mock('react-native-rag', () => ({ })); jest.mock('@react-native-rag/op-sqlite', () => ({})); -const mockDb = {} as any; -const mockVectorStore = { add: jest.fn() } as any; +const mockDb = {} as Partial as SQLiteDatabase; +const vectorStoreAdd = jest.fn(); +const mockVectorStore = { + add: vectorStoreAdd, +} as Partial as OPSQLiteVectorStore; const mockReadDocumentText = fileReaders.readDocumentText as jest.Mock; const mockInsertSource = sourcesRepository.insertSource as jest.Mock; @@ -87,7 +94,7 @@ describe('addSource', () => { }); it('adds a temp source with negative id and isProcessing=true before DB insert', async () => { - let capturedSources: any[] = []; + let capturedSources: Source[] = []; mockReadDocumentText.mockResolvedValue('content'); mockInsertSource.mockImplementation(async () => { capturedSources = useSourceStore.getState().sources; @@ -154,17 +161,54 @@ describe('addSource', () => { .getState() .addSource(baseSource, '/path/doc.txt', mockVectorStore); - expect(mockVectorStore.add).toHaveBeenCalledTimes(3); - expect(mockVectorStore.add).toHaveBeenCalledWith({ + expect(vectorStoreAdd).toHaveBeenCalledTimes(3); + expect(vectorStoreAdd).toHaveBeenCalledWith({ + id: '1:0', document: 'a', - metadata: { documentId: 1, isFirstChunk: true }, + embedding: undefined, + metadata: { + documentId: 1, + name: 'doc.txt', + chunkIndex: 0, + isFirstChunk: true, + }, }); - expect(mockVectorStore.add).toHaveBeenCalledWith({ + expect(vectorStoreAdd).toHaveBeenCalledWith({ + id: '1:1', document: 'b', - metadata: { documentId: 1, isFirstChunk: false }, + embedding: undefined, + metadata: { + documentId: 1, + name: 'doc.txt', + chunkIndex: 1, + isFirstChunk: false, + }, }); }); + it('embeds each chunk with the document prefix when embeddings are provided', async () => { + mockReadDocumentText.mockResolvedValue('content'); + mockInsertSource.mockResolvedValue(1); + MockSplitter.mockImplementation(() => ({ + splitText: jest.fn().mockResolvedValue(['a', 'b']), + })); + const embedDocument = jest + .fn() + .mockImplementation(async (text: string) => [text.length]); + + await useSourceStore + .getState() + .addSource(baseSource, '/path/doc.txt', mockVectorStore, { + embedDocument, + } as Partial as LFMEmbeddings); + + expect(embedDocument).toHaveBeenCalledWith('a'); + expect(embedDocument).toHaveBeenCalledWith('b'); + expect(vectorStoreAdd).toHaveBeenCalledWith( + expect.objectContaining({ document: 'a', embedding: [1] }) + ); + }); + it('removes temp source and resets isReading when DB insert fails', async () => { mockReadDocumentText.mockResolvedValue('content'); mockInsertSource.mockResolvedValue(null); // insert failure @@ -256,10 +300,11 @@ describe('cleanupOrphanedSources', () => { mockGetOrphanedSources.mockResolvedValue(orphaned); mockDeleteSource.mockResolvedValue(undefined); + const vectorStoreDelete = jest.fn(); const mockVectorStoreWithDelete = { - ...mockVectorStore, - delete: jest.fn(), - }; + add: vectorStoreAdd, + delete: vectorStoreDelete, + } as Partial as OPSQLiteVectorStore; await useSourceStore .getState() @@ -267,7 +312,7 @@ describe('cleanupOrphanedSources', () => { expect(mockGetOrphanedSources).toHaveBeenCalledWith(mockDb); expect(mockDeleteSource).toHaveBeenCalledWith(mockDb, 5); - expect(mockVectorStoreWithDelete.delete).toHaveBeenCalledWith({ + expect(vectorStoreDelete).toHaveBeenCalledWith({ predicate: expect.any(Function), }); }); @@ -288,9 +333,9 @@ describe('cleanupOrphanedSources', () => { mockGetAllSources.mockResolvedValue(updated); const mockVectorStoreWithDelete = { - ...mockVectorStore, + add: vectorStoreAdd, delete: jest.fn(), - }; + } as Partial as OPSQLiteVectorStore; await useSourceStore .getState() diff --git a/database/chatRepository.ts b/database/chatRepository.ts index 0b399e4f..8e513b66 100644 --- a/database/chatRepository.ts +++ b/database/chatRepository.ts @@ -42,11 +42,53 @@ export type Message = { content: string; imagePath?: string; documentName?: string; + sourceDocuments?: SourceDocument[]; tokensPerSecond?: number; timeToFirstToken?: number; timestamp: number; }; +export type SourceDocument = { + documentId?: number; + name: string; + passage?: string; + similarity?: number; +}; + +type RawMessage = Omit & { + sourceDocuments?: string | null; +}; + +const parseSourceDocuments = ( + sourceDocuments?: string | null +): SourceDocument[] | undefined => { + if (!sourceDocuments) return undefined; + + try { + const parsed = JSON.parse(sourceDocuments); + if (!Array.isArray(parsed)) return undefined; + + return parsed + .filter( + (source): source is SourceDocument => + !!source && + typeof source === 'object' && + typeof source.name === 'string' + ) + .map((source) => ({ + documentId: + typeof source.documentId === 'number' ? source.documentId : undefined, + name: source.name, + passage: + typeof source.passage === 'string' ? source.passage : undefined, + similarity: + typeof source.similarity === 'number' ? source.similarity : undefined, + })); + } catch { + return undefined; + } +}; + export const createChat = async ( db: SQLiteDatabase, title: string, @@ -96,10 +138,15 @@ export const getChatMessages = async ( db: SQLiteDatabase, chatId: number ): Promise => { - return db.getAllAsync( + const messages = await db.getAllAsync( `SELECT * FROM messages WHERE chatId = ? ORDER BY id ASC`, [chatId] ); + + return messages.map((message) => ({ + ...message, + sourceDocuments: parseSourceDocuments(message.sourceDocuments), + })); }; export const persistMessage = async ( @@ -107,7 +154,7 @@ export const persistMessage = async ( message: Omit ): Promise => { const result = await db.runAsync( - `INSERT INTO messages (chatId, role, content, modelName, tokensPerSecond, timeToFirstToken, imagePath, documentName) VALUES (?, ?, ?, ?, ?, ?, ?, ?);`, + `INSERT INTO messages (chatId, role, content, modelName, tokensPerSecond, timeToFirstToken, imagePath, documentName, sourceDocuments) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);`, [ message.chatId, message.role, @@ -117,6 +164,9 @@ export const persistMessage = async ( message.timeToFirstToken ?? 0, message.imagePath || null, message.documentName || null, + message.sourceDocuments?.length + ? JSON.stringify(message.sourceDocuments) + : null, ] ); @@ -133,8 +183,8 @@ export const persistMessage = async ( }; // SQLite's default SQLITE_MAX_VARIABLE_NUMBER is 999 on older builds. -// 9 params per row, so 100 rows per batch keeps us well under the limit. -const IMPORT_BATCH_SIZE = 100; +// 10 params per row, so 90 rows per batch keeps us well under the limit. +const IMPORT_BATCH_SIZE = 90; export const importMessages = async ( db: SQLiteDatabase, @@ -146,7 +196,7 @@ export const importMessages = async ( for (let i = 0; i < messages.length; i += IMPORT_BATCH_SIZE) { const batch = messages.slice(i, i + IMPORT_BATCH_SIZE); const placeholders = batch - .map(() => '(?, ?, ?, ?, ?, ?, ?, ?, ?)') + .map(() => '(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)') .join(', '); const flattenedValues = batch.flatMap((msg) => [ chatId, @@ -158,9 +208,10 @@ export const importMessages = async ( msg.timeToFirstToken ?? 0, msg.imagePath ?? null, msg.documentName ?? null, + msg.sourceDocuments?.length ? JSON.stringify(msg.sourceDocuments) : null, ]); await db.runAsync( - `INSERT INTO messages (chatId, role, content, timestamp, modelName, tokensPerSecond, timeToFirstToken, imagePath, documentName) VALUES ${placeholders}`, + `INSERT INTO messages (chatId, role, content, timestamp, modelName, tokensPerSecond, timeToFirstToken, imagePath, documentName, sourceDocuments) VALUES ${placeholders}`, flattenedValues ); } diff --git a/database/db.ts b/database/db.ts index 0708faf5..dd40ae7f 100644 --- a/database/db.ts +++ b/database/db.ts @@ -6,6 +6,8 @@ import { useModelStore } from '../store/modelStore'; import { addModel } from './modelRepository'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { useSourceStore } from '../store/sourceStore'; +import { initSourceLinkingBoundary } from '../utils/sourceLinkingBoundary'; +import { migrateLegacyVectorStore } from './vectorStoreMigration'; const runMigrations = async (db: SQLiteDatabase) => { const modelsTableInfo = await db.getAllAsync<{ name: string }>( @@ -86,6 +88,15 @@ const runMigrations = async (db: SQLiteDatabase) => { ); } + const hasSourceDocuments = messagesTableInfo.some( + (col) => col.name === 'sourceDocuments' + ); + if (!hasSourceDocuments) { + await db.execAsync( + `ALTER TABLE messages ADD COLUMN sourceDocuments TEXT DEFAULT NULL` + ); + } + // Check and add thinkingEnabled to chatSettings const chatSettingsTableInfo = await db.getAllAsync<{ name: string }>( `PRAGMA table_info(chatSettings)` @@ -121,16 +132,7 @@ const runMigrations = async (db: SQLiteDatabase) => { ); } - // Migrate: if the vector store's vectors table lacks the `document` column, - // drop it so it gets recreated on next load. Clear sources since their - // backing vector data is gone and they can no longer be queried against. - try { - await db.execAsync(`SELECT document FROM vectors LIMIT 0`); - } catch { - await db.execAsync(`DROP TABLE IF EXISTS vectors`); - await db.runAsync(`DELETE FROM chatSources`); - await db.runAsync(`DELETE FROM sources`); - } + await migrateLegacyVectorStore(db); // One-time cleanup of orphan rows from before FK enforcement was enabled. await db.runAsync( @@ -143,6 +145,8 @@ const runMigrations = async (db: SQLiteDatabase) => { `DELETE FROM chatSources WHERE chatId NOT IN (SELECT id FROM chats) OR sourceId NOT IN (SELECT id FROM sources)` ); + await initSourceLinkingBoundary(db); + await db.runAsync( `DELETE FROM models WHERE source = 'built-in' @@ -229,6 +233,7 @@ export const initDatabase = async (db: SQLiteDatabase) => { timeToFirstToken INTEGER DEFAULT 0, imagePath TEXT DEFAULT NULL, documentName TEXT DEFAULT NULL, + sourceDocuments TEXT DEFAULT NULL, FOREIGN KEY (chatId) REFERENCES chats (id) ON DELETE CASCADE ); `); diff --git a/database/sourcesRepository.ts b/database/sourcesRepository.ts index 22f4c732..58d04a55 100644 --- a/database/sourcesRepository.ts +++ b/database/sourcesRepository.ts @@ -71,11 +71,38 @@ export const activateSource = async ( db: SQLiteDatabase, chatId: number, sourceId: number -) => { +): Promise => { + const relationState = await db.getFirstAsync<{ + chatExists: number; + sourceExists: number; + alreadyEnabled: number; + }>( + `SELECT + EXISTS(SELECT 1 FROM chats WHERE id = ?) AS chatExists, + EXISTS(SELECT 1 FROM sources WHERE id = ?) AS sourceExists, + EXISTS( + SELECT 1 FROM chatSources WHERE chatId = ? AND sourceId = ? + ) AS alreadyEnabled`, + [chatId, sourceId, chatId, sourceId] + ); + + if (!relationState?.chatExists || !relationState?.sourceExists) { + console.warn('Skipping source activation because FK target is missing', { + chatId, + sourceId, + chatExists: !!relationState?.chatExists, + sourceExists: !!relationState?.sourceExists, + }); + return false; + } + + if (relationState.alreadyEnabled) return true; + await db.runAsync( `INSERT INTO chatSources (chatId, sourceId) VALUES (?, ?)`, [chatId, sourceId] ); + return true; }; export const deleteSourceFromChats = async ( diff --git a/database/vectorStoreMigration.ts b/database/vectorStoreMigration.ts new file mode 100644 index 00000000..76aea6dd --- /dev/null +++ b/database/vectorStoreMigration.ts @@ -0,0 +1,21 @@ +import type { SQLiteDatabase } from 'expo-sqlite'; + +// Drops the legacy pre-RAG `vectors` table and its orphaned sources. The +// sqlite_master guard is load-bearing: vectors moved to a separate op-sqlite db, +// so without it the catch below would wipe sources on every launch. +export const migrateLegacyVectorStore = async ( + db: SQLiteDatabase +): Promise => { + const hasLegacyVectorsTable = await db.getFirstAsync( + `SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'vectors'` + ); + if (!hasLegacyVectorsTable) return; + + try { + await db.execAsync(`SELECT document FROM vectors LIMIT 0`); + } catch { + await db.execAsync(`DROP TABLE IF EXISTS vectors`); + await db.runAsync(`DELETE FROM chatSources`); + await db.runAsync(`DELETE FROM sources`); + } +}; diff --git a/store/chatStore.ts b/store/chatStore.ts index 52fbff15..81cc7c04 100644 --- a/store/chatStore.ts +++ b/store/chatStore.ts @@ -203,7 +203,8 @@ export const useChatStore = create((set, get) => ({ return; } - await activateSource(db, chatId, sourceId); + const activated = await activateSource(db, chatId, sourceId); + if (!activated) return; set((state) => ({ chats: state.chats.map((chat) => diff --git a/store/sourceStore.ts b/store/sourceStore.ts index 8db395d5..afd88416 100644 --- a/store/sourceStore.ts +++ b/store/sourceStore.ts @@ -13,6 +13,11 @@ import { OPSQLiteVectorStore } from '@react-native-rag/op-sqlite'; import { RecursiveCharacterTextSplitter } from 'react-native-rag'; import { readDocumentText } from '../utils/fileReaders'; import { useLLMStore } from './llmStore'; +import { LFMEmbeddings } from '../utils/lfmEmbeddings'; +import { + addChunkToKeywordIndex, + removeDocumentFromKeywordIndex, +} from '../database/keywordIndex'; interface SourceStore { sources: Source[]; @@ -23,7 +28,8 @@ interface SourceStore { addSource: ( source: Omit, sourceUri: string, - vectorStore: OPSQLiteVectorStore + vectorStore: OPSQLiteVectorStore, + embeddings?: LFMEmbeddings | null ) => Promise<{ success: boolean; isEmpty?: boolean; sourceId?: number }>; setSourceProcessing: (id: number, isProcessing: boolean) => void; deleteSource: (source: Source) => Promise; @@ -54,7 +60,7 @@ export const useSourceStore = create((set, get) => ({ } }, - addSource: async (source, sourceUri, vectorStore) => { + addSource: async (source, sourceUri, vectorStore, embeddings) => { const db = get().db; if (!db) return { success: false }; @@ -89,10 +95,29 @@ export const useSourceStore = create((set, get) => ({ } for (let i = 0; i < chunks.length; i++) { + const embedding = embeddings + ? await embeddings.embedDocument(chunks[i]!) + : undefined; + const chunkId = `${sourceId}:${i}`; await vectorStore?.add({ + id: chunkId, document: chunks[i]!, - metadata: { documentId: sourceId, isFirstChunk: i === 0 }, + embedding, + metadata: { + documentId: sourceId, + name: source.name, + chunkIndex: i, + isFirstChunk: i === 0, + }, }); + if (vectorStore) { + await addChunkToKeywordIndex( + vectorStore.db, + chunkId, + sourceId, + chunks[i]! + ); + } } set((state) => ({ @@ -156,6 +181,7 @@ export const useSourceStore = create((set, get) => ({ await vectorStore.delete({ predicate: (value) => value.metadata?.documentId === source.id, }); + await removeDocumentFromKeywordIndex(vectorStore.db, source.id); await deleteSource(db, source.id); } if (orphaned.length > 0) { diff --git a/utils/legacyChat.ts b/utils/legacyChat.ts new file mode 100644 index 00000000..bbe4d27e --- /dev/null +++ b/utils/legacyChat.ts @@ -0,0 +1,31 @@ +import { Message } from '../database/chatRepository'; +import { getSourceLinkingBoundary } from './sourceLinkingBoundary'; + +// Negative synthetic id, distinct from real ids and the -1 stream placeholder. +export const LEGACY_CHAT_WARNING_MESSAGE_ID = -100; + +// True when a legacy document (attached before the boundary) is present but no +// message carries sourceDocuments. The boundary excludes new interrupted uploads. +export const chatPredatesSourceLinking = ( + messages: Message[], + boundaryMessageId: number = getSourceLinkingBoundary() +): boolean => { + const usedLegacyDocument = messages.some( + (message) => !!message.documentName && message.id <= boundaryMessageId + ); + if (!usedLegacyDocument) return false; + + const hasSourceLinking = messages.some( + (message) => !!message.sourceDocuments && message.sourceDocuments.length > 0 + ); + return !hasSourceLinking; +}; + +export const buildLegacyChatWarningMessage = (chatId: number): Message => ({ + id: LEGACY_CHAT_WARNING_MESSAGE_ID, + chatId, + role: 'event', + content: + 'Note: this conversation predates document linking, so its attached document is no longer available here. Attach it again in a new chat to use it as a source.', + timestamp: 0, +}); diff --git a/utils/messageSources.ts b/utils/messageSources.ts new file mode 100644 index 00000000..8b308eba --- /dev/null +++ b/utils/messageSources.ts @@ -0,0 +1,157 @@ +import { OPSQLiteVectorStore } from '@react-native-rag/op-sqlite'; +import { LFMEmbeddings } from './lfmEmbeddings'; +import { SourceDocument } from '../database/chatRepository'; +import { + formatContextChunks, + formatFirstChunks, + getSourceDocumentsFromChunks, + sourceKey, +} from './contextUtils'; +import { hybridRetrieve } from './hybridRetrieval'; + +// Builds one LLM turn's source data from the message's attachments + the chat's +// enabled sources: `context` (the "Source N" / overview blocks for the model), +// `sourceDocuments` (citations for the reply) and `preferredSourceDocuments` +// (freshly attached sources to prioritise). State-free, so it's unit-testable. + +export interface SourceRow { + id: number; + name: string; + type?: string; + firstChunk?: string; +} + +const getAttachmentSourceDocuments = ( + sources: SourceRow[], + attachmentSourceIds: number[] +): SourceDocument[] => + sources + .filter((source) => attachmentSourceIds.includes(source.id)) + .map((source) => ({ + documentId: source.id, + name: source.name, + passage: source.firstChunk, + })); + +// Order citations attachment-first: retrieved attachment docs, then attachments +// with no retrieved chunk (cited via overview), then the remaining retrieved docs. +export const mergeAttachmentFirst = ( + retrieved: SourceDocument[], + preferred: SourceDocument[], + attachmentSourceIds: number[] +): SourceDocument[] => { + const attachmentIds = new Set(attachmentSourceIds); + const isAttachment = (doc: SourceDocument) => + doc.documentId !== undefined && attachmentIds.has(doc.documentId); + + const attachmentDocs = retrieved.filter(isAttachment); + const otherDocs = retrieved.filter((doc) => !isAttachment(doc)); + + const citedKeys = new Set( + attachmentDocs.map((doc) => sourceKey(doc.documentId, doc.name)) + ); + const missingAttachments = preferred.filter( + (doc) => !citedKeys.has(sourceKey(doc.documentId, doc.name)) + ); + + return [...attachmentDocs, ...missingAttachments, ...otherDocs]; +}; + +const retrieveChunks = async ( + userInput: string, + allSourceIds: number[], + activeSources: SourceRow[], + attachmentSourceIds: number[], + vectorStore: OPSQLiteVectorStore, + embeddings?: LFMEmbeddings | null +) => { + try { + const relevantChunks = await hybridRetrieve({ + prompt: userInput, + enabledSourceIds: allSourceIds, + vectorStore, + sourceNamesById: new Map(activeSources.map((s) => [s.id, s.name])), + embeddings, + attachmentSourceIds, + }); + return relevantChunks; + } catch (error) { + console.error('Error preparing context:', error); + return []; + } +}; + +export interface BuildMessageSourcesParams { + userInput: string; + attachmentSourceIds: number[]; + enabledSources: number[]; + sources: SourceRow[]; + vectorStore: OPSQLiteVectorStore; + embeddings?: LFMEmbeddings | null; +} + +export interface MessageSources { + context: string[]; + sourceDocuments: SourceDocument[]; + preferredSourceDocuments: SourceDocument[]; +} + +export const buildMessageSources = async ({ + userInput, + attachmentSourceIds, + enabledSources, + sources, + vectorStore, + embeddings, +}: BuildMessageSourcesParams): Promise => { + const empty: MessageSources = { + context: [], + sourceDocuments: [], + preferredSourceDocuments: [], + }; + + const allSourceIds = [...new Set([...enabledSources, ...attachmentSourceIds])]; + if (allSourceIds.length === 0) return empty; + + const activeSources = sources.filter((s) => allSourceIds.includes(s.id)); + const activeAttachmentSources = activeSources.filter((s) => + attachmentSourceIds.includes(s.id) + ); + const preferredSourceDocuments = getAttachmentSourceDocuments( + activeSources, + attachmentSourceIds + ); + const attachmentOverview = () => + formatFirstChunks(activeAttachmentSources, 'Current Attachment Source'); + + const context: string[] = []; + let sourceDocuments: SourceDocument[] = []; + + if (userInput.trim()) { + const relevantChunks = await retrieveChunks( + userInput, + allSourceIds, + activeSources, + attachmentSourceIds, + vectorStore, + embeddings + ); + context.push(...attachmentOverview()); + context.push(...formatContextChunks(relevantChunks)); + + const retrieved = getSourceDocumentsFromChunks(relevantChunks); + sourceDocuments = + attachmentSourceIds.length > 0 + ? mergeAttachmentFirst( + retrieved, + preferredSourceDocuments, + attachmentSourceIds + ) + : retrieved; + } else if (attachmentSourceIds.length > 0) { + sourceDocuments = preferredSourceDocuments; + context.push(...attachmentOverview()); + } + + return { context, sourceDocuments, preferredSourceDocuments }; +}; diff --git a/utils/sourceLinkingBoundary.ts b/utils/sourceLinkingBoundary.ts new file mode 100644 index 00000000..3ebdd116 --- /dev/null +++ b/utils/sourceLinkingBoundary.ts @@ -0,0 +1,37 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import type { SQLiteDatabase } from 'expo-sqlite'; + +const SOURCE_LINKING_BOUNDARY_KEY = 'source_linking_boundary_message_id'; + +let cachedBoundary = 0; + +export const getSourceLinkingBoundary = (): number => cachedBoundary; + +export const setSourceLinkingBoundary = (value: number): void => { + cachedBoundary = Number.isFinite(value) ? value : 0; +}; + +export const initSourceLinkingBoundary = async ( + db: SQLiteDatabase +): Promise => { + try { + const stored = await AsyncStorage.getItem(SOURCE_LINKING_BOUNDARY_KEY); + if (stored != null) { + setSourceLinkingBoundary(Number(stored)); + return cachedBoundary; + } + + const row = await db.getFirstAsync<{ maxId: number | null }>( + `SELECT MAX(id) AS maxId FROM messages` + ); + setSourceLinkingBoundary(row?.maxId ?? 0); + await AsyncStorage.setItem( + SOURCE_LINKING_BOUNDARY_KEY, + String(cachedBoundary) + ); + } catch (error) { + console.warn('Failed to initialize source-linking boundary', error); + setSourceLinkingBoundary(0); + } + return cachedBoundary; +}; From f4cdb92525a25a4f4bbfbbaf7589287a9008e39b Mon Sep 17 00:00:00 2001 From: Krzysztof Faracik Date: Mon, 6 Jul 2026 18:58:01 +0200 Subject: [PATCH 05/42] feat(rag): wire retrieval into send and persist cited sources --- __tests__/promptUtils.test.ts | 154 +++++++++++++++++++++++++- components/chat-screen/ChatScreen.tsx | 98 ++++++++-------- constants/context-window.ts | 24 ++++ store/llmStore.ts | 21 +++- utils/promptUtils.ts | 106 ++++++++++++++---- 5 files changed, 321 insertions(+), 82 deletions(-) create mode 100644 constants/context-window.ts diff --git a/__tests__/promptUtils.test.ts b/__tests__/promptUtils.test.ts index 73578199..e9ff5075 100644 --- a/__tests__/promptUtils.test.ts +++ b/__tests__/promptUtils.test.ts @@ -75,6 +75,23 @@ describe('prepareMessagesForLLM', () => { expect(result[0].content).toBe(baseSettings.systemPrompt); expect(result[0].content).not.toContain('IMPORTANT CONTEXT INFORMATION'); }); + + it('adds current attachment priority without making it exclusive', () => { + const messages = makeMessages(2); + const result = prepareMessagesForLLM( + messages, + ['some context'], + baseSettings, + baseModel, + [{ documentId: 2, name: 'current.pdf' }] + ); + + expect(result[0].content).toContain('CURRENT ATTACHMENT PRIORITY'); + expect(result[0].content).toContain('current.pdf'); + expect(result[0].content).toContain( + 'You may still use earlier conversation' + ); + }); }); describe('event message filtering', () => { @@ -106,8 +123,9 @@ describe('prepareMessagesForLLM', () => { ); const roles = result.map((m) => m.role); expect(roles).not.toContain('event'); - // system + user + assistant + placeholder - expect(result).toHaveLength(4); + // system + user + assistant; trailing empty assistant placeholder is not + // sent to the model. + expect(result).toHaveLength(3); }); }); @@ -120,8 +138,8 @@ describe('prepareMessagesForLLM', () => { baseSettings, baseModel ); - // system + 20 messages + 1 trailing placeholder - expect(result).toHaveLength(22); + // system + 20 messages; trailing empty assistant placeholder is not sent. + expect(result).toHaveLength(21); expect(result[0].role).toBe('system'); }); @@ -184,7 +202,7 @@ describe('prepareMessagesForLLM', () => { }); describe('context injection', () => { - it('wraps context in tags on the last message', () => { + it('wraps context in tags on the latest user message', () => { const messages = makeMessages(3); const result = prepareMessagesForLLM( messages, @@ -193,10 +211,12 @@ describe('prepareMessagesForLLM', () => { baseModel ); const last = result[result.length - 1]; + expect(last.role).toBe('user'); expect(last.content).toContain('chunk one chunk two'); + expect(last.content).toContain('message 3'); }); - it('context is added to the last message (placeholder)', () => { + it('removes the assistant placeholder before adding context', () => { const messages: Message[] = [ { id: 1, @@ -228,9 +248,37 @@ describe('prepareMessagesForLLM', () => { baseModel ); const last = result[result.length - 1]; + expect(result).toHaveLength(4); + expect(last.role).toBe('user'); + expect(last.content).toContain('Tell me more'); expect(last.content).toContain('some context'); }); + it('adds a grounding reminder next to the question when an attachment is present', () => { + const messages = makeMessages(3); + const result = prepareMessagesForLLM( + messages, + ['some context'], + baseSettings, + baseModel, + [{ documentId: 2, name: 'current.pdf' }] + ); + const last = result[result.length - 1]; + expect(last.content).toMatch(/Ignore any document mentioned earlier/i); + }); + + it('omits the grounding reminder when there is no attachment', () => { + const messages = makeMessages(3); + const result = prepareMessagesForLLM( + messages, + ['some context'], + baseSettings, + baseModel + ); + const last = result[result.length - 1]; + expect(last.content).not.toMatch(/Ignore any document mentioned earlier/i); + }); + it('combines context and /think token', () => { const messages = makeMessages(3); const settings = { ...baseSettings, thinkingEnabled: true }; @@ -242,8 +290,102 @@ describe('prepareMessagesForLLM', () => { ); const last = result[result.length - 1]; // /think is appended before context wrapping + expect(last.role).toBe('user'); expect(last.content).toContain('/think'); expect(last.content).toContain(''); }); }); + + describe('context window budget', () => { + const bigMessage = (id: number, role: Message['role']): Message => ({ + id, + chatId: 1, + role, + content: 'x'.repeat(2000), + timestamp: 0, + }); + + it('drops the oldest history messages when the prompt overflows', () => { + const history: Message[] = Array.from({ length: 10 }, (_, i) => + bigMessage(i + 1, i % 2 === 0 ? 'user' : 'assistant') + ); + const messages: Message[] = [ + ...history, + { + id: 11, + chatId: 1, + role: 'user', + content: 'latest question', + timestamp: 0, + }, + { id: 12, chatId: 1, role: 'assistant', content: '', timestamp: 0 }, + ]; + + const result = prepareMessagesForLLM( + messages, + [], + baseSettings, + baseModel + ); + + expect(result[0].role).toBe('system'); + expect(result[result.length - 1].content).toContain('latest question'); + expect(result.length).toBeLessThan(messages.length); + expect(result.length).toBeGreaterThanOrEqual(2); + }); + + it('always keeps the system prompt and the latest question', () => { + const history: Message[] = Array.from({ length: 20 }, (_, i) => + bigMessage(i + 1, i % 2 === 0 ? 'user' : 'assistant') + ); + const messages: Message[] = [ + ...history, + { id: 21, chatId: 1, role: 'user', content: 'keep me', timestamp: 0 }, + { id: 22, chatId: 1, role: 'assistant', content: '', timestamp: 0 }, + ]; + + const result = prepareMessagesForLLM( + messages, + [], + baseSettings, + baseModel + ); + + expect(result[0].role).toBe('system'); + const last = result[result.length - 1]; + expect(last.role).toBe('user'); + expect(last.content).toContain('keep me'); + }); + + it('truncates the RAG context when it alone overflows the budget', () => { + const messages: Message[] = [ + { id: 1, chatId: 1, role: 'user', content: 'question', timestamp: 0 }, + { id: 2, chatId: 1, role: 'assistant', content: '', timestamp: 0 }, + ]; + const hugeContext = 'y'.repeat(20000); + + const result = prepareMessagesForLLM( + messages, + [hugeContext], + baseSettings, + baseModel + ); + + const last = result[result.length - 1]; + expect(last.content).toContain('question'); + expect(last.content).toContain(''); + expect(last.content.length).toBeLessThan(hugeContext.length); + }); + + it('does not trim when everything comfortably fits', () => { + const messages = makeMessages(6); + const result = prepareMessagesForLLM( + messages, + ['small context'], + baseSettings, + baseModel + ); + expect(result).toHaveLength(7); + }); + }); }); diff --git a/components/chat-screen/ChatScreen.tsx b/components/chat-screen/ChatScreen.tsx index a17f455d..7d81bd33 100644 --- a/components/chat-screen/ChatScreen.tsx +++ b/components/chat-screen/ChatScreen.tsx @@ -26,6 +26,7 @@ import { checkIfChatExists, setChatSettings, type Message, + type SourceDocument, } from '../../database/chatRepository'; import { Model } from '../../database/modelRepository'; import Messages from './Messages'; @@ -34,12 +35,12 @@ import ModelSelectSheet from '../bottomSheets/ModelSelectSheet'; import { Theme } from '../../styles/colors'; import { useSQLiteContext } from 'expo-sqlite'; import { useVectorStore } from '../../context/VectorStoreContext'; -import { OPSQLiteVectorStore } from '@react-native-rag/op-sqlite'; import { Attachment } from '../../hooks/useAttachment'; +import { buildMessageSources } from '../../utils/messageSources'; import { - filterAndFormatContext, - formatFirstChunks, -} from '../../utils/contextUtils'; + chatPredatesSourceLinking, + buildLegacyChatWarningMessage, +} from '../../utils/legacyChat'; import { useSourceStore } from '../../store/sourceStore'; import useChatSettings from '../../hooks/useChatSettings'; import Toast from 'react-native-toast-message'; @@ -56,23 +57,6 @@ interface Props { openModelSheetRef?: React.MutableRefObject<(() => void) | null>; } -const prepareContext = async ( - prompt: string, - enabledSources: number[], - vectorStore: OPSQLiteVectorStore -) => { - try { - const context = await vectorStore.query({ - queryText: prompt, - predicate: (r) => enabledSources.includes(r.metadata?.documentId), - }); - return filterAndFormatContext(context); - } catch (error) { - console.error('Error preparing context:', error); - return []; - } -}; - export default function ChatScreen({ chatId, chat, @@ -90,7 +74,7 @@ export default function ChatScreen({ const modelBottomSheetModalRef = useRef(null); const db = useSQLiteContext(); - const { vectorStore } = useVectorStore(); + const { vectorStore, embeddings } = useVectorStore(); const { isGenerating, sendChatMessage, @@ -196,34 +180,37 @@ export default function ChatScreen({ // https://vercel.com/blog/how-we-built-the-v0-ios-app messagesRef.current?.onMessageSent(); - // Build context from attachments + persisted sources - const context: string[] = []; - - // Collect all source IDs: from attachments + already enabled on chat + // Resolve which attachment sources actually exist, then build the RAG + // context + citations for this turn (see utils/messageSources). + const allSources = useSourceStore.getState().sources; + const existingSourceIds = new Set(allSources.map((source) => source.id)); const attachmentSourceIds = (attachments || []) .filter((a) => a.type === 'document' && a.sourceId) - .map((a) => a.sourceId!); - const allSourceIds = [ - ...new Set([...enabledSources, ...attachmentSourceIds]), - ]; - - // RAG context from all sources (persisted + newly attached) - if (allSourceIds.length > 0 && vectorStore) { - const allSources = useSourceStore.getState().sources; - const activeSources = allSources.filter((s) => - allSourceIds.includes(s.id) - ); - const firstChunkContext = formatFirstChunks(activeSources); - context.push(...firstChunkContext); - - if (userInput.trim()) { - const ragContext = await prepareContext( + .map((a) => a.sourceId!) + .filter((sourceId) => { + const exists = existingSourceIds.has(sourceId); + if (!exists) { + console.warn('Skipping missing attachment source before send', { + chatId: targetChatId, + sourceId, + }); + } + return exists; + }); + + let context: string[] = []; + let sourceDocuments: SourceDocument[] = []; + let preferredSourceDocuments: SourceDocument[] = []; + if (vectorStore) { + ({ context, sourceDocuments, preferredSourceDocuments } = + await buildMessageSources({ userInput, - allSourceIds, - vectorStore - ); - context.push(...ragContext); - } + attachmentSourceIds, + enabledSources, + sources: allSources, + vectorStore, + embeddings, + })); } // Enable new sources for this chat (persists for future messages) @@ -251,7 +238,9 @@ export default function ChatScreen({ context, settings, persistedImagePath, - docName + docName, + sourceDocuments, + preferredSourceDocuments ); }; @@ -320,6 +309,17 @@ export default function ChatScreen({ const isEmpty = !isLoading && messageHistory.length === 0; + // Conversations created before documents were linked to messages get a + // transient (unsaved) notice at the top explaining the missing source. The + // real history is untouched — this only affects what is rendered. + const displayedHistory = useMemo( + () => + chatPredatesSourceLinking(messageHistory) + ? [buildLegacyChatWarningMessage(chatId), ...messageHistory] + : messageHistory, + [messageHistory, chatId] + ); + const { height: windowHeight } = useWindowDimensions(); const gradientProgress = useSharedValue(isEmpty ? 1 : 0); useEffect(() => { @@ -343,7 +343,7 @@ export default function ChatScreen({ = {}; + +export const getContextWindowTokens = (model: Model): number => + (model.family ? CONTEXT_WINDOW_TOKENS_BY_FAMILY[model.family] : undefined) ?? + DEFAULT_CONTEXT_WINDOW_TOKENS; + +export const estimateTokens = (text: string): number => + Math.ceil(text.length / CHARS_PER_TOKEN); + +export const getPromptCharBudget = (model: Model): number => { + const promptTokenBudget = Math.max( + 0, + getContextWindowTokens(model) - GENERATION_RESERVE_TOKENS + ); + return promptTokenBudget * CHARS_PER_TOKEN; +}; diff --git a/store/llmStore.ts b/store/llmStore.ts index e890d657..95136e69 100644 --- a/store/llmStore.ts +++ b/store/llmStore.ts @@ -7,6 +7,7 @@ import { getChatMessages, Message, persistMessage, + SourceDocument, } from '../database/chatRepository'; import DeviceInfo from 'react-native-device-info'; import { BENCHMARK_PROMPT } from '../constants/default-benchmark'; @@ -41,7 +42,9 @@ interface LLMStore { context: string[], settings: ChatSettings, imagePath?: string, - documentName?: string + documentName?: string, + sourceDocuments?: SourceDocument[], + preferredSourceDocuments?: SourceDocument[] ) => Promise; runBenchmark: () => Promise; interrupt: () => void; @@ -75,7 +78,7 @@ const createMemoryTracker = (onUpdate: (usedMemory: number) => void) => { if (Platform.OS !== 'ios') { return { start: () => {}, stop: () => {} }; } - let trackerId: number; + let trackerId: ReturnType; return { start: () => { trackerId = setInterval(async () => { @@ -115,6 +118,7 @@ const updateChatStateForGeneration = ( assistantPlaceholder?: Message; timeToFirstToken?: number; tokensPerSecond?: number; + finalAssistantMessage?: Partial; } ) => { switch (phase) { @@ -144,6 +148,7 @@ const updateChatStateForGeneration = ( index === state.activeChatMessages.length - 1 ? { ...msg, + ...data.finalAssistantMessage, timeToFirstToken: data.timeToFirstToken!, tokensPerSecond: data.tokensPerSecond!, } @@ -350,7 +355,9 @@ export const useLLMStore = create((set, get) => ({ context, settings, imagePath, - documentName + documentName, + sourceDocuments, + preferredSourceDocuments ) => { const { db, model: currentModel, activeChatMessages } = get(); if (!db || !currentModel) { @@ -374,6 +381,7 @@ export const useLLMStore = create((set, get) => ({ chatId: chatId, timestamp: Date.now(), id: -1, + sourceDocuments, }; const userMessageId = await persistMessage(db, userMessage); const updatedChatMessages = [ @@ -391,7 +399,8 @@ export const useLLMStore = create((set, get) => ({ get().activeChatMessages, context, settings, - currentModel + currentModel, + preferredSourceDocuments ); await waitForModelLoad(get); @@ -419,6 +428,10 @@ export const useLLMStore = create((set, get) => ({ updateChatStateForGeneration(set, 'complete', { timeToFirstToken: responsePerformance.timeToFirstToken, tokensPerSecond: responsePerformance.tokensPerSecond, + finalAssistantMessage: { + content: finalResponse, + sourceDocuments, + }, }); } else { updateChatStateForGeneration(set, 'complete'); diff --git a/utils/promptUtils.ts b/utils/promptUtils.ts index 8404436f..0a59cada 100644 --- a/utils/promptUtils.ts +++ b/utils/promptUtils.ts @@ -1,67 +1,127 @@ -import { ChatSettings, Message } from '../database/chatRepository'; +import { + ChatSettings, + Message, + SourceDocument, +} from '../database/chatRepository'; import { Model } from '../database/modelRepository'; import { type Message as ExecutorchMessage } from 'react-native-executorch'; +import { getPromptCharBudget } from '../constants/context-window'; const CONTEXT_INSTRUCTION = ` IMPORTANT CONTEXT INFORMATION: -You have access to relevant information from the user's document sources. Use this context to provide accurate, well-informed responses. Always prioritize information from the provided context when it's relevant to the user's question. +You have access to relevant excerpts from the user's document sources. Use this context to provide accurate, well-informed responses. Always prioritize information from the provided context when it's relevant to the user's question. Instructions for using context: - The context is delimited by and tags -- Refer to the context information when answering questions -- If the context directly addresses the user's question, use that information as the primary basis for your response +- Retrieved passages are labeled "Source N: "; a freshly attached document's overview is labeled "Current Attachment Source: (Overview)" +- The block is the ONLY authoritative source for the current question. Answer strictly from the excerpts inside it. +- Do NOT describe, summarize, or answer about any document that is not present in the current block, even if it was discussed or attached in an earlier turn of this conversation. Earlier turns are for conversational continuity only, not a source of document facts. - If information from context conflicts with your general knowledge, prioritize the context -- If the context doesn't contain relevant information say "I don't know" or "The provided context does not contain the information" -- When citing information from context, you can reference it naturally without formal citations`; +- If the context doesn't contain relevant information say "I don't know" or "The provided context does not contain the information"`; + +const getPreferredSourceInstruction = (sources?: SourceDocument[]) => { + if (!sources?.length) return ''; + + const sourceNames = sources.map((source) => source.name).join(', '); + return ` + +CURRENT ATTACHMENT PRIORITY: +The user just attached these document sources to the current message: ${sourceNames}. +They are the primary subject of the latest question. When the user says "this file", "the document", "the file", "it" or asks about a format, they mean these current attachment sources — never a document that only appeared earlier in the conversation. +Base your answer on the documents present in the block below. Only bring in another source when these attachment sources do not contain the answer, or the user's question explicitly asks about a different document. +You may still use earlier conversation for continuity when it does not conflict with the current attachment sources.`; +}; export const prepareMessagesForLLM = ( activeChatMessages: Message[], context: string[], settings: ChatSettings, - model: Model + model: Model, + preferredSourceDocuments?: SourceDocument[] ): ExecutorchMessage[] => { let systemPrompt = settings.systemPrompt; if (context.length > 0) { systemPrompt += CONTEXT_INSTRUCTION; + systemPrompt += getPreferredSourceInstruction(preferredSourceDocuments); } - const filteredMessages: ExecutorchMessage[] = activeChatMessages - .filter((msg) => msg.role !== 'event') - .map((msg) => ({ - role: msg.role, - content: msg.content, - ...(msg.imagePath ? { mediaPath: msg.imagePath } : {}), - })); + const nonEventMessages = activeChatMessages.filter( + (msg): msg is Message & { role: Exclude } => + msg.role !== 'event' + ); + const lastNonEventMessage = nonEventMessages.at(-1); + const messagesForLLM = + lastNonEventMessage?.role === 'assistant' && + lastNonEventMessage.content.trim().length === 0 + ? nonEventMessages.slice(0, -1) + : nonEventMessages; + + const filteredMessages: ExecutorchMessage[] = messagesForLLM.map((msg) => ({ + role: msg.role, + content: msg.content, + ...(msg.imagePath ? { mediaPath: msg.imagePath } : {}), + })); const messagesWithSystemPrompt: ExecutorchMessage[] = [ { role: 'system', content: systemPrompt }, ...filteredMessages, ]; - const lastMessage = messagesWithSystemPrompt.at(-1); - - if (!lastMessage) { + if (messagesWithSystemPrompt.length <= 1) { return messagesWithSystemPrompt; } + const lastMessage = messagesWithSystemPrompt.at(-1)!; + if (settings.thinkingEnabled) { lastMessage.content += ' /think'; } else if (model.thinking) { lastMessage.content += ' /no_think'; } + const budgetChars = getPromptCharBudget(model); + const systemChars = messagesWithSystemPrompt[0].content.length; + if (context.length > 0) { - // Strip any nested in source chunks — otherwise a document - // containing the literal closing tag (e.g. an HTML export) would close - // the delimiter early and the rest would be parsed as user instruction. const safeContext = context .map((c) => c.replace(/<\/context>/gi, '')) .join(' '); - lastMessage.content = `${safeContext} - ${lastMessage.content} + + const userText = lastMessage.content; + const groundingHint = preferredSourceDocuments?.length + ? `\nAnswer only about the document(s) in the above. Ignore any document mentioned earlier in the chat that is not in it.` + : ''; + const wrap = (ctx: string) => `${ctx}${groundingHint} + ${userText} `; + + const availableForLast = Math.max(0, budgetChars - systemChars); + let finalContext = safeContext; + if (wrap(finalContext).length > availableForLast) { + const overhead = wrap('').length; + const room = Math.max(0, availableForLast - overhead); + finalContext = safeContext.slice(0, room); + } + lastMessage.content = wrap(finalContext); } - return messagesWithSystemPrompt; + const mandatoryChars = systemChars + lastMessage.content.length; + let remainingChars = budgetChars - mandatoryChars; + const history = messagesWithSystemPrompt.slice(1, -1); + const keptReversed: ExecutorchMessage[] = []; + for (let i = history.length - 1; i >= 0; i--) { + const cost = history[i].content.length; + if (remainingChars - cost < 0) { + break; + } + remainingChars -= cost; + keptReversed.push(history[i]); + } + + return [ + messagesWithSystemPrompt[0], + ...keptReversed.reverse(), + lastMessage, + ]; }; From 822bf9b8cf67bcd3339259c9cb9f259d6e04956f Mon Sep 17 00:00:00 2001 From: Krzysztof Faracik Date: Tue, 7 Jul 2026 11:32:37 +0200 Subject: [PATCH 06/42] feat(attachments): require embeddings for docs and normalize file text --- __tests__/ChatBar.test.tsx | 62 +++++++----- __tests__/fileReaders.test.ts | 54 +++++++++-- __tests__/useAttachment.test.ts | 69 ++++++++++++- components/chat-screen/ChatBar.tsx | 9 ++ hooks/useAttachment.ts | 151 +++++++++++++++++++++-------- utils/fileReaders.ts | 76 +++++++++------ 6 files changed, 322 insertions(+), 99 deletions(-) diff --git a/__tests__/ChatBar.test.tsx b/__tests__/ChatBar.test.tsx index 0b84541c..1c47b267 100644 --- a/__tests__/ChatBar.test.tsx +++ b/__tests__/ChatBar.test.tsx @@ -13,13 +13,16 @@ jest.mock('../context/ThemeContext', () => ({ })); jest.mock('../store/llmStore', () => ({ - useLLMStore: jest.fn(() => ({ - isGenerating: false, - isProcessingPrompt: false, - interrupt: jest.fn(), - loadModel: jest.fn(), - model: null, - })), + useLLMStore: jest.fn((selector?: (s: any) => any) => { + const state = { + isGenerating: false, + isProcessingPrompt: false, + interrupt: jest.fn(), + loadModel: jest.fn(), + model: null, + }; + return selector ? selector(state) : state; + }), })); const mockUseAttachment = { @@ -193,12 +196,15 @@ const renderBar = (props: Partial = {}) => render(); beforeEach(() => { - mockUseLLMStore.mockReturnValue({ - isGenerating: false, - isProcessingPrompt: false, - interrupt: jest.fn(), - loadModel: jest.fn(), - model: null, + mockUseLLMStore.mockImplementation((selector?: (s: any) => any) => { + const state = { + isGenerating: false, + isProcessingPrompt: false, + interrupt: jest.fn(), + loadModel: jest.fn(), + model: null, + }; + return selector ? selector(state) : state; }); mockUseAttachment.attachments = []; mockUseAttachment.openSheet.mockClear(); @@ -299,12 +305,15 @@ describe('downloaded model — text input', () => { describe('generating state', () => { it('shows interrupt button when isGenerating', () => { - mockUseLLMStore.mockReturnValue({ - isGenerating: true, - isProcessingPrompt: false, - interrupt: jest.fn(), - loadModel: jest.fn(), - model: null, + mockUseLLMStore.mockImplementation((selector?: (s: any) => any) => { + const state = { + isGenerating: true, + isProcessingPrompt: false, + interrupt: jest.fn(), + loadModel: jest.fn(), + model: null, + }; + return selector ? selector(state) : state; }); renderBar(); expect(screen.getByTestId('interrupt-btn')).toBeTruthy(); @@ -312,12 +321,15 @@ describe('generating state', () => { it('calls interrupt when interrupt button is pressed', () => { const interrupt = jest.fn(); - mockUseLLMStore.mockReturnValue({ - isGenerating: true, - isProcessingPrompt: false, - interrupt, - loadModel: jest.fn(), - model: null, + mockUseLLMStore.mockImplementation((selector?: (s: any) => any) => { + const state = { + isGenerating: true, + isProcessingPrompt: false, + interrupt, + loadModel: jest.fn(), + model: null, + }; + return selector ? selector(state) : state; }); renderBar(); fireEvent.press(screen.getByTestId('interrupt-btn')); diff --git a/__tests__/fileReaders.test.ts b/__tests__/fileReaders.test.ts index 2ad41d83..ea21cd05 100644 --- a/__tests__/fileReaders.test.ts +++ b/__tests__/fileReaders.test.ts @@ -1,4 +1,4 @@ -import { readDocumentText } from '../utils/fileReaders'; +import { readDocumentText, normalizePdfText } from '../utils/fileReaders'; import { readPDF } from 'react-native-pdfium'; import { File } from 'expo-file-system'; @@ -64,8 +64,6 @@ describe('readDocumentText — TXT / MD', () => { }); describe('readDocumentText — HTML', () => { - const html = (body: string) => body; - it('strips basic HTML tags', async () => { const mockText = jest.fn().mockResolvedValue('

Hello world

'); MockFile.mockImplementation(() => ({ text: mockText })); @@ -132,10 +130,54 @@ describe('readDocumentText — unsupported types', () => { 'Unsupported file type: docx' ); }); +}); + +describe('readDocumentText — CSV', () => { + it('reads csv files as text via File.text()', async () => { + const mockText = jest.fn().mockResolvedValue('a,b,c\n1,2,3'); + MockFile.mockImplementation(() => ({ text: mockText })); + + const result = await readDocumentText('/data.csv', 'csv'); + expect(MockFile).toHaveBeenCalledWith('/data.csv'); + expect(result).toBe('a,b,c\n1,2,3'); + }); +}); + +describe('normalizePdfText', () => { + it('collapses single soft-wrap line breaks into spaces', () => { + expect(normalizePdfText('Sta\nwka\nVAT')).toBe('Sta wka VAT'); + }); + + it('rejoins wrapped multi-word headers', () => { + expect(normalizePdfText('Cena brutto\n[zł]')).toBe('Cena brutto [zł]'); + }); - it('throws for csv', async () => { - await expect(readDocumentText('/data.csv', 'csv')).rejects.toThrow( - 'Unsupported file type: csv' + it('preserves paragraph breaks between blocks', () => { + expect(normalizePdfText('Block one.\n\n\nBlock two.')).toBe( + 'Block one.\n\nBlock two.' ); }); + + it('rejoins a word split by a hyphen at a line break', () => { + expect(normalizePdfText('structured e-\ninvoice')).toBe( + 'structured einvoice' + ); + }); + + it('strips control and format characters (soft hyphen, zero-width space)', () => { + const softHyphen = String.fromCharCode(0x00ad); + const zeroWidth = String.fromCharCode(0x200b); + const input = `e${softHyphen}invoice a${zeroWidth}b`; + expect(normalizePdfText(input)).toBe('einvoice ab'); + }); + + it('leaves already-clean single-line text unchanged', () => { + expect(normalizePdfText('extracted text')).toBe('extracted text'); + }); + + it('applies to text read from a PDF', async () => { + mockReadPDF.mockResolvedValue('Cena brutto\n[zł]\nWartość'); + const result = await readDocumentText('file:///doc.pdf', 'pdf'); + expect(result).toBe('Cena brutto [zł] Wartość'); + }); }); diff --git a/__tests__/useAttachment.test.ts b/__tests__/useAttachment.test.ts index ec0bd05b..dfc25e81 100644 --- a/__tests__/useAttachment.test.ts +++ b/__tests__/useAttachment.test.ts @@ -27,12 +27,25 @@ jest.mock('react-native-toast-message', () => ({ import { launchImageLibrary, launchCamera } from 'react-native-image-picker'; import * as DocumentPicker from 'expo-document-picker'; import { useAttachment } from '../hooks/useAttachment'; +import { useEmbeddingModelStore } from '../store/embeddingModelStore'; const mockLaunchImageLibrary = launchImageLibrary as jest.Mock; const mockLaunchCamera = launchCamera as jest.Mock; const mockGetDocumentAsync = DocumentPicker.getDocumentAsync as jest.Mock; -beforeEach(() => jest.clearAllMocks()); +beforeEach(() => { + jest.clearAllMocks(); + useEmbeddingModelStore.setState({ status: 'ready', progress: 1 }); +}); + +const createDeferred = () => { + let resolve!: (value: T) => void; + const promise = new Promise((promiseResolve) => { + resolve = promiseResolve; + }); + + return { promise, resolve }; +}; describe('useAttachment', () => { it('initializes with empty attachments', () => { @@ -99,6 +112,56 @@ describe('useAttachment', () => { expect(att.status).toBe('ready'); }); + it('ignores a stale document result when a second document replaces it', async () => { + const firstSource = createDeferred<{ + success: boolean; + sourceId: number; + }>(); + const secondSource = createDeferred<{ + success: boolean; + sourceId: number; + }>(); + const mockAddSource = jest + .fn() + .mockReturnValueOnce(firstSource.promise) + .mockReturnValueOnce(secondSource.promise); + const { useSourceStore } = require('../store/sourceStore'); + useSourceStore.getState.mockReturnValue({ addSource: mockAddSource }); + mockGetDocumentAsync + .mockResolvedValueOnce({ + canceled: false, + assets: [{ uri: 'file://first.pdf', name: 'first.pdf', size: 100 }], + }) + .mockResolvedValueOnce({ + canceled: false, + assets: [{ uri: 'file://second.pdf', name: 'second.pdf', size: 100 }], + }); + jest.spyOn(console, 'warn').mockImplementation(() => {}); + + const { result } = renderHook(() => useAttachment()); + let firstPick!: Promise; + let secondPick!: Promise; + + await act(async () => { + firstPick = result.current.pickDocument(); + }); + await act(async () => { + secondPick = result.current.pickDocument(); + }); + await act(async () => { + secondSource.resolve({ success: true, sourceId: 2 }); + await secondPick; + }); + await act(async () => { + firstSource.resolve({ success: true, sourceId: 1 }); + await firstPick; + }); + + expect(result.current.attachments).toHaveLength(1); + expect(result.current.attachments[0].name).toBe('second.pdf'); + expect(result.current.attachments[0].sourceId).toBe(2); + }); + it('removeAttachment removes by id', async () => { mockLaunchImageLibrary.mockResolvedValue({ assets: [{ uri: 'file://photo.jpg' }], @@ -192,7 +255,7 @@ describe('useAttachment', () => { expect(result.current.attachments[0].type).toBe('document'); }); - it('picking an image after a document cleans up the orphaned source', async () => { + it('picking an image after a document does not clean up sources during attachment replacement', async () => { mockGetDocumentAsync.mockResolvedValue({ canceled: false, assets: [{ uri: 'file://doc.txt', name: 'doc.txt', size: 100 }], @@ -222,7 +285,7 @@ describe('useAttachment', () => { expect(result.current.attachments).toHaveLength(1); expect(result.current.attachments[0].type).toBe('image'); - expect(mockCleanup).toHaveBeenCalled(); + expect(mockCleanup).not.toHaveBeenCalled(); }); it('addPastedAttachment replaces an existing image', () => { diff --git a/components/chat-screen/ChatBar.tsx b/components/chat-screen/ChatBar.tsx index 0d0bb739..85fe8139 100644 --- a/components/chat-screen/ChatBar.tsx +++ b/components/chat-screen/ChatBar.tsx @@ -18,6 +18,7 @@ import { import type { SharedValue } from 'react-native-reanimated'; import { type PasteEventPayload, TextInputWrapper } from 'expo-paste-input'; import AttachmentSheet from '../bottomSheets/AttachmentSheet'; +import EmbeddingDownloadSheet from '../bottomSheets/EmbeddingDownloadSheet'; import { useAttachment, Attachment } from '../../hooks/useAttachment'; import { Model } from '../../database/modelRepository'; import { fontFamily, fontSizes, lineHeights } from '../../styles/fontStyles'; @@ -84,9 +85,12 @@ const ChatBar = ({ const { attachments, sheetRef, + embeddingDownloadSheetRef, pickFromLibrary, pickFromCamera, pickDocument, + downloadModelAndContinue, + markDownloadSheetClosed, removeAttachment, clearAll, openSheet, @@ -343,6 +347,11 @@ const ChatBar = ({ onPickDocument={pickDocument} onSheetStateChange={onAttachmentSheetStateChange} /> + )} diff --git a/hooks/useAttachment.ts b/hooks/useAttachment.ts index 63f94c28..115bc566 100644 --- a/hooks/useAttachment.ts +++ b/hooks/useAttachment.ts @@ -1,4 +1,4 @@ -import { useState, useRef, useCallback } from 'react'; +import { useState, useRef, useCallback, useEffect } from 'react'; import { launchImageLibrary, launchCamera } from 'react-native-image-picker'; import * as DocumentPicker from 'expo-document-picker'; import { BottomSheetModal } from '@gorhom/bottom-sheet'; @@ -6,6 +6,7 @@ import { Platform, PermissionsAndroid } from 'react-native'; import Toast from 'react-native-toast-message'; import { useSourceStore } from '../store/sourceStore'; import { useVectorStore } from '../context/VectorStoreContext'; +import { useEmbeddingModelStore } from '../store/embeddingModelStore'; export interface Attachment { id: string; @@ -57,21 +58,25 @@ export const useAttachment = () => { const [attachments, setAttachments] = useState([]); const attachmentsRef = useRef([]); attachmentsRef.current = attachments; + const attachmentRequestRef = useRef(0); + const currentDocumentAttachmentIdRef = useRef(null); const sheetRef = useRef(null); - const { vectorStore } = useVectorStore(); + const embeddingDownloadSheetRef = useRef(null); + const embeddingDownloadSheetOpenRef = useRef(false); + const { vectorStore, embeddings } = useVectorStore(); - const replaceWithImage = useCallback( - (uri: string) => { - const hadSource = attachmentsRef.current.some((a) => a.sourceId); - setAttachments([ - { id: `img-${Date.now()}`, type: 'image', uri, status: 'ready' }, - ]); - if (hadSource && vectorStore) { - useSourceStore.getState().cleanupOrphanedSources(vectorStore); - } - }, - [vectorStore] - ); + useEffect(() => { + return () => { + embeddingDownloadSheetOpenRef.current = false; + }; + }, []); + + const replaceWithImage = useCallback((uri: string) => { + currentDocumentAttachmentIdRef.current = null; + setAttachments([ + { id: `img-${Date.now()}`, type: 'image', uri, status: 'ready' }, + ]); + }, []); const pickFromLibrary = useCallback(async () => { const granted = await requestAndroidGalleryPermission(); @@ -101,23 +106,41 @@ export const useAttachment = () => { } }, [replaceWithImage]); - const pickDocument = useCallback(async () => { + const runDocumentPicker = useCallback(async () => { const pickedFileResult = await DocumentPicker.getDocumentAsync({ - type: ['application/pdf', 'text/plain', 'text/markdown', 'text/html'], + type: [ + 'application/pdf', + 'text/plain', + 'text/markdown', + 'text/x-markdown', + 'text/html', + 'text/csv', + 'text/comma-separated-values', + 'application/csv', + ], copyToCacheDirectory: true, }); if (pickedFileResult.canceled || !pickedFileResult.assets[0]) return; const asset = pickedFileResult.assets[0]; - const fileType = asset.uri.split('.').pop() || ''; + const extFromName = asset.name?.includes('.') + ? asset.name.split('.').pop() + : undefined; + const fileType = ( + extFromName || + asset.uri.split('.').pop() || + '' + ).toLowerCase(); const fileName = asset.name?.split('.')[0] || asset.uri.split('/').pop()?.split('.')[0] || 'Unnamed'; const attachmentId = `doc-${Date.now()}`; + const requestId = attachmentRequestRef.current + 1; + attachmentRequestRef.current = requestId; + currentDocumentAttachmentIdRef.current = attachmentId; - const hadSource = attachmentsRef.current.some((a) => a.sourceId); setAttachments([ { id: attachmentId, @@ -127,20 +150,34 @@ export const useAttachment = () => { status: 'loading', }, ]); - if (hadSource && vectorStore) { - useSourceStore.getState().cleanupOrphanedSources(vectorStore); - } try { const newSource = { - name: fileName, + name: asset.name || fileName, type: fileType, size: asset.size || null, }; const { addSource } = useSourceStore.getState(); - const result = await addSource(newSource, asset.uri, vectorStore!); + const result = await addSource( + newSource, + asset.uri, + vectorStore!, + embeddings + ); + const isCurrentDocumentRequest = + attachmentRequestRef.current === requestId && + currentDocumentAttachmentIdRef.current === attachmentId; if (result.success) { + if (!isCurrentDocumentRequest) { + console.warn('Ignoring stale document processing result', { + attachmentId, + sourceId: result.sourceId, + name: newSource.name, + }); + return; + } + setAttachments((prev) => prev.map((a) => a.id === attachmentId @@ -149,6 +186,8 @@ export const useAttachment = () => { ) ); } else { + if (!isCurrentDocumentRequest) return; + setAttachments((prev) => prev.filter((a) => a.id !== attachmentId)); Toast.show({ type: 'defaultToast', @@ -157,32 +196,65 @@ export const useAttachment = () => { : 'Failed to process document.', }); } - } catch { + } catch (error) { + console.error('Document attachment processing threw', { + attachmentId, + requestId, + name: asset.name || fileName, + error, + }); + if (attachmentRequestRef.current !== requestId) return; + setAttachments((prev) => prev.filter((a) => a.id !== attachmentId)); Toast.show({ type: 'defaultToast', text1: 'Error reading document.', }); } - }, [vectorStore]); + }, [vectorStore, embeddings]); - const removeAttachment = useCallback( - (id: string) => { - const hadSourceId = attachmentsRef.current.some( - (a) => a.id === id && a.sourceId - ); - setAttachments((prev) => prev.filter((a) => a.id !== id)); - if (hadSourceId && vectorStore) { - useSourceStore.getState().cleanupOrphanedSources(vectorStore); - } - }, - [vectorStore] - ); + const markDownloadSheetClosed = useCallback(() => { + embeddingDownloadSheetOpenRef.current = false; + }, []); + + const pickDocument = useCallback(async () => { + if (useEmbeddingModelStore.getState().status === 'ready') { + return runDocumentPicker(); + } + embeddingDownloadSheetOpenRef.current = true; + embeddingDownloadSheetRef.current?.present(); + }, [runDocumentPicker]); + + const downloadModelAndContinue = useCallback(async () => { + if (!vectorStore) return; + const ready = await useEmbeddingModelStore + .getState() + .ensureReady(vectorStore); + if (!ready) { + Toast.show({ + type: 'defaultToast', + text1: 'Failed to download the document model.', + }); + return; + } + if (embeddingDownloadSheetOpenRef.current) { + embeddingDownloadSheetRef.current?.dismiss(); + await runDocumentPicker(); + } + }, [vectorStore, runDocumentPicker]); + + const removeAttachment = useCallback((id: string) => { + if (currentDocumentAttachmentIdRef.current === id) { + currentDocumentAttachmentIdRef.current = null; + } + setAttachments((prev) => prev.filter((a) => a.id !== id)); + }, []); const clearAll = useCallback( (options: ClearAllOptions = {}) => { - const cleanupSources = options.cleanupSources ?? true; + const cleanupSources = options.cleanupSources ?? false; const hadDocuments = attachmentsRef.current.some((a) => a.sourceId); + currentDocumentAttachmentIdRef.current = null; setAttachments([]); if (cleanupSources && hadDocuments && vectorStore) { useSourceStore.getState().cleanupOrphanedSources(vectorStore); @@ -217,9 +289,12 @@ export const useAttachment = () => { return { attachments, sheetRef, + embeddingDownloadSheetRef, pickFromLibrary, pickFromCamera, pickDocument, + downloadModelAndContinue, + markDownloadSheetClosed, removeAttachment, clearAll, openSheet, diff --git a/utils/fileReaders.ts b/utils/fileReaders.ts index 25f34d49..55eb9dac 100644 --- a/utils/fileReaders.ts +++ b/utils/fileReaders.ts @@ -1,28 +1,62 @@ import { readPDF } from 'react-native-pdfium'; import { File } from 'expo-file-system'; -/** - * Reads text content from various file formats - * @param filePath - The path to the file - * @param fileType - The file extension (pdf, txt, md, html, csv, etc.) - * @returns The extracted text content - */ +const stripHtml = (html: string): string => + html + .replace(/)<[^<]*)*<\/script>/gi, '') + .replace(/)<[^<]*)*<\/style>/gi, '') + .replace(/<[^>]+>/g, ' ') + .replace(/ /g, ' ') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/\s+/g, ' ') + .trim(); + +const stripInvisibleChars = (text: string): string => + text.replace(/[\p{Cc}\p{Cf}\uFFF9-\uFFFF]/gu, (ch) => + ch === '\n' || ch === '\t' ? ch : '' + ); + +const rejoinHyphenatedWords = (text: string): string => + text.replace(/(\p{Ll})[-\u2010\u2011]\n(\p{Ll})/gu, '$1$2'); + +const collapseBlankLines = (text: string): string => + text.replace(/[ \t]*\n(?:[ \t]*\n)+/g, '\n\n'); + +const unwrapSoftLineBreaks = (text: string): string => + text.replace(/([^\n])\n(?!\n)/g, '$1 '); + +const tidyWhitespace = (text: string): string => + text.replace(/[ \t]{2,}/g, ' ').replace(/ +\n/g, '\n'); + +// PDF extraction leaks layout artifacts (hyphen-split words, hard-wrapped +// lines, invisible control characters) that break retrieval and citations. +export const normalizePdfText = (raw: string): string => { + const stripped = stripInvisibleChars(raw); + const rejoined = rejoinHyphenatedWords(stripped); + const collapsed = collapseBlankLines(rejoined); + const unwrapped = unwrapSoftLineBreaks(collapsed); + return tidyWhitespace(unwrapped).trim(); +}; + export async function readDocumentText( filePath: string, - fileType: string + fileExtension: string ): Promise { - const lowerFileType = fileType.toLowerCase(); - - switch (lowerFileType) { + switch (fileExtension.toLowerCase()) { case 'pdf': { - // PDF reader needs path without file:// prefix const normalizedPath = filePath.replace('file://', ''); - return await readPDF(normalizedPath); + const rawText = await readPDF(normalizedPath); + return normalizePdfText(rawText); } case 'txt': case 'md': - case 'markdown': { + case 'markdown': + case 'csv': { const textFile = new File(filePath); return await textFile.text(); } @@ -31,22 +65,10 @@ export async function readDocumentText( case 'htm': { const htmlFile = new File(filePath); const htmlContent = await htmlFile.text(); - // Basic HTML tag stripping - removes all HTML tags - return htmlContent - .replace(/)<[^<]*)*<\/script>/gi, '') // Remove script tags - .replace(/)<[^<]*)*<\/style>/gi, '') // Remove style tags - .replace(/<[^>]+>/g, ' ') // Remove all HTML tags - .replace(/ /g, ' ') - .replace(/&/g, '&') - .replace(/</g, '<') - .replace(/>/g, '>') - .replace(/"/g, '"') - .replace(/'/g, "'") - .replace(/\s+/g, ' ') // Normalize whitespace - .trim(); + return stripHtml(htmlContent); } default: - throw new Error(`Unsupported file type: ${fileType}`); + throw new Error(`Unsupported file type: ${fileExtension}`); } } From a83b97f81ed69ab11f41ed93bb01711ceb67193b Mon Sep 17 00:00:00 2001 From: Krzysztof Faracik Date: Tue, 7 Jul 2026 11:41:33 +0200 Subject: [PATCH 07/42] feat(rag): expand retrieved chunks with neighboring context --- __tests__/hybridRetrieval.test.ts | 45 +++++++++++ store/sourceStore.ts | 2 +- utils/hybridRetrieval.ts | 129 +++++++++++++++++++++++++++--- 3 files changed, 163 insertions(+), 13 deletions(-) diff --git a/__tests__/hybridRetrieval.test.ts b/__tests__/hybridRetrieval.test.ts index cf549913..02db1e3a 100644 --- a/__tests__/hybridRetrieval.test.ts +++ b/__tests__/hybridRetrieval.test.ts @@ -226,4 +226,49 @@ describe('hybridRetrieve', () => { expect(result.map((c) => c.metadata?.name)).toContain('FAQ'); }); + + it('expands a selected chunk with its same-document neighbors, in order', async () => { + const vectorResults = [ + { + id: '1:2', + document: 'middle of the table row 3', + embedding: [1, 0], + similarity: 0.9, + metadata: { documentId: 1, name: 'Invoice' }, + }, + ]; + const vectorsById = { + '1:1': { + id: '1:1', + document: 'table header and rows 1-2', + embedding: [1, 0], + metadata: JSON.stringify({ documentId: 1, name: 'Invoice' }), + }, + '1:3': { + id: '1:3', + document: 'table rows 4-6 and totals', + embedding: [1, 0], + metadata: JSON.stringify({ documentId: 1, name: 'Invoice' }), + }, + }; + mockKeywordSearch.mockResolvedValue([]); + + const result = await hybridRetrieve({ + prompt: 'what is in the table', + enabledSourceIds: [1], + vectorStore: makeVectorStore(vectorResults, vectorsById), + sourceNamesById: new Map(), + embeddings: null, + }); + + expect(result.map((c) => c.document)).toEqual([ + 'table header and rows 1-2', + 'middle of the table row 3', + 'table rows 4-6 and totals', + ]); + expect(new Set(result.map((c) => c.metadata?.name))).toEqual( + new Set(['Invoice']) + ); + expect(result.map((c) => c.similarity)).toEqual([0, 0.9, 0]); + }); }); diff --git a/store/sourceStore.ts b/store/sourceStore.ts index afd88416..591e0df3 100644 --- a/store/sourceStore.ts +++ b/store/sourceStore.ts @@ -38,7 +38,7 @@ interface SourceStore { } const TEXT_SPLITTER_CHUNK_SIZE = 1000; -const TEXT_SPLITTER_CHUNK_OVERLAP = 100; +const TEXT_SPLITTER_CHUNK_OVERLAP = 200; export const useSourceStore = create((set, get) => ({ sources: [], diff --git a/utils/hybridRetrieval.ts b/utils/hybridRetrieval.ts index f981d0c7..a73f8901 100644 --- a/utils/hybridRetrieval.ts +++ b/utils/hybridRetrieval.ts @@ -3,7 +3,7 @@ import { type Scalar } from '@op-engineering/op-sqlite'; import { LFMEmbeddings } from './lfmEmbeddings'; import { extractQueryTerms, stemPrefix } from './queryTerms'; import { keywordSearch } from '../database/keywordIndex'; -import { type ContextChunk } from './contextUtils'; +import { type ContextChunk, sourceKey } from './contextUtils'; import { cosineSimilarity, maximalMarginalRelevance, @@ -76,6 +76,116 @@ const hydrateChunksByIds = async ( }); }; +const parseChunkId = ( + id: string +): { documentId: number; chunkIndex: number } | null => { + const match = /^(\d+):(\d+)$/.exec(id); + return match + ? { documentId: Number(match[1]), chunkIndex: Number(match[2]) } + : null; +}; + +const NEIGHBOR_RADIUS = 1; + +const expandSelectedWithNeighbors = async ( + selectedIds: string[], + byId: Map, + vectorStore: OPSQLiteVectorStore, + sourceNamesById: Map +): Promise => { + type Group = { + documentId?: number; + name?: string; + indices: Map; + }; + const groupOrder: string[] = []; + const groups = new Map(); + const selectedSimilarity = new Map(); + + for (const id of selectedIds) { + const candidate = byId.get(id); + if (!candidate) continue; + selectedSimilarity.set(id, candidate.similarity); + + const key = sourceKey(candidate.documentId, candidate.name ?? ''); + let group = groups.get(key); + if (!group) { + group = { + documentId: candidate.documentId, + name: candidate.name, + indices: new Map(), + }; + groups.set(key, group); + groupOrder.push(key); + } + + const parsed = parseChunkId(id); + group.indices.set(id, parsed ? parsed.chunkIndex : -1); + if (!parsed) continue; + + for (let offset = -NEIGHBOR_RADIUS; offset <= NEIGHBOR_RADIUS; offset++) { + if (offset === 0) continue; + const neighborIndex = parsed.chunkIndex + offset; + if (neighborIndex < 0) continue; + const neighborId = `${parsed.documentId}:${neighborIndex}`; + if (!group.indices.has(neighborId)) { + group.indices.set(neighborId, neighborIndex); + } + } + } + + const allIds = groupOrder.flatMap((key) => [ + ...groups.get(key)!.indices.keys(), + ]); + const fetched = await hydrateChunksByIds( + vectorStore, + allIds.filter((id) => !byId.has(id)) + ); + + const chunkById = new Map(); + for (const id of allIds) { + const candidate = byId.get(id); + if (candidate) { + chunkById.set(id, { + id, + document: candidate.document, + embedding: candidate.embedding, + documentId: candidate.documentId, + name: candidate.name, + }); + } + } + for (const row of fetched) { + chunkById.set(row.id, { + ...row, + name: resolveName(row.name, row.documentId, sourceNamesById), + }); + } + + const result: ContextChunk[] = []; + for (const key of groupOrder) { + const group = groups.get(key)!; + const orderedIds = [...group.indices.entries()] + .filter(([id]) => chunkById.get(id)?.document) + .sort((a, b) => a[1] - b[1]) + .map(([id]) => id); + + for (const id of orderedIds) { + const info = chunkById.get(id)!; + result.push({ + document: info.document, + similarity: selectedSimilarity.get(id) ?? 0, + metadata: { + documentId: info.documentId ?? group.documentId, + name: info.name ?? group.name, + }, + }); + } + } + + return result; +}; + export type HybridRetrieveParams = { prompt: string; enabledSourceIds: number[]; @@ -209,15 +319,10 @@ export const hybridRetrieve = async ({ ) : selected; - return ordered - .map((item) => byId.get(item.id)) - .filter((candidate): candidate is Candidate => candidate !== undefined) - .map((candidate) => ({ - document: candidate.document, - similarity: candidate.similarity, - metadata: { - documentId: candidate.documentId, - name: candidate.name, - }, - })); + return expandSelectedWithNeighbors( + ordered.map((item) => item.id), + byId, + vectorStore, + sourceNamesById + ); }; From 697291dcf52459c42d78ab09ce029bbf733cd380 Mon Sep 17 00:00:00 2001 From: Krzysztof Faracik Date: Tue, 7 Jul 2026 12:05:10 +0200 Subject: [PATCH 08/42] feat(citations): show cited sources with highlighted passages --- __tests__/MessageItem.test.tsx | 159 +++++++- __tests__/citationHighlight.test.ts | 117 ++++++ __tests__/citations.test.ts | 29 ++ assets/icons/book-open.svg | 4 + components/chat-screen/MarkdownComponent.tsx | 11 +- components/chat-screen/MessageItem.tsx | 116 +++++- components/chat-screen/Messages.tsx | 15 + components/chat-screen/SourcesSheet.tsx | 359 +++++++++++++++++++ constants/citations.ts | 7 + constants/design-system.ts | 69 ++++ utils/citationHighlight.ts | 176 +++++++++ utils/citations.ts | 16 + 12 files changed, 1065 insertions(+), 13 deletions(-) create mode 100644 __tests__/citationHighlight.test.ts create mode 100644 __tests__/citations.test.ts create mode 100644 assets/icons/book-open.svg create mode 100644 components/chat-screen/SourcesSheet.tsx create mode 100644 constants/citations.ts create mode 100644 constants/design-system.ts create mode 100644 utils/citationHighlight.ts create mode 100644 utils/citations.ts diff --git a/__tests__/MessageItem.test.tsx b/__tests__/MessageItem.test.tsx index 496880ba..8fdb7f0f 100644 --- a/__tests__/MessageItem.test.tsx +++ b/__tests__/MessageItem.test.tsx @@ -1,8 +1,12 @@ import React from 'react'; -import { render, screen } from '@testing-library/react-native'; -import { lightTheme } from '../styles/colors'; +import { fireEvent, render, screen } from '@testing-library/react-native'; -// ── mocks ───────────────────────────────────────────────────────────────────── +type MockLLMState = { + isGenerating: boolean; + isProcessingPrompt: boolean; +}; + +type MockLLMSelector = (state: MockLLMState) => T; jest.mock('../context/ThemeContext', () => ({ useTheme: () => ({ @@ -14,7 +18,10 @@ jest.mock('../context/ThemeContext', () => ({ })); jest.mock('../store/llmStore', () => ({ - useLLMStore: jest.fn(() => ({ isGenerating: false })), + useLLMStore: jest.fn((selector?: MockLLMSelector) => { + const state = { isGenerating: false, isProcessingPrompt: false }; + return selector ? selector(state) : state; + }), })); jest.mock('../components/chat-screen/MarkdownComponent', () => { @@ -36,12 +43,36 @@ jest.mock('../components/chat-screen/ThinkingBlock', () => { jest.mock('../components/chat-screen/AnimatedChatLoading', () => () => null); +jest.mock('@gorhom/bottom-sheet', () => { + const { View } = require('react-native'); + + const BottomSheetModal = React.forwardRef(({ children }: any, ref: any) => { + React.useImperativeHandle(ref, () => ({ + present: jest.fn(), + dismiss: jest.fn(), + })); + return {children}; + }); + + return { + BottomSheetBackdrop: (props: any) => , + BottomSheetModal, + BottomSheetView: View, + BottomSheetScrollView: View, + }; +}); + // ── helpers ─────────────────────────────────────────────────────────────────── import MessageItem from '../components/chat-screen/MessageItem'; import { useLLMStore } from '../store/llmStore'; -const mockUseLLMStore = useLLMStore as jest.Mock; +const mockUseLLMStore = useLLMStore as unknown as jest.Mock; + +const setLLMState = (state: MockLLMState) => + mockUseLLMStore.mockImplementation((selector?: MockLLMSelector) => + selector ? selector(state) : state + ); const renderItem = ( props: Partial> = {} @@ -56,7 +87,7 @@ const renderItem = ( ); beforeEach(() => { - mockUseLLMStore.mockReturnValue({ isGenerating: false }); + setLLMState({ isGenerating: false, isProcessingPrompt: false }); jest.spyOn(console, 'error').mockImplementation(() => {}); }); @@ -114,6 +145,118 @@ describe('assistant messages', () => { renderItem({ role: 'assistant', content: 'Hi' }); expect(screen.queryByText(/tps:/)).toBeNull(); }); + + it('renders a sources button that opens a deduplicated list without the "Source document" label', () => { + renderItem({ + role: 'assistant', + content: 'The answer is in the report.', + sourceDocuments: [ + { documentId: 1, name: 'financial_report.pdf' }, + { documentId: 1, name: 'financial_report.pdf' }, + ], + }); + + expect(screen.getByTestId('source-action-button')).toBeTruthy(); + expect(screen.getByLabelText('Sources')).toBeTruthy(); + expect(screen.getAllByText('Sources').length).toBeGreaterThanOrEqual(1); + expect(screen.getByText('PDF')).toBeTruthy(); + expect(screen.getAllByText('financial_report.pdf')).toHaveLength(1); + expect(screen.queryByText('Source document')).toBeNull(); + + fireEvent.press(screen.getByTestId('source-action-button')); + }); + + it('keeps the cited passage collapsed until the source row is tapped', () => { + renderItem({ + role: 'assistant', + content: 'The answer is in the report.', + sourceDocuments: [ + { + documentId: 1, + name: 'financial_report.pdf', + passage: 'Net revenue grew 12% year over year.', + }, + ], + }); + + expect(screen.queryByTestId('source-passage')).toBeNull(); + + fireEvent.press(screen.getByTestId('source-item')); + + expect(screen.getByTestId('source-passage')).toBeTruthy(); + expect( + screen.getByText('Net revenue grew 12% year over year.') + ).toBeTruthy(); + }); + + it('emphasises the passage span relevant to the user question', () => { + const passage = + 'Intro sentence. Net revenue grew 12% year over year. Outro.'; + renderItem({ + role: 'assistant', + content: 'Net revenue grew 12% last year, showing strong growth.', + userQuestion: 'What was the net revenue growth?', + sourceDocuments: [ + { documentId: 1, name: 'financial_report.pdf', passage }, + ], + }); + + fireEvent.press(screen.getByTestId('source-item')); + + const cited = screen.getByText('Net revenue grew 12% year over year.'); + expect(cited).toBeTruthy(); + expect(cited.props.style).toEqual( + expect.objectContaining({ fontFamily: expect.any(String) }) + ); + }); + + it('does not render a passage block when the source has no passage', () => { + renderItem({ + role: 'assistant', + content: 'The answer is in the report.', + sourceDocuments: [{ documentId: 1, name: 'financial_report.pdf' }], + }); + + fireEvent.press(screen.getByTestId('source-item')); + expect(screen.queryByTestId('source-passage')).toBeNull(); + }); + + it('strips inline [n] citation markers from the rendered answer', () => { + renderItem({ + role: 'assistant', + content: 'The total was 100 [1].', + sourceDocuments: [{ documentId: 1, name: 'financial_report.pdf' }], + }); + + const markdown = screen.getByTestId('markdown'); + expect(markdown.props.children).toBe('The total was 100.'); + }); + + it('does not render source actions for user messages', () => { + renderItem({ + role: 'user', + content: 'Question', + sourceDocuments: [{ documentId: 1, name: 'notes.txt' }], + }); + + expect(screen.queryByTestId('source-action-button')).toBeNull(); + }); + + it('does not render source actions while the last assistant message is generating', () => { + setLLMState({ + isGenerating: true, + isProcessingPrompt: false, + }); + + renderItem({ + role: 'assistant', + content: 'Streaming answer', + isLastMessage: true, + sourceDocuments: [{ documentId: 1, name: 'report.pdf' }], + }); + + expect(screen.queryByTestId('source-action-button')).toBeNull(); + }); }); // ─── user messages ──────────────────────────────────────────────────────────── @@ -248,14 +391,14 @@ describe('thinking block parsing', () => { }); it('marks ThinkingBlock as inProgress when last message and isGenerating and thinking is incomplete', () => { - mockUseLLMStore.mockReturnValue({ isGenerating: true }); + setLLMState({ isGenerating: true, isProcessingPrompt: false }); renderItem({ content: 'working...', isLastMessage: true }); const block = screen.getByTestId('thinking-block'); expect(block.props.accessibilityLabel).toContain('inProgress:true'); }); it('does not mark ThinkingBlock as inProgress when not isLastMessage', () => { - mockUseLLMStore.mockReturnValue({ isGenerating: true }); + setLLMState({ isGenerating: true, isProcessingPrompt: false }); renderItem({ content: 'working...', isLastMessage: false }); const block = screen.getByTestId('thinking-block'); expect(block.props.accessibilityLabel).toContain('inProgress:false'); diff --git a/__tests__/citationHighlight.test.ts b/__tests__/citationHighlight.test.ts new file mode 100644 index 00000000..55b69462 --- /dev/null +++ b/__tests__/citationHighlight.test.ts @@ -0,0 +1,117 @@ +import { findCitedSpan, queryNamesDocument } from '../utils/citationHighlight'; + +describe('queryNamesDocument', () => { + it('matches when every filename token appears in the query', () => { + expect( + queryNamesDocument( + 'Co jest w pliku polityka_urlopowa_2026.txt', + 'polityka_urlopowa_2026.txt' + ) + ).toBe(true); + }); + + it('does not match an unrelated document sharing only an incidental term', () => { + expect( + queryNamesDocument( + 'Co jest w pliku polityka_urlopowa_2026.txt', + 'sample.pdf' + ) + ).toBe(false); + }); + + it('returns false when the query names no document', () => { + expect( + queryNamesDocument('O czym jest ten plik?', 'raport_finansowy.pdf') + ).toBe(false); + }); +}); + +describe('findCitedSpan', () => { + it('returns the span of the sentence most relevant to the query', () => { + const passage = + 'The company was founded in 1998. Total revenue reached 2455 PLN last year. Employees enjoy free coffee.'; + const span = findCitedSpan(passage, 'What was the total revenue?'); + + expect(span).not.toBeNull(); + const cited = passage.slice(span!.start, span!.end); + expect(cited).toBe('Total revenue reached 2455 PLN last year.'); + }); + + it('matches identifiers/numbers even when short', () => { + const passage = + 'Ogólne warunki umowy. Faktura FS-219039 na kwotę 2455,01 PLN. Dziękujemy za współpracę.'; + const span = findCitedSpan(passage, 'Ile wynosi faktura FS-219039?'); + + expect(span).not.toBeNull(); + const cited = passage.slice(span!.start, span!.end); + expect(cited).toContain('FS-219039'); + }); + + it('picks the narrowest (densest) sentence when scores tie', () => { + const passage = + 'Revenue. This long sentence also mentions revenue but pads it with a great many additional unrelated words.'; + const span = findCitedSpan(passage, 'revenue'); + + expect(span).not.toBeNull(); + expect(passage.slice(span!.start, span!.end)).toBe('Revenue.'); + }); + + it('matches an inflected passage word to its query stem (Polish)', () => { + const passage = + 'Czy moje dane sa bezpieczne? Wszystkie modele dzialaja lokalnie, a dane nigdy nie opuszczaja urzadzenia.'; + const span = findCitedSpan(passage, 'Czy moje dane opuszczaja urzadzenie?'); + + expect(span).not.toBeNull(); + const cited = passage.slice(span!.start, span!.end); + expect(cited).toBe( + 'Wszystkie modele dzialaja lokalnie, a dane nigdy nie opuszczaja urzadzenia.' + ); + }); + + it('does not stem-match short tokens or identifiers', () => { + const span = findCitedSpan('Zupa dania obiadowe.', 'gdzie sa dane'); + expect(span).toBeNull(); + }); + + it('returns null when nothing overlaps', () => { + expect( + findCitedSpan('Completely unrelated content here.', 'quarterly revenue') + ).toBeNull(); + }); + + it('ignores stopword-only queries', () => { + expect(findCitedSpan('Some real content.', 'what is the')).toBeNull(); + }); + + it('handles empty/undefined input safely', () => { + expect(findCitedSpan(undefined, 'revenue')).toBeNull(); + expect(findCitedSpan('', 'revenue')).toBeNull(); + expect(findCitedSpan('Content.', '')).toBeNull(); + }); + + it('produces offsets that map back onto the original passage', () => { + const passage = 'Intro line.\nThe invoice number is 12345.\nOutro.'; + const span = findCitedSpan(passage, 'invoice 12345'); + + expect(span).not.toBeNull(); + expect(passage.slice(span!.start, span!.end)).toBe( + 'The invoice number is 12345.' + ); + }); + + it('does not highlight on a single weak (stem-only) match', () => { + const span = findCitedSpan( + 'Skanowanie kodu QR ulatwia pobranie dokumentu z systemu KSeF.', + 'Co jest kupowane w dokumencie' + ); + expect(span).toBeNull(); + }); + + it('still highlights when two terms match by stem', () => { + const span = findCitedSpan( + 'Instrukcja obslugi urzadzenia oraz dokumentacji technicznej.', + 'urzadzenie i dokumentacja' + ); + expect(span).not.toBeNull(); + }); +}); diff --git a/__tests__/citations.test.ts b/__tests__/citations.test.ts new file mode 100644 index 00000000..4886004a --- /dev/null +++ b/__tests__/citations.test.ts @@ -0,0 +1,29 @@ +import { stripCitations } from '../utils/citations'; + +describe('stripCitations', () => { + it('removes a single citation marker and tidies punctuation spacing', () => { + expect(stripCitations('The total was 100 [1].')).toBe('The total was 100.'); + }); + + it('removes grouped citation markers', () => { + expect(stripCitations('Backed by [1][3] as noted.')).toBe( + 'Backed by as noted.' + ); + }); + + it('collapses doubled spaces left behind mid-sentence', () => { + expect(stripCitations('See [2] the report.')).toBe('See the report.'); + }); + + it('leaves text without citations untouched', () => { + expect(stripCitations('No markers here.')).toBe('No markers here.'); + }); + + it('does not strip long bracketed numbers (e.g. array indices)', () => { + expect(stripCitations('arr[1234] value')).toBe('arr[1234] value'); + }); + + it('returns empty/falsy text unchanged', () => { + expect(stripCitations('')).toBe(''); + }); +}); diff --git a/assets/icons/book-open.svg b/assets/icons/book-open.svg new file mode 100644 index 00000000..d30fae05 --- /dev/null +++ b/assets/icons/book-open.svg @@ -0,0 +1,4 @@ + + + + diff --git a/components/chat-screen/MarkdownComponent.tsx b/components/chat-screen/MarkdownComponent.tsx index 8ac68770..ceb94078 100644 --- a/components/chat-screen/MarkdownComponent.tsx +++ b/components/chat-screen/MarkdownComponent.tsx @@ -13,10 +13,17 @@ interface Props { isUser?: boolean; isThinking?: boolean; streaming?: boolean; + onLinkPress?: (event: { url: string }) => void; } const MarkdownComponent = memo( - ({ text, isUser = false, isThinking = false, streaming = false }: Props) => { + ({ + text, + isUser = false, + isThinking = false, + streaming = false, + onLinkPress, + }: Props) => { const { theme } = useTheme(); const baseColor = theme.text.primary; const baseFontSize = isThinking ? fontSizes.sm : fontSizes.md; @@ -136,6 +143,7 @@ const MarkdownComponent = memo( markdown={text} markdownStyle={markdownStyle} selectable={true} + onLinkPress={onLinkPress} /> ); } @@ -145,6 +153,7 @@ const MarkdownComponent = memo( markdown={text} markdownStyle={markdownStyle} selectable={true} + onLinkPress={onLinkPress} /> ); } diff --git a/components/chat-screen/MessageItem.tsx b/components/chat-screen/MessageItem.tsx index b8b892a2..d22277d1 100644 --- a/components/chat-screen/MessageItem.tsx +++ b/components/chat-screen/MessageItem.tsx @@ -1,14 +1,27 @@ -import React, { memo, useMemo, useState } from 'react'; -import { View, StyleSheet, Text, TouchableOpacity, Image } from 'react-native'; +import React, { memo, useCallback, useMemo, useRef, useState } from 'react'; +import { + View, + StyleSheet, + Text, + TouchableOpacity, + Image, + Pressable, + Linking, +} from 'react-native'; import MarkdownComponent from './MarkdownComponent'; import ThinkingBlock from './ThinkingBlock'; import AnimatedChatLoading from './AnimatedChatLoading'; +import SourcesSheet, { type SourcesSheetHandle } from './SourcesSheet'; import { fontFamily, fontSizes, lineHeights } from '../../styles/fontStyles'; import { useTheme } from '../../context/ThemeContext'; import { useLLMStore } from '../../store/llmStore'; import { Theme } from '../../styles/colors'; import ImageLightbox from './ImageLightbox'; import AttachmentIcon from '../../assets/icons/attachment.svg'; +import BookIcon from '../../assets/icons/book-open.svg'; +import { type SourceDocument } from '../../database/chatRepository'; +import { stripCitations } from '../../utils/citations'; +import { sourceKey } from '../../utils/contextUtils'; interface MessageItemProps { content: string; @@ -19,6 +32,8 @@ interface MessageItemProps { isLastMessage: boolean; imagePath?: string; documentName?: string; + sourceDocuments?: SourceDocument[]; + userQuestion?: string; } const THINK_OPEN = ''; @@ -63,13 +78,52 @@ const MessageItem = memo( isLastMessage = false, imagePath, documentName, + sourceDocuments, + userQuestion, }: MessageItemProps) => { const { theme } = useTheme(); const styles = useMemo(() => createStyles(theme), [theme]); const { isGenerating, isProcessingPrompt } = useLLMStore(); const [lightboxVisible, setLightboxVisible] = useState(false); + const sourcesSheetRef = useRef(null); const contentParts = parseThinkingContent(content); + const hasSources = !!sourceDocuments?.length; + const displayedSources = useMemo(() => { + if (!sourceDocuments?.length) return []; + + const seen = new Set(); + return sourceDocuments.filter((source) => { + const key = sourceKey(source.documentId, source.name); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + }, [sourceDocuments]); + + const handleLinkPress = useCallback(({ url }: { url: string }) => { + Linking.openURL(url).catch(() => {}); + }, []); + + const normalContent = useMemo( + () => + hasSources + ? stripCitations(contentParts.normalContent) + : contentParts.normalContent, + [contentParts.normalContent, hasSources] + ); + const normalAfterThink = useMemo( + () => + hasSources + ? stripCitations(contentParts.normalAfterThink ?? '') + : contentParts.normalAfterThink ?? '', + [contentParts.normalAfterThink, hasSources] + ); + + const canShowSourcesAction = + !!content.trim() && + displayedSources.length > 0 && + !(isLastMessage && (isGenerating || isProcessingPrompt)); return ( <> @@ -138,8 +192,9 @@ const MessageItem = memo( ) : null} {contentParts.normalContent.trim() && ( )} {contentParts.hasThinking && @@ -157,8 +212,9 @@ const MessageItem = memo( {contentParts.normalAfterThink && contentParts.normalAfterThink.trim() && ( )} {tokensPerSecond !== undefined && tokensPerSecond !== 0 && ( @@ -167,9 +223,38 @@ const MessageItem = memo( {tokensPerSecond?.toFixed(2)} tok/s )} + {canShowSourcesAction && ( + + [ + styles.sourcesButton, + pressed && styles.sourcesButtonPressed, + ]} + onPress={() => sourcesSheetRef.current?.present()} + hitSlop={8} + accessibilityRole="button" + accessibilityLabel="Sources" + testID="source-action-button" + > + + Sources + + + )} )} + {role === 'assistant' && canShowSourcesAction && ( + + )} ); } @@ -263,4 +348,27 @@ const createStyles = (theme: Theme) => fontFamily: fontFamily.regular, color: theme.text.defaultTertiary, }, + messageActions: { + flexDirection: 'row', + alignItems: 'center', + marginTop: 6, + }, + sourcesButton: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + alignSelf: 'flex-start', + paddingVertical: 4, + }, + sourcesButtonPressed: { + opacity: 0.6, + }, + sourcesButtonIcon: { + color: theme.text.primary, + }, + sourcesButtonLabel: { + fontSize: fontSizes.sm, + fontFamily: fontFamily.medium, + color: theme.text.primary, + }, }); diff --git a/components/chat-screen/Messages.tsx b/components/chat-screen/Messages.tsx index eeec5f84..2172e0f1 100644 --- a/components/chat-screen/Messages.tsx +++ b/components/chat-screen/Messages.tsx @@ -303,6 +303,18 @@ const Messages = ({ [opacity, blankSpace] ); + // Citations are highlighted against the preceding user question. + const questionForAssistantAt = useMemo(() => { + const questions: (string | undefined)[] = new Array(chatHistory.length); + let lastUserContent: string | undefined; + for (let i = 0; i < chatHistory.length; i += 1) { + const message = chatHistory[i]; + if (message.role === 'user') lastUserContent = message.content; + questions[i] = message.role === 'assistant' ? lastUserContent : undefined; + } + return questions; + }, [chatHistory]); + // Identify the last user and last assistant indices so we can wrap // those specific rows in onLayout measurement Views. let lastUserIndex = -1; @@ -337,6 +349,7 @@ const Messages = ({ > {chatHistory.map((message, index) => { const isLastMessage = index === chatHistory.length - 1; + const userQuestion = questionForAssistantAt[index]; // Streaming assistant placeholder has id: -1 until persisted; fall // back to role+index for that single in-flight row. const key = @@ -361,6 +374,8 @@ const Messages = ({ isLastMessage={isLastMessage} imagePath={message.imagePath} documentName={message.documentName} + sourceDocuments={message.sourceDocuments} + userQuestion={userQuestion} /> ); diff --git a/components/chat-screen/SourcesSheet.tsx b/components/chat-screen/SourcesSheet.tsx new file mode 100644 index 00000000..dbbd8436 --- /dev/null +++ b/components/chat-screen/SourcesSheet.tsx @@ -0,0 +1,359 @@ +import React, { + forwardRef, + useCallback, + useImperativeHandle, + useMemo, + useRef, + useState, +} from 'react'; +import { + View, + StyleSheet, + Text, + Pressable, + LayoutAnimation, + Platform, + UIManager, + Dimensions, +} from 'react-native'; +import { + BottomSheetBackdrop, + BottomSheetModal, + BottomSheetScrollView, + type BottomSheetBackdropProps, + type BottomSheetScrollViewMethods, +} from '@gorhom/bottom-sheet'; +import { useTheme } from '../../context/ThemeContext'; +import { Theme } from '../../styles/colors'; +import { radius, space, textStyles } from '../../constants/design-system'; +import SourceIcon from '../../assets/icons/source.svg'; +import ChevronDownIcon from '../../assets/icons/chevron-down.svg'; +import ChevronUpIcon from '../../assets/icons/chevron-up.svg'; +import { type SourceDocument } from '../../database/chatRepository'; +import { + findCitedSpan, + buildCitationExcerpt, + queryNamesDocument, + type CitationExcerpt, +} from '../../utils/citationHighlight'; + +if ( + Platform.OS === 'android' && + UIManager.setLayoutAnimationEnabledExperimental +) { + UIManager.setLayoutAnimationEnabledExperimental(true); +} + +const SCREEN_HEIGHT = Dimensions.get('window').height; +const SOURCE_ROW_HEIGHT = space.twelve; +const SOURCE_ROW_GAP = space.one; +const SHEET_CHROME_HEIGHT = space.ten + space.eight; + +const getSourcesSnapPoints = ( + count: number, + bottomInset: number +): (string | number)[] => { + const contentHeight = + SHEET_CHROME_HEIGHT + + count * SOURCE_ROW_HEIGHT + + Math.max(0, count - 1) * SOURCE_ROW_GAP + + bottomInset + + space.eight; + const fraction = Math.min(0.9, Math.max(0.32, contentHeight / SCREEN_HEIGHT)); + const first = `${Math.round(fraction * 100)}%`; + return fraction >= 0.85 ? ['90%'] : [first, '90%']; +}; + +const SPREADSHEET_DOC_TYPES = new Set(['XLSX', 'XLS', 'XLSM', 'CSV']); + +const getDocumentType = (name: string): string => { + const lastDot = name.lastIndexOf('.'); + if (lastDot <= 0 || lastDot === name.length - 1) return ''; + return name.slice(lastDot + 1).toUpperCase(); +}; + +const renderPassage = ( + excerpt: CitationExcerpt, + styles: ReturnType +) => { + const { text, span } = excerpt; + + if ( + !span || + span.start < 0 || + span.end > text.length || + span.start >= span.end + ) { + return text; + } + + return ( + <> + {text.slice(0, span.start)} + + {text.slice(span.start, span.end)} + + {text.slice(span.end)} + + ); +}; + +export interface SourcesSheetHandle { + present: (highlightIndex?: number | null) => void; +} + +interface SourcesSheetProps { + sources: SourceDocument[]; + userQuestion?: string; +} + +const SourcesSheet = forwardRef( + ({ sources, userQuestion }, ref) => { + const { theme } = useTheme(); + const styles = useMemo(() => createStyles(theme), [theme]); + + const sheetRef = useRef(null); + const scrollRef = useRef(null); + const rowYRef = useRef>({}); + const listYRef = useRef(0); + const [highlightedIndex, setHighlightedIndex] = useState( + null + ); + const [expandedIndex, setExpandedIndex] = useState(null); + + useImperativeHandle( + ref, + () => ({ + present: (highlightIndex: number | null = null) => { + setHighlightedIndex(highlightIndex); + setExpandedIndex(highlightIndex); + sheetRef.current?.present(); + }, + }), + [] + ); + + const snapPoints = useMemo( + () => getSourcesSnapPoints(sources.length, theme.insets.bottom), + [sources.length, theme.insets.bottom] + ); + + const renderBackdrop = useCallback( + (props: BottomSheetBackdropProps) => ( + + ), + [] + ); + + const toggleExpanded = useCallback( + (index: number) => { + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); + const willExpand = expandedIndex !== index; + setExpandedIndex(willExpand ? index : null); + if (willExpand) { + setTimeout(() => { + const y = listYRef.current + (rowYRef.current[index] ?? 0); + scrollRef.current?.scrollTo({ + y: Math.max(space.none, y - space.two), + animated: true, + }); + }, 260); + } + }, + [expandedIndex] + ); + + const anyNamedSource = useMemo( + () => + sources.some((source) => + queryNamesDocument(userQuestion ?? '', source.name) + ), + [sources, userQuestion] + ); + + const expandedExcerpt = useMemo(() => { + if (expandedIndex === null) return null; + const source = sources[expandedIndex]; + const passage = source?.passage; + const suppressHighlight = + anyNamedSource && + !queryNamesDocument(userQuestion ?? '', source?.name ?? ''); + const span = suppressHighlight + ? null + : findCitedSpan(passage, userQuestion ?? ''); + return buildCitationExcerpt(passage, span); + }, [expandedIndex, sources, userQuestion, anyNamedSource]); + + return ( + { + setHighlightedIndex(null); + setExpandedIndex(null); + }} + > + + Sources + { + listYRef.current = e.nativeEvent.layout.y; + }} + > + {sources.map((source, index) => { + const docType = getDocumentType(source.name); + const isSpreadsheet = SPREADSHEET_DOC_TYPES.has(docType); + const hasPassage = !!source.passage && !isSpreadsheet; + const isExpanded = expandedIndex === index; + + return ( + toggleExpanded(index) : undefined} + onLayout={(e) => { + rowYRef.current[index] = e.nativeEvent.layout.y; + }} + disabled={!hasPassage} + accessibilityRole="button" + accessibilityState={{ expanded: isExpanded }} + testID="source-item" + > + + + + + {docType ? ( + {docType} + ) : null} + + {source.name} + + {hasPassage ? ( + isExpanded ? ( + + ) : ( + + ) + ) : null} + + {hasPassage && isExpanded && expandedExcerpt ? ( + + {renderPassage(expandedExcerpt, styles)} + + ) : null} + + ); + })} + + + + ); + } +); + +SourcesSheet.displayName = 'SourcesSheet'; + +export default SourcesSheet; + +const createStyles = (theme: Theme) => + StyleSheet.create({ + sourcesSheetBackground: { + backgroundColor: theme.bg.softPrimary, + }, + sourcesSheetHandle: { + backgroundColor: theme.border.soft, + }, + sourcesSheet: { + paddingHorizontal: space.four, + paddingTop: space.two, + paddingBottom: theme.insets.bottom + space.eight, + gap: space.three, + backgroundColor: theme.bg.softPrimary, + }, + sourcesSheetTitle: { + ...textStyles.titleH3, + color: theme.text.primary, + }, + sourcesList: { + gap: space.one, + }, + sourceRow: { + flexDirection: 'column', + gap: space.two, + paddingVertical: space.twoHalf, + paddingHorizontal: space.two, + borderRadius: radius.twelve, + }, + sourceRowHighlighted: { + backgroundColor: theme.bg.softSecondary, + }, + sourceRowHeader: { + flexDirection: 'row', + alignItems: 'center', + gap: space.two, + }, + sourceIconWrapper: { + width: space.six + space.one, + height: space.six + space.one, + borderRadius: radius.six, + backgroundColor: theme.bg.softSecondary, + justifyContent: 'center', + alignItems: 'center', + }, + sourceRowIcon: { + color: theme.text.primary, + }, + sourceRowType: { + ...textStyles.bodyQuaternaryMedium, + color: theme.text.defaultTertiary, + }, + sourceRowName: { + flex: 1, + ...textStyles.bodySecondaryMedium, + color: theme.text.primary, + }, + sourceRowChevron: { + color: theme.text.defaultTertiary, + }, + sourcePassageText: { + ...textStyles.bodyTertiaryRegular, + color: theme.text.defaultTertiary, + }, + sourcePassageCited: { + ...textStyles.bodyTertiaryMedium, + color: theme.text.primary, + }, + }); diff --git a/constants/citations.ts b/constants/citations.ts new file mode 100644 index 00000000..f0c85b6f --- /dev/null +++ b/constants/citations.ts @@ -0,0 +1,7 @@ +export const CITATION_PATTERN = /(^|[^\w$)\]])\[\d{1,3}\]/g; +export const CITATION_SENTENCE_PATTERN = /[^.!?\n]*[.!?]+|\n+|[^.!?\n]+$/g; +export const CITATION_STEM_PREFIX_LENGTH = 5; +export const CITATION_ALPHA_TERM_PATTERN = /^[a-ząćęłńóśźż]+$/; +export const CITATION_MIN_MATCH_SCORE = 2; +export const CITATION_EXCERPT_MAX_CHARS = 300; +export const CITATION_DOCUMENT_NAME_TOKEN_PATTERN = /[^a-z0-9ąćęłńóśźż]+/i; diff --git a/constants/design-system.ts b/constants/design-system.ts new file mode 100644 index 00000000..97366e48 --- /dev/null +++ b/constants/design-system.ts @@ -0,0 +1,69 @@ +import { fontFamily } from '../styles/fontStyles'; + +export const space = { + none: 0, + half: 2, + one: 4, + two: 8, + twoHalf: 10, + three: 12, + four: 16, + five: 20, + six: 24, + eight: 32, + ten: 40, + twelve: 48, + fourteen: 56, + sixteen: 64, + twenty: 80, +} as const; + +export const radius = { + six: 6, + twelve: 12, + eighteen: 18, + full: 9999, +} as const; + +export const stroke = { + soft: 1, + strong: 2, +} as const; + +export const textStyles = { + titleH1: { + fontFamily: fontFamily.bold, + fontSize: 28, + lineHeight: 34, + }, + titleH2: { + fontFamily: fontFamily.medium, + fontSize: 22, + lineHeight: 28, + }, + titleH3: { + fontFamily: fontFamily.regular, + fontSize: 18, + lineHeight: 24, + }, + bodySecondaryMedium: { + fontFamily: fontFamily.medium, + fontSize: 14, + lineHeight: 20, + }, + bodyTertiaryRegular: { + fontFamily: fontFamily.regular, + fontSize: 12, + lineHeight: 16, + }, + bodyTertiaryMedium: { + fontFamily: fontFamily.medium, + fontSize: 12, + lineHeight: 16, + }, + bodyQuaternaryMedium: { + fontFamily: fontFamily.medium, + fontSize: 10, + lineHeight: 14, + }, +} as const; diff --git a/utils/citationHighlight.ts b/utils/citationHighlight.ts new file mode 100644 index 00000000..8b127058 --- /dev/null +++ b/utils/citationHighlight.ts @@ -0,0 +1,176 @@ +import { TOKEN_PATTERN, extractQueryTerms } from './queryTerms'; +import { + CITATION_ALPHA_TERM_PATTERN, + CITATION_DOCUMENT_NAME_TOKEN_PATTERN, + CITATION_EXCERPT_MAX_CHARS, + CITATION_MIN_MATCH_SCORE, + CITATION_SENTENCE_PATTERN, + CITATION_STEM_PREFIX_LENGTH, +} from '../constants/citations'; + +export { extractQueryTerms }; + +export interface CitationSpan { + start: number; + end: number; +} + +interface Sentence { + text: string; + start: number; + end: number; +} + +const splitSentences = (passage: string): Sentence[] => { + const sentences: Sentence[] = []; + let match: RegExpExecArray | null; + + CITATION_SENTENCE_PATTERN.lastIndex = 0; + while ((match = CITATION_SENTENCE_PATTERN.exec(passage)) !== null) { + const raw = match[0]; + if (!raw.trim()) continue; + + const leading = raw.length - raw.trimStart().length; + const trailing = raw.length - raw.trimEnd().length; + const start = match.index + leading; + const end = match.index + raw.length - trailing; + if (end > start) { + sentences.push({ text: passage.slice(start, end), start, end }); + } + } + + return sentences; +}; + +type TermMatch = 'exact' | 'stem' | 'none'; + +const matchTermInSentence = ( + lower: string, + words: string[], + term: string +): TermMatch => { + if (lower.includes(term)) return 'exact'; + if ( + term.length < CITATION_STEM_PREFIX_LENGTH || + !CITATION_ALPHA_TERM_PATTERN.test(term) + ) { + return 'none'; + } + const prefix = term.slice(0, CITATION_STEM_PREFIX_LENGTH); + return words.some((word) => word.startsWith(prefix)) ? 'stem' : 'none'; +}; + +export const findCitedSpan = ( + passage: string | undefined, + query: string +): CitationSpan | null => { + if (!passage?.trim() || !query.trim()) return null; + + const terms = extractQueryTerms(query); + if (terms.size === 0) return null; + + const sentences = splitSentences(passage); + if (sentences.length === 0) return null; + + let best: CitationSpan | null = null; + let bestScore = 0; + let bestExact = 0; + let bestDensity = 0; + + for (const sentence of sentences) { + const lower = sentence.text.toLowerCase(); + const words = lower.match(TOKEN_PATTERN) ?? []; + let score = 0; + let exact = 0; + for (const term of terms) { + const match = matchTermInSentence(lower, words, term); + if (match === 'none') continue; + score += 1; + if (match === 'exact') exact += 1; + } + if (score === 0) continue; + + const density = score / sentence.text.length; + if (score > bestScore || (score === bestScore && density > bestDensity)) { + best = { start: sentence.start, end: sentence.end }; + bestScore = score; + bestExact = exact; + bestDensity = density; + } + } + + if (bestExact === 0 && bestScore < CITATION_MIN_MATCH_SCORE) return null; + + return best; +}; + +export const queryNamesDocument = (query: string, name: string): boolean => { + const base = name.replace(/\.[^.]+$/, '').toLowerCase(); + const tokens = base + .split(CITATION_DOCUMENT_NAME_TOKEN_PATTERN) + .filter((token) => token.length >= 3); + if (tokens.length === 0) return false; + + const lowerQuery = query.toLowerCase(); + return tokens.every((token) => lowerQuery.includes(token)); +}; + +export interface CitationExcerpt { + text: string; + span: CitationSpan | null; +} + +const cutAtWordBoundary = (text: string, limit: number): number => { + if (text.length <= limit) return text.length; + const window = text.slice(0, limit); + const lastSpace = window.lastIndexOf(' '); + return lastSpace > limit * 0.6 ? lastSpace : limit; +}; + +export const buildCitationExcerpt = ( + passage: string | undefined, + span: CitationSpan | null, + maxChars = CITATION_EXCERPT_MAX_CHARS +): CitationExcerpt => { + const source = passage ?? ''; + + const hasValidSpan = + span !== null && + span.start >= 0 && + span.end <= source.length && + span.start < span.end; + + if (!hasValidSpan) { + if (source.length <= maxChars) return { text: source, span: null }; + const cut = cutAtWordBoundary(source, maxChars); + return { text: `${source.slice(0, cut).trimEnd()}…`, span: null }; + } + + const { start: citeStart, end: citeEnd } = span!; + const citedLength = citeEnd - citeStart; + + if (citedLength >= maxChars) { + const text = source.slice(citeStart, citeEnd); + return { text, span: { start: 0, end: text.length } }; + } + + const remaining = maxChars - citedLength; + let start = Math.max(0, citeStart - Math.ceil(remaining / 2)); + let end = Math.min(source.length, citeEnd + (remaining - (citeStart - start))); + + if (start > 0) { + const nextSpace = source.indexOf(' ', start); + if (nextSpace !== -1 && nextSpace < citeStart) start = nextSpace + 1; + } + if (end < source.length) { + const prevSpace = source.lastIndexOf(' ', end); + if (prevSpace > citeEnd) end = prevSpace; + } + + const prefix = start > 0 ? '…' : ''; + const suffix = end < source.length ? '…' : ''; + const text = `${prefix}${source.slice(start, end)}${suffix}`; + const relStart = prefix.length + (citeStart - start); + + return { text, span: { start: relStart, end: relStart + citedLength } }; +}; diff --git a/utils/citations.ts b/utils/citations.ts new file mode 100644 index 00000000..8e16c124 --- /dev/null +++ b/utils/citations.ts @@ -0,0 +1,16 @@ +import { CITATION_PATTERN } from '../constants/citations'; + +export const stripCitations = (text: string): string => { + if (!text) return text; + + let stripped = text; + let previous: string; + do { + previous = stripped; + stripped = stripped.replace(CITATION_PATTERN, '$1'); + } while (stripped !== previous); + + return stripped + .replace(/ +([.,;:!?])/g, '$1') + .replace(/[ \t]{2,}/g, ' '); +}; From bc142b8edeb20f33a07d0f47cac8da2d504ddb61 Mon Sep 17 00:00:00 2001 From: Krzysztof Faracik Date: Tue, 7 Jul 2026 12:26:00 +0200 Subject: [PATCH 09/42] chore(deps): refresh locks and tidy retrieval test setup --- __tests__/ChatBar.test.tsx | 69 +- __tests__/MessageItem.test.tsx | 28 +- __tests__/chatStore.test.ts | 6 +- __tests__/dbMigration.test.ts | 9 +- __tests__/messageSources.test.ts | 8 +- __tests__/prepareContext.test.ts | 1 - __tests__/promptUtils.test.ts | 4 +- __tests__/queryTerms.test.ts | 4 +- components/chat-screen/MessageItem.tsx | 2 +- constants/retrieval.ts | 3 + ios/Podfile.lock | 116 +- store/embeddingModelStore.ts | 6 +- store/sourceStore.ts | 7 +- utils/citationHighlight.ts | 5 +- utils/citations.ts | 4 +- utils/messageSources.ts | 11 +- utils/promptUtils.ts | 15 +- yarn.lock | 2625 ++++++++++++------------ 18 files changed, 1515 insertions(+), 1408 deletions(-) diff --git a/__tests__/ChatBar.test.tsx b/__tests__/ChatBar.test.tsx index 1c47b267..b3e890f0 100644 --- a/__tests__/ChatBar.test.tsx +++ b/__tests__/ChatBar.test.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { render, screen, fireEvent, act } from '@testing-library/react-native'; +import type { LLMStore } from '../store/llmStore'; // ── mocks ───────────────────────────────────────────────────────────────────── @@ -13,7 +14,7 @@ jest.mock('../context/ThemeContext', () => ({ })); jest.mock('../store/llmStore', () => ({ - useLLMStore: jest.fn((selector?: (s: any) => any) => { + useLLMStore: jest.fn((selector?: (state: Partial) => unknown) => { const state = { isGenerating: false, isProcessingPrompt: false, @@ -196,16 +197,18 @@ const renderBar = (props: Partial = {}) => render(); beforeEach(() => { - mockUseLLMStore.mockImplementation((selector?: (s: any) => any) => { - const state = { - isGenerating: false, - isProcessingPrompt: false, - interrupt: jest.fn(), - loadModel: jest.fn(), - model: null, - }; - return selector ? selector(state) : state; - }); + mockUseLLMStore.mockImplementation( + (selector?: (state: Partial) => unknown) => { + const state = { + isGenerating: false, + isProcessingPrompt: false, + interrupt: jest.fn(), + loadModel: jest.fn(), + model: null, + }; + return selector ? selector(state) : state; + } + ); mockUseAttachment.attachments = []; mockUseAttachment.openSheet.mockClear(); mockUseAttachment.clearAll.mockClear(); @@ -305,32 +308,36 @@ describe('downloaded model — text input', () => { describe('generating state', () => { it('shows interrupt button when isGenerating', () => { - mockUseLLMStore.mockImplementation((selector?: (s: any) => any) => { - const state = { - isGenerating: true, - isProcessingPrompt: false, - interrupt: jest.fn(), - loadModel: jest.fn(), - model: null, - }; - return selector ? selector(state) : state; - }); + mockUseLLMStore.mockImplementation( + (selector?: (state: Partial) => unknown) => { + const state = { + isGenerating: true, + isProcessingPrompt: false, + interrupt: jest.fn(), + loadModel: jest.fn(), + model: null, + }; + return selector ? selector(state) : state; + } + ); renderBar(); expect(screen.getByTestId('interrupt-btn')).toBeTruthy(); }); it('calls interrupt when interrupt button is pressed', () => { const interrupt = jest.fn(); - mockUseLLMStore.mockImplementation((selector?: (s: any) => any) => { - const state = { - isGenerating: true, - isProcessingPrompt: false, - interrupt, - loadModel: jest.fn(), - model: null, - }; - return selector ? selector(state) : state; - }); + mockUseLLMStore.mockImplementation( + (selector?: (state: Partial) => unknown) => { + const state = { + isGenerating: true, + isProcessingPrompt: false, + interrupt, + loadModel: jest.fn(), + model: null, + }; + return selector ? selector(state) : state; + } + ); renderBar(); fireEvent.press(screen.getByTestId('interrupt-btn')); expect(interrupt).toHaveBeenCalled(); diff --git a/__tests__/MessageItem.test.tsx b/__tests__/MessageItem.test.tsx index 8fdb7f0f..b16f617a 100644 --- a/__tests__/MessageItem.test.tsx +++ b/__tests__/MessageItem.test.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import type { ViewProps } from 'react-native'; import { fireEvent, render, screen } from '@testing-library/react-native'; type MockLLMState = { @@ -8,6 +9,21 @@ type MockLLMState = { type MockLLMSelector = (state: MockLLMState) => T; +type ThinkingBlockMockProps = { + content: string; + isComplete: boolean; + inProgress: boolean; +}; + +type BottomSheetModalHandle = { + present: jest.Mock; + dismiss: jest.Mock; +}; + +type BottomSheetModalMockProps = { + children?: React.ReactNode; +}; + jest.mock('../context/ThemeContext', () => ({ useTheme: () => ({ theme: { @@ -31,7 +47,7 @@ jest.mock('../components/chat-screen/MarkdownComponent', () => { jest.mock('../components/chat-screen/ThinkingBlock', () => { const { Text } = require('react-native'); - return ({ content, isComplete, inProgress }: any) => ( + return ({ content, isComplete, inProgress }: ThinkingBlockMockProps) => ( { jest.mock('../components/chat-screen/AnimatedChatLoading', () => () => null); jest.mock('@gorhom/bottom-sheet', () => { + const MockReact = require('react') as typeof import('react'); const { View } = require('react-native'); - const BottomSheetModal = React.forwardRef(({ children }: any, ref: any) => { - React.useImperativeHandle(ref, () => ({ + const BottomSheetModal = MockReact.forwardRef< + BottomSheetModalHandle, + BottomSheetModalMockProps + >(({ children }, ref) => { + MockReact.useImperativeHandle(ref, () => ({ present: jest.fn(), dismiss: jest.fn(), })); @@ -55,7 +75,7 @@ jest.mock('@gorhom/bottom-sheet', () => { }); return { - BottomSheetBackdrop: (props: any) => , + BottomSheetBackdrop: (props: ViewProps) => , BottomSheetModal, BottomSheetView: View, BottomSheetScrollView: View, diff --git a/__tests__/chatStore.test.ts b/__tests__/chatStore.test.ts index a9065926..558035af 100644 --- a/__tests__/chatStore.test.ts +++ b/__tests__/chatStore.test.ts @@ -267,9 +267,9 @@ describe('initPhantomChat with model system prompt', () => { systemPrompt: 'global default', }); - await useChatStore - .getState() - .initPhantomChat(99, { systemPrompt: modelPrompt } as Partial as Model); + await useChatStore.getState().initPhantomChat(99, { + systemPrompt: modelPrompt, + } as Partial as Model); const phantom = useChatStore.getState().phantomChat; expect(phantom?.settings?.systemPrompt).toBe(modelPrompt); diff --git a/__tests__/dbMigration.test.ts b/__tests__/dbMigration.test.ts index 9f03254a..e4dd15be 100644 --- a/__tests__/dbMigration.test.ts +++ b/__tests__/dbMigration.test.ts @@ -12,7 +12,10 @@ const makeDb = (opts: { return null; }); const execAsync: jest.Mock = jest.fn(async (sql: string) => { - if (sql.includes('SELECT document FROM vectors') && opts.documentColumnMissing) { + if ( + sql.includes('SELECT document FROM vectors') && + opts.documentColumnMissing + ) { throw new Error('no such column: document'); } }); @@ -35,7 +38,9 @@ const deleteCalls = (runAsync: jest.Mock): string[] => describe('migrateLegacyVectorStore', () => { it('does NOT wipe sources when no legacy vectors table exists (current dual-db setup)', async () => { - const { db, execAsync, runAsync } = makeDb({ hasLegacyVectorsTable: false }); + const { db, execAsync, runAsync } = makeDb({ + hasLegacyVectorsTable: false, + }); await migrateLegacyVectorStore(db); diff --git a/__tests__/messageSources.test.ts b/__tests__/messageSources.test.ts index b843277d..c3bf9373 100644 --- a/__tests__/messageSources.test.ts +++ b/__tests__/messageSources.test.ts @@ -9,9 +9,11 @@ const doc = (documentId: number | undefined, name: string): SourceDocument => ({ describe('mergeAttachmentFirst', () => { it('leads with retrieved attachment docs, then the rest', () => { const retrieved = [doc(1, 'old.pdf'), doc(2, 'attachment.txt')]; - const result = mergeAttachmentFirst(retrieved, [doc(2, 'attachment.txt')], [ - 2, - ]); + const result = mergeAttachmentFirst( + retrieved, + [doc(2, 'attachment.txt')], + [2] + ); expect(result.map((d) => d.documentId)).toEqual([2, 1]); }); diff --git a/__tests__/prepareContext.test.ts b/__tests__/prepareContext.test.ts index 8f59f60f..b42eedde 100644 --- a/__tests__/prepareContext.test.ts +++ b/__tests__/prepareContext.test.ts @@ -97,4 +97,3 @@ describe('formatFirstChunks', () => { expect(result[0]).toContain('End of Current Attachment Source'); }); }); - diff --git a/__tests__/promptUtils.test.ts b/__tests__/promptUtils.test.ts index e9ff5075..4c267099 100644 --- a/__tests__/promptUtils.test.ts +++ b/__tests__/promptUtils.test.ts @@ -276,7 +276,9 @@ describe('prepareMessagesForLLM', () => { baseModel ); const last = result[result.length - 1]; - expect(last.content).not.toMatch(/Ignore any document mentioned earlier/i); + expect(last.content).not.toMatch( + /Ignore any document mentioned earlier/i + ); }); it('combines context and /think token', () => { diff --git a/__tests__/queryTerms.test.ts b/__tests__/queryTerms.test.ts index ee8dbc97..47556d59 100644 --- a/__tests__/queryTerms.test.ts +++ b/__tests__/queryTerms.test.ts @@ -12,7 +12,9 @@ describe('extractQueryTerms', () => { }); it('keeps longer identifiers and years', () => { - const terms = extractQueryTerms('What changed in invoice FS-219039 during 2020?'); + const terms = extractQueryTerms( + 'What changed in invoice FS-219039 during 2020?' + ); expect(terms.has('219039')).toBe(true); expect(terms.has('2020')).toBe(true); expect(terms.has('invoice')).toBe(true); diff --git a/components/chat-screen/MessageItem.tsx b/components/chat-screen/MessageItem.tsx index d22277d1..89812fbe 100644 --- a/components/chat-screen/MessageItem.tsx +++ b/components/chat-screen/MessageItem.tsx @@ -116,7 +116,7 @@ const MessageItem = memo( () => hasSources ? stripCitations(contentParts.normalAfterThink ?? '') - : contentParts.normalAfterThink ?? '', + : (contentParts.normalAfterThink ?? ''), [contentParts.normalAfterThink, hasSources] ); diff --git a/constants/retrieval.ts b/constants/retrieval.ts index b883bf4d..8ad8ff47 100644 --- a/constants/retrieval.ts +++ b/constants/retrieval.ts @@ -25,3 +25,6 @@ export const STRONG_SEMANTIC_THRESHOLD = 0.55; /** Min cosine to qualify via lexical overlap (paired with non-zero term coverage). */ export const LEXICAL_MATCH_MIN_SIMILARITY = 0.1; + +export const TEXT_SPLITTER_CHUNK_SIZE = 1000; +export const TEXT_SPLITTER_CHUNK_OVERLAP = 200; diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 69945c0a..c02a7a0e 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,9 +1,9 @@ PODS: - - EXApplication (55.0.14): + - EXApplication (55.0.16): - ExpoModulesCore - - EXConstants (55.0.15): + - EXConstants (55.0.16): - ExpoModulesCore - - Expo (55.0.20): + - Expo (55.0.27): - ExpoModulesCore - hermes-engine - RCTRequired @@ -28,40 +28,40 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - ExpoAsset (55.0.16): + - ExpoAsset (55.0.17): - ExpoModulesCore - - ExpoClipboard (55.0.13): + - ExpoClipboard (55.0.14): - ExpoModulesCore - - ExpoDevice (55.0.15): + - ExpoDevice (55.0.18): - ExpoModulesCore - - ExpoDocumentPicker (55.0.13): + - ExpoDocumentPicker (55.0.14): - ExpoModulesCore - - ExpoDomWebView (55.0.5): + - ExpoDomWebView (55.0.6): - ExpoModulesCore - - ExpoFileSystem (55.0.17): + - ExpoFileSystem (55.0.23): - ExpoModulesCore - - ExpoFont (55.0.6): + - ExpoFont (55.0.8): - ExpoModulesCore - - ExpoGlassEffect (55.0.10): + - ExpoGlassEffect (55.0.11): - ExpoModulesCore - - ExpoImage (55.0.9): + - ExpoImage (55.0.11): - ExpoModulesCore - libavif/libdav1d - SDWebImage (~> 5.21.0) - SDWebImageAVIFCoder (~> 0.11.0) - SDWebImageSVGCoder (~> 1.7.0) - SDWebImageWebPCoder (~> 0.14.6) - - ExpoKeepAwake (55.0.7): + - ExpoKeepAwake (55.0.8): - ExpoModulesCore - - ExpoLinearGradient (55.0.13): + - ExpoLinearGradient (55.0.15): - ExpoModulesCore - - ExpoLinking (55.0.14): + - ExpoLinking (55.0.16): - ExpoModulesCore - - ExpoLocalization (55.0.13): + - ExpoLocalization (55.0.16): - ExpoModulesCore - - ExpoLogBox (55.0.11): + - ExpoLogBox (55.0.12): - React-Core - - ExpoModulesCore (55.0.24): + - ExpoModulesCore (55.0.25): - ExpoModulesJSI - hermes-engine - RCTRequired @@ -86,25 +86,25 @@ PODS: - ReactNativeDependencies - RNWorklets - Yoga - - ExpoModulesJSI (55.0.24): + - ExpoModulesJSI (55.0.25): - hermes-engine - React-Core - React-runtimescheduler - ReactCommon - ExpoPasteInput (1.0.0): - ExpoModulesCore - - ExpoRouter (55.0.13): + - ExpoRouter (55.0.16): - ExpoModulesCore - RNScreens - - ExpoSharing (55.0.18): + - ExpoSharing (55.0.21): - ExpoModulesCore - - ExpoSplashScreen (55.0.19): + - ExpoSplashScreen (55.0.22): - ExpoModulesCore - - ExpoSQLite (55.0.15): + - ExpoSQLite (55.0.17): - ExpoModulesCore - - ExpoStoreReview (55.0.13): + - ExpoStoreReview (55.0.15): - ExpoModulesCore - - ExpoSymbols (55.0.7): + - ExpoSymbols (55.0.9): - ExpoModulesCore - FBLazyVector (0.83.4) - hermes-engine (0.14.1): @@ -128,7 +128,7 @@ PODS: - libwebp/sharpyuv (1.5.0) - libwebp/webp (1.5.0): - libwebp/sharpyuv - - op-sqlite (15.2.12): + - op-sqlite (15.2.14): - hermes-engine - RCTRequired - RCTTypeSafety @@ -173,8 +173,9 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - Pulsar (1.4.0): + - Pulsar (1.6.1): - hermes-engine + - Pulsar-haptics (= 1.1.4) - RCTRequired - RCTTypeSafety - React-Core @@ -195,6 +196,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga + - Pulsar-haptics (1.1.4) - RCTDeprecation (0.83.4) - RCTRequired (0.83.4) - RCTSwiftUI (0.83.4) @@ -2176,7 +2178,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - ReactNativeFs (2.38.2): + - ReactNativeFs (2.39.1): - hermes-engine - RCTRequired - RCTTypeSafety @@ -2735,6 +2737,7 @@ SPEC REPOS: - libdav1d - libwebp - opencv-rne + - Pulsar-haptics - SDWebImage - SDWebImageAVIFCoder - SDWebImageSVGCoder @@ -2978,42 +2981,43 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native/ReactCommon/yoga" SPEC CHECKSUMS: - EXApplication: ab5a9ca485adce8be81309a5172d1565e9d7467f - EXConstants: 5caf448ff399f60be9d1053e77aed32e39411900 - Expo: 1bf84a82787de952fb2fb1270214e3f15d166f05 - ExpoAsset: f56ed740b6a127da2bfd90832cf688ae309d4410 - ExpoClipboard: 5d1b0cd2686406f21e616f2d9b3431259dee2e6a - ExpoDevice: 3a4a361d685333e4139da5d56bb2740c86132beb - ExpoDocumentPicker: be59b82799ae30811e3f37a7521d6622baa63a19 - ExpoDomWebView: 2b2fbd9a07de8790569257cbf9dfdaa31cf95c70 - ExpoFileSystem: a0e4a5a09c9c4f84338289b273a172609452dec9 - ExpoFont: cdd7a1d574a376fa003c713eff49e0a4df8672c7 - ExpoGlassEffect: 72bcb9dc634262c59897ff7c53c16e2ff03990d0 - ExpoImage: bd9e9c5bc4eff7a6883339be305f8b0bb5a0cf1c - ExpoKeepAwake: 861c66deb0f9f28f87882610195f26c4b88fba6f - ExpoLinearGradient: c654e92d726a6d64c588a0988bb22bea331d5e79 - ExpoLinking: f9e01f5182f2653ea9d90f18fb47f1913e2f99ef - ExpoLocalization: c5cd7fa65c797d3a2f1adbd1fd4c601c524fd677 - ExpoLogBox: 617da77cbc083627692b2ea050a3666808aef2cd - ExpoModulesCore: 088468ce2605ce08b704105945c2232e73a7b9ee - ExpoModulesJSI: 9b60671a31c30bc8e24b25632e7cb475a84b9016 + EXApplication: a1413100c36551bef1d775fc6c64147286962bf8 + EXConstants: dadbeba983acc30f855a919658a2b34fbd86615d + Expo: b0f96de93d2e9ab7c6eabcf88597b13f6fe3f67e + ExpoAsset: 7721c5c4ae2a7c3c8147a046727ac6c020b05648 + ExpoClipboard: db4d0d7371d22999ed506dad5f0115d89fd25998 + ExpoDevice: 2d3050f79f8e0b6d6da7933d4fc5f54908731b34 + ExpoDocumentPicker: f7bb7d99ad78f5d3c526930275b5cd34446276bc + ExpoDomWebView: b2e9d601f9cb77d3c97da73234e175b64be91928 + ExpoFileSystem: 49ab894dee0818cb8b83d489fc51eafade73efb9 + ExpoFont: a7e34a75bdfb703fa07a0e87807f836236608c76 + ExpoGlassEffect: 2dedac2fe817f9b02d0dad29ce0aef7afebc5cd4 + ExpoImage: 5854bd51edd7c0a775755473598f9daeb3fde4e3 + ExpoKeepAwake: d0eb7a0719500d2a43fbf29b6f79e84d70c75ebf + ExpoLinearGradient: 07d8c721fd9ea91a7aa666753538f80dfda95bf3 + ExpoLinking: 221ab136d976b632e04ff4023660cc9c24233d07 + ExpoLocalization: 880f2337e7dd372f76ef66336a875f24d0330f80 + ExpoLogBox: a678d36477ab9544fe63e0b5328a0716539b6774 + ExpoModulesCore: 5e1ad569ce7cf8d35c0737585f34cc299fc3f0ca + ExpoModulesJSI: 964334271fc77832736f66ee1785761cdc778dbe ExpoPasteInput: e8eba5274ad513ba3e02ceb71ad6524bf2e46ce1 - ExpoRouter: e2c56f80102b153c5f8c887b38094ef936c266a2 - ExpoSharing: 5acc9d78894386d31b862b12ed1cebe7dce74e68 - ExpoSplashScreen: 43517d54a0f90d381c80deda859aa6087e80884b - ExpoSQLite: c6e966c6096f27c2ee33d608e7c40025a3cbc930 - ExpoStoreReview: dab6e25f0641784bd6207f729f893423afbb6574 - ExpoSymbols: 8b63e859ba013df1f2fc666f535fddb3d5270569 + ExpoRouter: e0e2aa4b03843e7abfa3e63e0cdd6c642d74780f + ExpoSharing: 06ec115626db3732d31ca72f580e180e75948b2c + ExpoSplashScreen: 693efd4323f4561d4c87c79050be7a0a4b9bfc51 + ExpoSQLite: 3938a01789399eddb903ea610d0d5d5f558e7909 + ExpoStoreReview: 416b2de497481b0aeb28ef95d3f93bc9ae4bca6a + ExpoSymbols: 896159288ad708e1a32dc12c92fe5f90bff01126 FBLazyVector: 061f518bbd81677ed8a8317e2ae60b8779495808 hermes-engine: b73eefca929bd717e549c62316ddf5e1785d6499 iosMath: f7a6cbadf9d836d2149c2a84c435b1effc244cba libavif: 5f8e715bea24debec477006f21ef9e95432e254d libdav1d: 23581a4d8ec811ff171ed5e2e05cd27bad64c39f libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8 - op-sqlite: 8d0462679673b145c995a240ff432e4346718689 + op-sqlite: 10397885c9f2d478a9d099d20e2a7915c5fda944 opencv-rne: 2305807573b6e29c8c87e3416ab096d09047a7a0 Pdfium: 2695c434cbefe1a651d3fee0034571dc2817efd5 - Pulsar: db1cccc0a590d3dae98f2a9a3151bfc9be8785c3 + Pulsar: 9ec19a182a03d08ecefb47877e8de9e976c2a03c + Pulsar-haptics: f795eaf55d8d0290148fd97c747dcc6689c3d079 RCTDeprecation: 5045f20b2cc1239bf422764004338c720684a22f RCTRequired: b6b9724225dd780ac2989d2e575d5513c50fe04b RCTSwiftUI: 395b65655229fa2006415207adcfcb6e35dc78ed @@ -3090,7 +3094,7 @@ SPEC CHECKSUMS: ReactCommon: c6e81cc1ae185fa84863f3ea1d58caac4be741d7 ReactNativeDependencies: f6a49cf945d48640a12c0f24cd404b89c7218273 ReactNativeEnrichedMarkdown: 27a73f8df1bc304c35b7bd922c9885a4dfec3dc5 - ReactNativeFs: 54a8079110e99a52ab4aeb72f16c6785095eb9e8 + ReactNativeFs: f2b571997aa8d6397850a6477e1c8f5823171e50 RNAudioAPI: a2a67b86dbd376f391252f0e249aec34a6e1ae92 RNCAsyncStorage: 3a4f5e2777dae1688b781a487923a08569e27fe4 RNDeviceInfo: 4c852998208b60dc192ae3529e5867817719ad1e diff --git a/store/embeddingModelStore.ts b/store/embeddingModelStore.ts index 2adc692f..b3f5cc09 100644 --- a/store/embeddingModelStore.ts +++ b/store/embeddingModelStore.ts @@ -2,11 +2,7 @@ import { create } from 'zustand'; import { OPSQLiteVectorStore } from '@react-native-rag/op-sqlite'; export type EmbeddingModelStatus = - | 'unknown' - | 'not_downloaded' - | 'downloading' - | 'ready' - | 'error'; + 'unknown' | 'not_downloaded' | 'downloading' | 'ready' | 'error'; type EmbeddingModelStore = { status: EmbeddingModelStatus; diff --git a/store/sourceStore.ts b/store/sourceStore.ts index 591e0df3..9825f970 100644 --- a/store/sourceStore.ts +++ b/store/sourceStore.ts @@ -18,6 +18,10 @@ import { addChunkToKeywordIndex, removeDocumentFromKeywordIndex, } from '../database/keywordIndex'; +import { + TEXT_SPLITTER_CHUNK_OVERLAP, + TEXT_SPLITTER_CHUNK_SIZE, +} from '../constants/retrieval'; interface SourceStore { sources: Source[]; @@ -37,9 +41,6 @@ interface SourceStore { cleanupOrphanedSources: (vectorStore: OPSQLiteVectorStore) => Promise; } -const TEXT_SPLITTER_CHUNK_SIZE = 1000; -const TEXT_SPLITTER_CHUNK_OVERLAP = 200; - export const useSourceStore = create((set, get) => ({ sources: [], db: null, diff --git a/utils/citationHighlight.ts b/utils/citationHighlight.ts index 8b127058..2ca1c3a4 100644 --- a/utils/citationHighlight.ts +++ b/utils/citationHighlight.ts @@ -156,7 +156,10 @@ export const buildCitationExcerpt = ( const remaining = maxChars - citedLength; let start = Math.max(0, citeStart - Math.ceil(remaining / 2)); - let end = Math.min(source.length, citeEnd + (remaining - (citeStart - start))); + let end = Math.min( + source.length, + citeEnd + (remaining - (citeStart - start)) + ); if (start > 0) { const nextSpace = source.indexOf(' ', start); diff --git a/utils/citations.ts b/utils/citations.ts index 8e16c124..4349dd86 100644 --- a/utils/citations.ts +++ b/utils/citations.ts @@ -10,7 +10,5 @@ export const stripCitations = (text: string): string => { stripped = stripped.replace(CITATION_PATTERN, '$1'); } while (stripped !== previous); - return stripped - .replace(/ +([.,;:!?])/g, '$1') - .replace(/[ \t]{2,}/g, ' '); + return stripped.replace(/ +([.,;:!?])/g, '$1').replace(/[ \t]{2,}/g, ' '); }; diff --git a/utils/messageSources.ts b/utils/messageSources.ts index 8b308eba..5059e11e 100644 --- a/utils/messageSources.ts +++ b/utils/messageSources.ts @@ -14,6 +14,13 @@ import { hybridRetrieve } from './hybridRetrieval'; // `sourceDocuments` (citations for the reply) and `preferredSourceDocuments` // (freshly attached sources to prioritise). State-free, so it's unit-testable. +const DEBUG_PREVIEW_LENGTH = 1200; + +const previewText = (value?: string) => + value && value.length > DEBUG_PREVIEW_LENGTH + ? `${value.slice(0, DEBUG_PREVIEW_LENGTH)}...` + : value; + export interface SourceRow { id: number; name: string; @@ -110,7 +117,9 @@ export const buildMessageSources = async ({ preferredSourceDocuments: [], }; - const allSourceIds = [...new Set([...enabledSources, ...attachmentSourceIds])]; + const allSourceIds = [ + ...new Set([...enabledSources, ...attachmentSourceIds]), + ]; if (allSourceIds.length === 0) return empty; const activeSources = sources.filter((s) => allSourceIds.includes(s.id)); diff --git a/utils/promptUtils.ts b/utils/promptUtils.ts index 0a59cada..19643805 100644 --- a/utils/promptUtils.ts +++ b/utils/promptUtils.ts @@ -96,14 +96,7 @@ export const prepareMessagesForLLM = ( ${userText} `; - const availableForLast = Math.max(0, budgetChars - systemChars); - let finalContext = safeContext; - if (wrap(finalContext).length > availableForLast) { - const overhead = wrap('').length; - const room = Math.max(0, availableForLast - overhead); - finalContext = safeContext.slice(0, room); - } - lastMessage.content = wrap(finalContext); + lastMessage.content = wrap(safeContext); } const mandatoryChars = systemChars + lastMessage.content.length; @@ -119,9 +112,5 @@ export const prepareMessagesForLLM = ( keptReversed.push(history[i]); } - return [ - messagesWithSystemPrompt[0], - ...keptReversed.reverse(), - lastMessage, - ]; + return [messagesWithSystemPrompt[0], ...keptReversed.reverse(), lastMessage]; }; diff --git a/yarn.lock b/yarn.lock index 38557189..f87afd19 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5,50 +5,59 @@ __metadata: version: 6 cacheKey: 8 -"@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.12.13, @babel/code-frame@npm:^7.20.0, @babel/code-frame@npm:^7.24.7, @babel/code-frame@npm:^7.28.6, @babel/code-frame@npm:^7.29.0": - version: 7.29.0 - resolution: "@babel/code-frame@npm:7.29.0" +"@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.12.13, @babel/code-frame@npm:^7.20.0, @babel/code-frame@npm:^7.24.7, @babel/code-frame@npm:^7.29.0, @babel/code-frame@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/code-frame@npm:7.29.7" dependencies: - "@babel/helper-validator-identifier": ^7.28.5 + "@babel/helper-validator-identifier": ^7.29.7 js-tokens: ^4.0.0 picocolors: ^1.1.1 - checksum: 39f5b303757e4d63bbff8133e251094cd4f952b46e3fa9febc7368d907583911d6a1eded6090876dc1feeff5cf6e134fb19b706f8d58d26c5402cd50e5e1aeb2 + checksum: 21b12fe2356e36f6cc3cd8a3721f878bfeea80ce38356979a0518b47b3aafdcc0bd263da75ccc9d51c64d40b1b6df00e768ce2446acb0b7cbec0ae8f905663ad languageName: node linkType: hard -"@babel/compat-data@npm:^7.28.6": - version: 7.29.3 - resolution: "@babel/compat-data@npm:7.29.3" - checksum: 977192bab334f66bc8150026340a33ed318c1a7ce18a9323f4c1b86f8ed1d8645bfe5600242bf682717c05c46a4ff06225242207c1d599f4296914b9b4e3efb5 +"@babel/code-frame@npm:~7.10.4": + version: 7.10.4 + resolution: "@babel/code-frame@npm:7.10.4" + dependencies: + "@babel/highlight": ^7.10.4 + checksum: feb4543c8a509fe30f0f6e8d7aa84f82b41148b963b826cd330e34986f649a85cb63b2f13dd4effdf434ac555d16f14940b8ea5f4433297c2f5ff85486ded019 + languageName: node + linkType: hard + +"@babel/compat-data@npm:^7.28.6, @babel/compat-data@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/compat-data@npm:7.29.7" + checksum: 4424f8fb72f61657c8e811bdf7e5c69af212a15fe362711162b2e00b82f0e0588fb8259ecd9a70c5490562bf51d408b0cb92f277ead71a2390ddddfe7283b35d languageName: node linkType: hard "@babel/core@npm:^7.11.6, @babel/core@npm:^7.12.3, @babel/core@npm:^7.13.16, @babel/core@npm:^7.20.0, @babel/core@npm:^7.21.3, @babel/core@npm:^7.23.9, @babel/core@npm:^7.24.4, @babel/core@npm:^7.25.2": - version: 7.29.0 - resolution: "@babel/core@npm:7.29.0" - dependencies: - "@babel/code-frame": ^7.29.0 - "@babel/generator": ^7.29.0 - "@babel/helper-compilation-targets": ^7.28.6 - "@babel/helper-module-transforms": ^7.28.6 - "@babel/helpers": ^7.28.6 - "@babel/parser": ^7.29.0 - "@babel/template": ^7.28.6 - "@babel/traverse": ^7.29.0 - "@babel/types": ^7.29.0 + version: 7.29.7 + resolution: "@babel/core@npm:7.29.7" + dependencies: + "@babel/code-frame": ^7.29.7 + "@babel/generator": ^7.29.7 + "@babel/helper-compilation-targets": ^7.29.7 + "@babel/helper-module-transforms": ^7.29.7 + "@babel/helpers": ^7.29.7 + "@babel/parser": ^7.29.7 + "@babel/template": ^7.29.7 + "@babel/traverse": ^7.29.7 + "@babel/types": ^7.29.7 "@jridgewell/remapping": ^2.3.5 convert-source-map: ^2.0.0 debug: ^4.1.0 gensync: ^1.0.0-beta.2 json5: ^2.2.3 semver: ^6.3.1 - checksum: 85e1df6e213382c46dee27bcd07ed9202fa108a85bb74eb37be656308fd949349171ad2aa17cc84cf0720c908dc9ea6309d25e64d2a7fcdaa63721ce0c67c10b + checksum: 95149e98ffde5b9d903459c284fbcf1f9ad8b3a833d69fdfe1aa7185821a102af1925324fe2892caf4b643b151c1dc948510d1e25a5e3c6c6c6f17f6712eb38e languageName: node linkType: hard "@babel/eslint-parser@npm:^7.25.1": - version: 7.28.6 - resolution: "@babel/eslint-parser@npm:7.28.6" + version: 7.29.7 + resolution: "@babel/eslint-parser@npm:7.29.7" dependencies: "@nicolo-ribaudo/eslint-scope-5-internals": 5.1.1-v1 eslint-visitor-keys: ^2.1.0 @@ -56,72 +65,72 @@ __metadata: peerDependencies: "@babel/core": ^7.11.0 eslint: ^7.5.0 || ^8.0.0 || ^9.0.0 - checksum: 6d789f16842c6f47a6a15f8159ef822e4bf75e8d15f85be2a813098ca4ba49703590ff2cdd56c78cc8816f5779b687cd6245ada4049c25e923e8e40132ace501 + checksum: def39180054d2bdf2ab6e119d50e2746a60531a410496e973c08f5f16daa167722d7c8be96d759837a79b3a763d3bf9b188a0c55671e08d0c1e73a81617e9aa1 languageName: node linkType: hard -"@babel/generator@npm:^7.20.5, @babel/generator@npm:^7.25.0, @babel/generator@npm:^7.29.0, @babel/generator@npm:^7.29.1, @babel/generator@npm:^7.7.2": - version: 7.29.1 - resolution: "@babel/generator@npm:7.29.1" +"@babel/generator@npm:^7.20.5, @babel/generator@npm:^7.25.0, @babel/generator@npm:^7.29.1, @babel/generator@npm:^7.29.7, @babel/generator@npm:^7.7.2": + version: 7.29.7 + resolution: "@babel/generator@npm:7.29.7" dependencies: - "@babel/parser": ^7.29.0 - "@babel/types": ^7.29.0 + "@babel/parser": ^7.29.7 + "@babel/types": ^7.29.7 "@jridgewell/gen-mapping": ^0.3.12 "@jridgewell/trace-mapping": ^0.3.28 jsesc: ^3.0.2 - checksum: d8e6863b2d04f684e65ad72731049ac7d754d3a3d1a67cdfc20807b109ba3180ed90d7ccef58ce5d38ded2eaeb71983a76c711eecb9b6266118262378f6c7226 + checksum: 6bb8f4dc0641dca19e81f5daab37ed1a1f5a78e4d702eb79a4275739f773975134e250090c182cf1eea35d9bd65e634b9d9babf66600ffdcf8ac62fa40f3b980 languageName: node linkType: hard -"@babel/helper-annotate-as-pure@npm:^7.27.1, @babel/helper-annotate-as-pure@npm:^7.27.3": - version: 7.27.3 - resolution: "@babel/helper-annotate-as-pure@npm:7.27.3" +"@babel/helper-annotate-as-pure@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-annotate-as-pure@npm:7.29.7" dependencies: - "@babel/types": ^7.27.3 - checksum: 63863a5c936ef82b546ca289c9d1b18fabfc24da5c4ee382830b124e2e79b68d626207febc8d4bffc720f50b2ee65691d7d12cc0308679dee2cd6bdc926b7190 + "@babel/types": ^7.29.7 + checksum: acd9e128de634a5144b5d622357d018fa616de45f64c74e42007c048dd15d0a0be213f4d5a2bf02307bdaddf053791b87900a99d183de828c08dc3b556329009 languageName: node linkType: hard -"@babel/helper-compilation-targets@npm:^7.27.1, @babel/helper-compilation-targets@npm:^7.28.6": - version: 7.28.6 - resolution: "@babel/helper-compilation-targets@npm:7.28.6" +"@babel/helper-compilation-targets@npm:^7.28.6, @babel/helper-compilation-targets@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-compilation-targets@npm:7.29.7" dependencies: - "@babel/compat-data": ^7.28.6 - "@babel/helper-validator-option": ^7.27.1 + "@babel/compat-data": ^7.29.7 + "@babel/helper-validator-option": ^7.29.7 browserslist: ^4.24.0 lru-cache: ^5.1.1 semver: ^6.3.1 - checksum: 8151e36b74eb1c5e414fe945c189436421f7bfa011884de5be3dd7fd77f12f1f733ff7c982581dfa0a49d8af724450243c2409427114b4a6cfeb8333259d001c + checksum: f60a943937f4eba0e671aa28551cb45569fd081c1e30a52ede167860475dc0417f3dbdf2a0fa3f086965595c7070aa76308da60cc0319860de05db4ed2a431f7 languageName: node linkType: hard -"@babel/helper-create-class-features-plugin@npm:^7.18.6, @babel/helper-create-class-features-plugin@npm:^7.28.6": - version: 7.29.3 - resolution: "@babel/helper-create-class-features-plugin@npm:7.29.3" +"@babel/helper-create-class-features-plugin@npm:^7.18.6, @babel/helper-create-class-features-plugin@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-create-class-features-plugin@npm:7.29.7" dependencies: - "@babel/helper-annotate-as-pure": ^7.27.3 - "@babel/helper-member-expression-to-functions": ^7.28.5 - "@babel/helper-optimise-call-expression": ^7.27.1 - "@babel/helper-replace-supers": ^7.28.6 - "@babel/helper-skip-transparent-expression-wrappers": ^7.27.1 - "@babel/traverse": ^7.29.0 + "@babel/helper-annotate-as-pure": ^7.29.7 + "@babel/helper-member-expression-to-functions": ^7.29.7 + "@babel/helper-optimise-call-expression": ^7.29.7 + "@babel/helper-replace-supers": ^7.29.7 + "@babel/helper-skip-transparent-expression-wrappers": ^7.29.7 + "@babel/traverse": ^7.29.7 semver: ^6.3.1 peerDependencies: "@babel/core": ^7.0.0 - checksum: ed7e755d83a59679ea9554b733e2f4b41fd1dd68ef4d8d3e5009930d8ff323f7aabdb8577abea0d98e5cc62a7ce5d14aca501e4446ccfe397c6c2fa3d8164f4b + checksum: c954e4bfe423a277cdcbad64344637cf696ddcd80085fce5f284b02a0c700af0d2d7b61468a06d9e296e948de60ece138921b65564aec023a9d9594f5d9fe18d languageName: node linkType: hard -"@babel/helper-create-regexp-features-plugin@npm:^7.27.1, @babel/helper-create-regexp-features-plugin@npm:^7.28.5": - version: 7.28.5 - resolution: "@babel/helper-create-regexp-features-plugin@npm:7.28.5" +"@babel/helper-create-regexp-features-plugin@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-create-regexp-features-plugin@npm:7.29.7" dependencies: - "@babel/helper-annotate-as-pure": ^7.27.3 + "@babel/helper-annotate-as-pure": ^7.29.7 regexpu-core: ^6.3.1 semver: ^6.3.1 peerDependencies: "@babel/core": ^7.0.0 - checksum: de202103e6ff8cd8da0d62eb269fcceb29857f3fa16173f0ff38188fd514e9ad4901aef1d590ff8ba25381644b42eaf70ad9ba91fda59fe7aa6a5e694cdde267 + checksum: 702a34db6c064a2c26675b717b3af88b8acaeb5341e2285792a873a67a16ec4cbe987ba72e055db198b2e03ead05600d8c9c0c1e7436708e95865bbfb3516026 languageName: node linkType: hard @@ -140,148 +149,160 @@ __metadata: languageName: node linkType: hard -"@babel/helper-globals@npm:^7.28.0": - version: 7.28.0 - resolution: "@babel/helper-globals@npm:7.28.0" - checksum: d8d7b91c12dad1ee747968af0cb73baf91053b2bcf78634da2c2c4991fb45ede9bd0c8f9b5f3254881242bc0921218fcb7c28ae885477c25177147e978ce4397 +"@babel/helper-globals@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-globals@npm:7.29.7" + checksum: 6deaf9846a415c7f110ac153e8c3d81e3543c9b685aa9fd9a59b4235f402e2b760129d3df49466daea9162f0cf73ff98a0ec7f180448a3449ca14ae5e1117b42 languageName: node linkType: hard -"@babel/helper-member-expression-to-functions@npm:^7.28.5": - version: 7.28.5 - resolution: "@babel/helper-member-expression-to-functions@npm:7.28.5" +"@babel/helper-member-expression-to-functions@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-member-expression-to-functions@npm:7.29.7" dependencies: - "@babel/traverse": ^7.28.5 - "@babel/types": ^7.28.5 - checksum: 447d385233bae2eea713df1785f819b5a5ca272950740da123c42d23f491045120f0fbbb5609c091f7a9bbd40f289a442846dde0cb1bf0c59440fa093690cf7c + "@babel/traverse": ^7.29.7 + "@babel/types": ^7.29.7 + checksum: 79d5f095b4bafadff3d1ec316d9f17ec85940fece957db62dd523b08e142da73c53180d1bce2fdc4d523e3889bb01b39bea70ad968b6e2f4dbdc66ebd1055b8a languageName: node linkType: hard -"@babel/helper-module-imports@npm:^7.25.9, @babel/helper-module-imports@npm:^7.28.6": - version: 7.28.6 - resolution: "@babel/helper-module-imports@npm:7.28.6" +"@babel/helper-module-imports@npm:^7.25.9, @babel/helper-module-imports@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-module-imports@npm:7.29.7" dependencies: - "@babel/traverse": ^7.28.6 - "@babel/types": ^7.28.6 - checksum: 437513aa029898b588a38f7991d7656c539b22f595207d85d0c407240c9e3f2aff8b9d0d7115fdedc91e7fdce4465100549a052024e2fba6a810bcbb7584296b + "@babel/traverse": ^7.29.7 + "@babel/types": ^7.29.7 + checksum: ad5a768fc9c162620b7f5b7645c6c2efee1e6f9df432bb79651888661a7e4cd91dac7192f3ddcfd6de4778a089e9fb30fafae768156aa73a07eeca425f64e849 languageName: node linkType: hard -"@babel/helper-module-transforms@npm:^7.28.6": - version: 7.28.6 - resolution: "@babel/helper-module-transforms@npm:7.28.6" +"@babel/helper-module-transforms@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-module-transforms@npm:7.29.7" dependencies: - "@babel/helper-module-imports": ^7.28.6 - "@babel/helper-validator-identifier": ^7.28.5 - "@babel/traverse": ^7.28.6 + "@babel/helper-module-imports": ^7.29.7 + "@babel/helper-validator-identifier": ^7.29.7 + "@babel/traverse": ^7.29.7 peerDependencies: "@babel/core": ^7.0.0 - checksum: 522f7d1d08b5e2ccd4ec912aca879bd1506af78d1fb30f46e3e6b4bb69c6ae6ab4e379a879723844230d27dc6d04a55b03f5215cd3141b7a2b40bb4a02f71a9f + checksum: 484f6d02975d304f680d44e331d4b832d67e51917483985eab7b853664452b5a627366e7a9d421200ec772a123213a591e28b40636566afeac189411ba3f45a8 languageName: node linkType: hard -"@babel/helper-optimise-call-expression@npm:^7.27.1": - version: 7.27.1 - resolution: "@babel/helper-optimise-call-expression@npm:7.27.1" +"@babel/helper-optimise-call-expression@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-optimise-call-expression@npm:7.29.7" dependencies: - "@babel/types": ^7.27.1 - checksum: 0fb7ee824a384529d6b74f8a58279f9b56bfe3cce332168067dddeab2552d8eeb56dc8eaf86c04a3a09166a316cb92dfc79c4c623cd034ad4c563952c98b464f + "@babel/types": ^7.29.7 + checksum: 6b477e01b403fd48349336cb1d94722bff4fa54af2841b5fa950c557b796f4ecc14724052252ed1362ccfc23d1c09c54dc03e182fea59d3dc5bd69f8c626ba25 languageName: node linkType: hard -"@babel/helper-plugin-utils@npm:^7.0.0, @babel/helper-plugin-utils@npm:^7.10.4, @babel/helper-plugin-utils@npm:^7.12.13, @babel/helper-plugin-utils@npm:^7.14.5, @babel/helper-plugin-utils@npm:^7.18.6, @babel/helper-plugin-utils@npm:^7.20.2, @babel/helper-plugin-utils@npm:^7.27.1, @babel/helper-plugin-utils@npm:^7.28.6, @babel/helper-plugin-utils@npm:^7.8.0": - version: 7.28.6 - resolution: "@babel/helper-plugin-utils@npm:7.28.6" - checksum: a0b4caab5e2180b215faa4d141ceac9e82fad9d446b8023eaeb8d82a6e62024726675b07fe8e616dd12f34e2bb59747e8d57aa8adab3e0717d1b8d691b118379 +"@babel/helper-plugin-utils@npm:^7.0.0, @babel/helper-plugin-utils@npm:^7.10.4, @babel/helper-plugin-utils@npm:^7.12.13, @babel/helper-plugin-utils@npm:^7.14.5, @babel/helper-plugin-utils@npm:^7.18.6, @babel/helper-plugin-utils@npm:^7.20.2, @babel/helper-plugin-utils@npm:^7.28.6, @babel/helper-plugin-utils@npm:^7.29.7, @babel/helper-plugin-utils@npm:^7.8.0": + version: 7.29.7 + resolution: "@babel/helper-plugin-utils@npm:7.29.7" + checksum: b0a183abcc6670afa4861425fa428217d8ebadce062d5b43117919e8715f820080fd63bbfcf0e43c6e0e7d21a96b21f635c46dda80bdb0ce7e8a762ebee1d8d9 languageName: node linkType: hard -"@babel/helper-remap-async-to-generator@npm:^7.27.1": - version: 7.27.1 - resolution: "@babel/helper-remap-async-to-generator@npm:7.27.1" +"@babel/helper-remap-async-to-generator@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-remap-async-to-generator@npm:7.29.7" dependencies: - "@babel/helper-annotate-as-pure": ^7.27.1 - "@babel/helper-wrap-function": ^7.27.1 - "@babel/traverse": ^7.27.1 + "@babel/helper-annotate-as-pure": ^7.29.7 + "@babel/helper-wrap-function": ^7.29.7 + "@babel/traverse": ^7.29.7 peerDependencies: "@babel/core": ^7.0.0 - checksum: 0747397ba013f87dbf575454a76c18210d61c7c9af0f697546b4bcac670b54ddc156330234407b397f0c948738c304c228e0223039bc45eab4fbf46966a5e8cc + checksum: 98338ad6e34ebb4be2dc23f8d9199d28d6d8ac6a2ce8b90fe9efdf3595b39748321528d9f2540ec0586a6e45f7c84f5f623fbf980c5efa7fa9ba7ce837ea4b20 languageName: node linkType: hard -"@babel/helper-replace-supers@npm:^7.28.6": - version: 7.28.6 - resolution: "@babel/helper-replace-supers@npm:7.28.6" +"@babel/helper-replace-supers@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-replace-supers@npm:7.29.7" dependencies: - "@babel/helper-member-expression-to-functions": ^7.28.5 - "@babel/helper-optimise-call-expression": ^7.27.1 - "@babel/traverse": ^7.28.6 + "@babel/helper-member-expression-to-functions": ^7.29.7 + "@babel/helper-optimise-call-expression": ^7.29.7 + "@babel/traverse": ^7.29.7 peerDependencies: "@babel/core": ^7.0.0 - checksum: aa6530a52010883b6be88465e3b9e789509786a40203650a23a51c315f7442b196e5925fb8e2d66d1e3dc2c604cdc817bd8c5c170dbb322ab5ebc7486fd8a022 + checksum: f7eb9a6b035d9d45250c880eb09605f95998998e828ec90759ed45764fe0abeee583419969de8b8dee551163dff914c9fc6ace90c1d819c56c23146f8df525db languageName: node linkType: hard -"@babel/helper-skip-transparent-expression-wrappers@npm:^7.20.0, @babel/helper-skip-transparent-expression-wrappers@npm:^7.27.1": - version: 7.27.1 - resolution: "@babel/helper-skip-transparent-expression-wrappers@npm:7.27.1" +"@babel/helper-skip-transparent-expression-wrappers@npm:^7.20.0, @babel/helper-skip-transparent-expression-wrappers@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-skip-transparent-expression-wrappers@npm:7.29.7" dependencies: - "@babel/traverse": ^7.27.1 - "@babel/types": ^7.27.1 - checksum: 4f380c5d0e0769fa6942a468b0c2d7c8f0c438f941aaa88f785f8752c103631d0904c7b4e76207a3b0e6588b2dec376595370d92ca8f8f1b422c14a69aa146d4 + "@babel/traverse": ^7.29.7 + "@babel/types": ^7.29.7 + checksum: a5800bfcdca6cef7f6fe33ac02a0f05ff33da9746f97806553f249733f7ba8400290a17f3831d7faa5d91656f254ab749931f53c8a29f301d958d7dd00499637 languageName: node linkType: hard -"@babel/helper-string-parser@npm:^7.27.1": - version: 7.27.1 - resolution: "@babel/helper-string-parser@npm:7.27.1" - checksum: 0a8464adc4b39b138aedcb443b09f4005d86207d7126e5e079177e05c3116107d856ec08282b365e9a79a9872f40f4092a6127f8d74c8a01c1ef789dacfc25d6 +"@babel/helper-string-parser@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-string-parser@npm:7.29.7" + checksum: 4c229d2c2296b6c94439e87ecddf3a93cee3ffd2d4cee0b4c28079275bed3de4e02cd943e4cb06036d1d9407549c4e3423006b61a46215c482fce902ee02bc0b languageName: node linkType: hard -"@babel/helper-validator-identifier@npm:^7.28.5": - version: 7.28.5 - resolution: "@babel/helper-validator-identifier@npm:7.28.5" - checksum: 5a251a6848e9712aea0338f659a1a3bd334d26219d5511164544ca8ec20774f098c3a6661e9da65a0d085c745c00bb62c8fada38a62f08fa1f8053bc0aeb57e4 +"@babel/helper-validator-identifier@npm:^7.25.9, @babel/helper-validator-identifier@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-validator-identifier@npm:7.29.7" + checksum: cc7779e96fe9c9478c96ca4bf04fc338b95b501aa5abe78178307dea282d4a5dc23d443efb12dde8e8c5636d03dd00e443532e6ed7f15fa7977349af1f87ba4d languageName: node linkType: hard -"@babel/helper-validator-option@npm:^7.27.1": - version: 7.27.1 - resolution: "@babel/helper-validator-option@npm:7.27.1" - checksum: db73e6a308092531c629ee5de7f0d04390835b21a263be2644276cb27da2384b64676cab9f22cd8d8dbd854c92b1d7d56fc8517cf0070c35d1c14a8c828b0903 +"@babel/helper-validator-option@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-validator-option@npm:7.29.7" + checksum: aeb6aa966f59300d3cc2fea7c68e1dfd7ad011fc10e535c8e2b2de3094b27c859428dc7220f16420350f8b1cde99da120b673be04bcb0c2f37b56258c96bed58 languageName: node linkType: hard -"@babel/helper-wrap-function@npm:^7.27.1": - version: 7.28.6 - resolution: "@babel/helper-wrap-function@npm:7.28.6" +"@babel/helper-wrap-function@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-wrap-function@npm:7.29.7" dependencies: - "@babel/template": ^7.28.6 - "@babel/traverse": ^7.28.6 - "@babel/types": ^7.28.6 - checksum: 1281f45d55ff291711de7cf05b8132fc28b8d2b30c6c9cf8fce68669bbe318503ed485057d434efa1a4f91ab55d62bf8f3ecb0a889a9f81d357ad4614cd0fa6c + "@babel/template": ^7.29.7 + "@babel/traverse": ^7.29.7 + "@babel/types": ^7.29.7 + checksum: 2e6dfca94a10a3672a6b0ff337c80f27d7c66f3ea110969e833529a2d5bfbb5e53ab40abf6fad4657b8f810fcab2e3d56998f8da89bfe82a58267c5e8bc06e9a languageName: node linkType: hard -"@babel/helpers@npm:^7.28.6": - version: 7.29.2 - resolution: "@babel/helpers@npm:7.29.2" +"@babel/helpers@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helpers@npm:7.29.7" dependencies: - "@babel/template": ^7.28.6 - "@babel/types": ^7.29.0 - checksum: 2c8ce711a639ef334539d3bd48977f57493f71af99e13d3f685fe47b3bc32aa83dbc1380688e19d5df924d958f8f29072f3dcff8110257ba6399524907287189 + "@babel/template": ^7.29.7 + "@babel/types": ^7.29.7 + checksum: b5c4ed0ce5983c5599cd01b3948444b77ba2fa47bf6282a82afdcbe45eb8cc5b7986194e3920ef2760f533c6a775504e6b00a66960c0a3bc52909920b8433908 languageName: node linkType: hard -"@babel/parser@npm:^7.1.0, @babel/parser@npm:^7.13.16, @babel/parser@npm:^7.14.7, @babel/parser@npm:^7.20.7, @babel/parser@npm:^7.23.9, @babel/parser@npm:^7.24.4, @babel/parser@npm:^7.25.3, @babel/parser@npm:^7.28.6, @babel/parser@npm:^7.29.0": - version: 7.29.3 - resolution: "@babel/parser@npm:7.29.3" +"@babel/highlight@npm:^7.10.4": + version: 7.25.9 + resolution: "@babel/highlight@npm:7.25.9" dependencies: - "@babel/types": ^7.29.0 + "@babel/helper-validator-identifier": ^7.25.9 + chalk: ^2.4.2 + js-tokens: ^4.0.0 + picocolors: ^1.0.0 + checksum: a6e0ac0a1c4bef7401915ca3442ab2b7ae4adf360262ca96b91396bfb9578abb28c316abf5e34460b780696db833b550238d9256bdaca60fade4ba7a67645064 + languageName: node + linkType: hard + +"@babel/parser@npm:^7.1.0, @babel/parser@npm:^7.13.16, @babel/parser@npm:^7.14.7, @babel/parser@npm:^7.20.7, @babel/parser@npm:^7.23.9, @babel/parser@npm:^7.24.4, @babel/parser@npm:^7.25.3, @babel/parser@npm:^7.29.0, @babel/parser@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/parser@npm:7.29.7" + dependencies: + "@babel/types": ^7.29.7 bin: parser: ./bin/babel-parser.js - checksum: 046f46996bf4053b6e29f8a7f420f9e0a2878593c1c9a9914a36faca23fc544a307c78a0101ba3ae98936ade68bdde686a83e1ab2b74c2ebb80dc4a9df48476d + checksum: 56f4c32a371004d3becd0a960a85a3bba4ec4df73d5e202aa3fe6473328b243162c6be9a510be1c12ed1825f5ce9ff1ea5cc357298631e8acf2e5b8da9f5a961 languageName: node linkType: hard @@ -298,26 +319,26 @@ __metadata: linkType: hard "@babel/plugin-proposal-decorators@npm:^7.12.9": - version: 7.29.0 - resolution: "@babel/plugin-proposal-decorators@npm:7.29.0" + version: 7.29.7 + resolution: "@babel/plugin-proposal-decorators@npm:7.29.7" dependencies: - "@babel/helper-create-class-features-plugin": ^7.28.6 - "@babel/helper-plugin-utils": ^7.28.6 - "@babel/plugin-syntax-decorators": ^7.28.6 + "@babel/helper-create-class-features-plugin": ^7.29.7 + "@babel/helper-plugin-utils": ^7.29.7 + "@babel/plugin-syntax-decorators": ^7.29.7 peerDependencies: "@babel/core": ^7.0.0-0 - checksum: e2aa792078a50e49b1fb2c0759863d4dc688aff19441b7f624361bde3f220b7dee7ab0ea73928e0e67ca21c89557c847ffe9f6e2c4bdcc9fbf066c6b5b372c54 + checksum: 4a92b511afc80a6708c42de6e2875bd5b93f18413c6c2ee2e97af61c7a54ea3b04837ec8fcca0d92de2178d6dd0a589690bab9ff38e0053261170d38e332bd5e languageName: node linkType: hard "@babel/plugin-proposal-export-default-from@npm:^7.24.7": - version: 7.27.1 - resolution: "@babel/plugin-proposal-export-default-from@npm:7.27.1" + version: 7.29.7 + resolution: "@babel/plugin-proposal-export-default-from@npm:7.29.7" dependencies: - "@babel/helper-plugin-utils": ^7.27.1 + "@babel/helper-plugin-utils": ^7.29.7 peerDependencies: "@babel/core": ^7.0.0-0 - checksum: cf9eb3c80bcee3ee82d28f1053db97fa6c6e4dea819f73df5a3cb9155d45efc29914e86353572eab36adfe691ca1573e6e2cddae4edbdd475253044575eb7a24 + checksum: 373ba179cb2a1ece4facd4a984aa43808ec046d3ad75404a0b2fdd92cd6c3ab41548856c055e63b1cc4a895ae23380298e36a219c454eb4c3ac9da1475a6cbd0 languageName: node linkType: hard @@ -390,14 +411,14 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-syntax-decorators@npm:^7.28.6": - version: 7.28.6 - resolution: "@babel/plugin-syntax-decorators@npm:7.28.6" +"@babel/plugin-syntax-decorators@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/plugin-syntax-decorators@npm:7.29.7" dependencies: - "@babel/helper-plugin-utils": ^7.28.6 + "@babel/helper-plugin-utils": ^7.29.7 peerDependencies: "@babel/core": ^7.0.0-0 - checksum: f59a229e80398663c99519ab0785df389a802aedd6e0cfb6d37fa99a5802c22b270ff6273db7cf94cd8fcb9024feba11dcdbb0e907c5624e02c7fe5da056a91f + checksum: 3a49c261fb62a00f72626937eed23a0d293d10225362163d61909c83c91cfa759a8d498b6c7a8847592437a7d14ddc28407d7b8781ec9addac6a70980d41bb22 languageName: node linkType: hard @@ -413,35 +434,35 @@ __metadata: linkType: hard "@babel/plugin-syntax-export-default-from@npm:^7.24.7": - version: 7.28.6 - resolution: "@babel/plugin-syntax-export-default-from@npm:7.28.6" + version: 7.29.7 + resolution: "@babel/plugin-syntax-export-default-from@npm:7.29.7" dependencies: - "@babel/helper-plugin-utils": ^7.28.6 + "@babel/helper-plugin-utils": ^7.29.7 peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 06330b90a4baf9edafe8a4e2e6520d548f83e178c1e832c1ad5018532052996331aedc8c3b4e6b0e51acaef75abe76e25ad3465d3d914658d65acec6908f202a + checksum: 33a0e0a629e58036038e51a48cf18e81a78e60c16827f82667dc6500df01bc52ee52e8f2b107200d3811526be0d1a10e5ccd53a803e6e9bdec79eedd0aa8d760 languageName: node linkType: hard -"@babel/plugin-syntax-flow@npm:^7.12.1, @babel/plugin-syntax-flow@npm:^7.27.1": - version: 7.28.6 - resolution: "@babel/plugin-syntax-flow@npm:7.28.6" +"@babel/plugin-syntax-flow@npm:^7.12.1, @babel/plugin-syntax-flow@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/plugin-syntax-flow@npm:7.29.7" dependencies: - "@babel/helper-plugin-utils": ^7.28.6 + "@babel/helper-plugin-utils": ^7.29.7 peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 3dfe5d8168e400376e16937c92648142771b9ba0d9937b04ccdaacd06bf9d854170021b466106d4aa39ba6062b8b5b9b53efddae2c64ca133d4d6fafaa472909 + checksum: 3e03792bb20d4a0f2610df5d5af6c2ec8cbb5096a7576b24027eca60ac2b9e3a183d48255e4156fa94768322d82b13e77623f785ef556660de1c0efc5708e52a languageName: node linkType: hard "@babel/plugin-syntax-import-attributes@npm:^7.24.7": - version: 7.28.6 - resolution: "@babel/plugin-syntax-import-attributes@npm:7.28.6" + version: 7.29.7 + resolution: "@babel/plugin-syntax-import-attributes@npm:7.29.7" dependencies: - "@babel/helper-plugin-utils": ^7.28.6 + "@babel/helper-plugin-utils": ^7.29.7 peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 6c8c6a5988dbb9799d6027360d1a5ba64faabf551f2ef11ba4eade0c62253b5c85d44ddc8eb643c74b9acb2bcaa664a950bd5de9a5d4aef291c4f2a48223bb4b + checksum: 9f47345d09aae16b7ab52ecaf541cde3e3ae1e57e3eb2d4088e062b29dfbd67db55d42d529840557583d66121e2a98788df7a455401cc6d635c8b7700a02efc9 languageName: node linkType: hard @@ -467,14 +488,14 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-syntax-jsx@npm:^7.27.1, @babel/plugin-syntax-jsx@npm:^7.28.6, @babel/plugin-syntax-jsx@npm:^7.7.2": - version: 7.28.6 - resolution: "@babel/plugin-syntax-jsx@npm:7.28.6" +"@babel/plugin-syntax-jsx@npm:^7.29.7, @babel/plugin-syntax-jsx@npm:^7.7.2": + version: 7.29.7 + resolution: "@babel/plugin-syntax-jsx@npm:7.29.7" dependencies: - "@babel/helper-plugin-utils": ^7.28.6 + "@babel/helper-plugin-utils": ^7.29.7 peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 572e38f5c1bb4b8124300e7e3dd13e82ae84a21f90d3f0786c98cd05e63c78ca1f32d1cfe462dfbaf5e7d5102fa7cd8fd741dfe4f3afc2e01a3b2877dcc8c866 + checksum: 84150d27c553a1d3d921354437f6725ca1d63b49514c25591bfcaaafa6ea4d6c10715b66fe7245e4ad7ab7c6cf4b6e1de7373defd3df00877ab12638170d7772 languageName: node linkType: hard @@ -566,536 +587,536 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-syntax-typescript@npm:^7.28.6, @babel/plugin-syntax-typescript@npm:^7.7.2": - version: 7.28.6 - resolution: "@babel/plugin-syntax-typescript@npm:7.28.6" +"@babel/plugin-syntax-typescript@npm:^7.29.7, @babel/plugin-syntax-typescript@npm:^7.7.2": + version: 7.29.7 + resolution: "@babel/plugin-syntax-typescript@npm:7.29.7" dependencies: - "@babel/helper-plugin-utils": ^7.28.6 + "@babel/helper-plugin-utils": ^7.29.7 peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 5c55f9c63bd36cf3d7e8db892294c8f85000f9c1526c3a1cc310d47d1e174f5c6f6605e5cc902c4636d885faba7a9f3d5e5edc6b35e4f3b1fd4c2d58d0304fa5 + checksum: ef454d2a7a6209dd4255361c072c94ab1293e7ad4b06e7e744d08bb308065d4d6544964eae9b2357c3b33d8d939f9e32d4aa95905bc464407cd8f7101dee4443 languageName: node linkType: hard "@babel/plugin-transform-arrow-functions@npm:^7.24.7, @babel/plugin-transform-arrow-functions@npm:^7.27.1": - version: 7.27.1 - resolution: "@babel/plugin-transform-arrow-functions@npm:7.27.1" + version: 7.29.7 + resolution: "@babel/plugin-transform-arrow-functions@npm:7.29.7" dependencies: - "@babel/helper-plugin-utils": ^7.27.1 + "@babel/helper-plugin-utils": ^7.29.7 peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 62c2cc0ae2093336b1aa1376741c5ed245c0987d9e4b4c5313da4a38155509a7098b5acce582b6781cc0699381420010da2e3086353344abe0a6a0ec38961eb7 + checksum: 0037fd7563c7c91cddb8ce104e270bc260190d29c7a297df65e4306471010b4343366de13fab4602cf8ee6c672d3b313b34a01b62f27a333cad16908c83368d8 languageName: node linkType: hard "@babel/plugin-transform-async-generator-functions@npm:^7.25.4": - version: 7.29.0 - resolution: "@babel/plugin-transform-async-generator-functions@npm:7.29.0" + version: 7.29.7 + resolution: "@babel/plugin-transform-async-generator-functions@npm:7.29.7" dependencies: - "@babel/helper-plugin-utils": ^7.28.6 - "@babel/helper-remap-async-to-generator": ^7.27.1 - "@babel/traverse": ^7.29.0 + "@babel/helper-plugin-utils": ^7.29.7 + "@babel/helper-remap-async-to-generator": ^7.29.7 + "@babel/traverse": ^7.29.7 peerDependencies: "@babel/core": ^7.0.0-0 - checksum: bd549b54283034dd3e2f6c4b41b99a0caba0ddc8e9418490a611136ddb01e62235f14b233fcc172902fd1d18eec6e029245d22212566ea5cb5e24c7450d6005d + checksum: d8239f43b4051b12cd7a5581367bd2b13ac26235da39d79f182999879f010124341ac500f480140b0961a62ec451a0a963b421c509f9c1a306c313f10fc60451 languageName: node linkType: hard "@babel/plugin-transform-async-to-generator@npm:^7.24.7": - version: 7.28.6 - resolution: "@babel/plugin-transform-async-to-generator@npm:7.28.6" + version: 7.29.7 + resolution: "@babel/plugin-transform-async-to-generator@npm:7.29.7" dependencies: - "@babel/helper-module-imports": ^7.28.6 - "@babel/helper-plugin-utils": ^7.28.6 - "@babel/helper-remap-async-to-generator": ^7.27.1 + "@babel/helper-module-imports": ^7.29.7 + "@babel/helper-plugin-utils": ^7.29.7 + "@babel/helper-remap-async-to-generator": ^7.29.7 peerDependencies: "@babel/core": ^7.0.0-0 - checksum: bca5774263ec01dd2bf71c74bbaf7baa183bf03576636b7826c3346be70c8c8cb15cff549112f2983c36885131a0afde6c443591278c281f733ee17f455aa9b1 + checksum: e24db9c4c69121daab25883f9a96e6849fa664c78c6bbcfb77fe0a0c6ab29b81ba39e8497985367463d3a88deac3b5bbe15dd1c5d0e5dd492cfccf8efdd27452 languageName: node linkType: hard "@babel/plugin-transform-block-scoping@npm:^7.25.0": - version: 7.28.6 - resolution: "@babel/plugin-transform-block-scoping@npm:7.28.6" + version: 7.29.7 + resolution: "@babel/plugin-transform-block-scoping@npm:7.29.7" dependencies: - "@babel/helper-plugin-utils": ^7.28.6 + "@babel/helper-plugin-utils": ^7.29.7 peerDependencies: "@babel/core": ^7.0.0-0 - checksum: cb4f71ac4fc7b32c2e3cc167eb9e7a1a11562127d702e3b5093567750e9a4eb11a29ae5a917f62741bf9d5792bfe3022cbcdcc7bb927ddb6f627b6749a38c118 + checksum: a17c02f8dfcaf2c26c0c323aaa8e3a1616f2de3ea0a4947e16789bb64cb0f177deff388eb1390c100c82d7e6fecd4d583646bb9305fb3df3ca824b1bdbebdc8e languageName: node linkType: hard "@babel/plugin-transform-class-properties@npm:^7.25.4, @babel/plugin-transform-class-properties@npm:^7.27.1": - version: 7.28.6 - resolution: "@babel/plugin-transform-class-properties@npm:7.28.6" + version: 7.29.7 + resolution: "@babel/plugin-transform-class-properties@npm:7.29.7" dependencies: - "@babel/helper-create-class-features-plugin": ^7.28.6 - "@babel/helper-plugin-utils": ^7.28.6 + "@babel/helper-create-class-features-plugin": ^7.29.7 + "@babel/helper-plugin-utils": ^7.29.7 peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 200f30d44b36a768fa3a8cf690db9e333996af2ad14d9fa1b4c91a427ed9302907873b219b4ce87517ca1014a810eb2e929a6a66be68473f72b546fc64d04fbc + checksum: 0cc5e7a882e29eead360f02ef79f6b2ec3b3813213b1513d8fdaa931d1d1361fccc92fbacc9b399e42495953d9d6fc722f283b5f3aa272fe016a0b5fe1e6a130 languageName: node linkType: hard "@babel/plugin-transform-class-static-block@npm:^7.27.1": - version: 7.28.6 - resolution: "@babel/plugin-transform-class-static-block@npm:7.28.6" + version: 7.29.7 + resolution: "@babel/plugin-transform-class-static-block@npm:7.29.7" dependencies: - "@babel/helper-create-class-features-plugin": ^7.28.6 - "@babel/helper-plugin-utils": ^7.28.6 + "@babel/helper-create-class-features-plugin": ^7.29.7 + "@babel/helper-plugin-utils": ^7.29.7 peerDependencies: "@babel/core": ^7.12.0 - checksum: 3db326156f73a0c0d1e2ea4d73e082b9ace2f6a9c965db1c2e51f3a186751b8b91bafb184d05e046bf970b50ecfde1f74862dd895f9a5ea0fad328369d74cfc4 + checksum: fe94eb94d8417de753a26f06a3c50780cdfc3f07e10bcd09dde2fe61dcd9a2714b83b1b2d36733328a3ad09b02e84fa06197f757644ffbcca1673e87c64ecb76 languageName: node linkType: hard "@babel/plugin-transform-classes@npm:^7.25.4, @babel/plugin-transform-classes@npm:^7.28.4": - version: 7.28.6 - resolution: "@babel/plugin-transform-classes@npm:7.28.6" + version: 7.29.7 + resolution: "@babel/plugin-transform-classes@npm:7.29.7" dependencies: - "@babel/helper-annotate-as-pure": ^7.27.3 - "@babel/helper-compilation-targets": ^7.28.6 - "@babel/helper-globals": ^7.28.0 - "@babel/helper-plugin-utils": ^7.28.6 - "@babel/helper-replace-supers": ^7.28.6 - "@babel/traverse": ^7.28.6 + "@babel/helper-annotate-as-pure": ^7.29.7 + "@babel/helper-compilation-targets": ^7.29.7 + "@babel/helper-globals": ^7.29.7 + "@babel/helper-plugin-utils": ^7.29.7 + "@babel/helper-replace-supers": ^7.29.7 + "@babel/traverse": ^7.29.7 peerDependencies: "@babel/core": ^7.0.0-0 - checksum: bddeefbfd1966272e5da6a0844d68369a0f43c286816c8b379dfd576cf835b8bc652089ef337b0334ff3ae6c9652d56d8332b78a7d29176534265c39856e4822 + checksum: cc45ff5b5b063339131cd701266af90506db8640e56494de0c78f882da6317f057951bf6507c5b48a0278cf5dad21691baf2af05e24b8c26b0725d677088edbf languageName: node linkType: hard "@babel/plugin-transform-computed-properties@npm:^7.24.7": - version: 7.28.6 - resolution: "@babel/plugin-transform-computed-properties@npm:7.28.6" + version: 7.29.7 + resolution: "@babel/plugin-transform-computed-properties@npm:7.29.7" dependencies: - "@babel/helper-plugin-utils": ^7.28.6 - "@babel/template": ^7.28.6 + "@babel/helper-plugin-utils": ^7.29.7 + "@babel/template": ^7.29.7 peerDependencies: "@babel/core": ^7.0.0-0 - checksum: fd1fcc55003a2584c7461bf214ae9e9fce370ad09339319e99e29e5e55a8a3bd485d10805b3d69636a738208761b3a5b0dafdd023534396be45a36409082b014 + checksum: ad945a48e6826c3b34f825e780ab33bf91e18c7924dd263e44bef8d8a6350636305b22af8f00793d3767482004651f4bfb9fed0c92f05c01b99ae80d79956e67 languageName: node linkType: hard -"@babel/plugin-transform-destructuring@npm:^7.24.8, @babel/plugin-transform-destructuring@npm:^7.28.5": - version: 7.28.5 - resolution: "@babel/plugin-transform-destructuring@npm:7.28.5" +"@babel/plugin-transform-destructuring@npm:^7.24.8, @babel/plugin-transform-destructuring@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/plugin-transform-destructuring@npm:7.29.7" dependencies: - "@babel/helper-plugin-utils": ^7.27.1 - "@babel/traverse": ^7.28.5 + "@babel/helper-plugin-utils": ^7.29.7 + "@babel/traverse": ^7.29.7 peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 74a06e55e715cfda0fdd8be53d2655d64dfdc28dffaede329d42548fd5b1449ad26a4ce43a24c3fd277b96f8b2010c7b3915afa8297911cda740cc5cc3a81f38 + checksum: 5f56c030beec2ae1640909eb5213fc655f0062046b28699d049cc8b00fb7d9aa1c5ba1dc1c7ee41c8bae97be778066f1f9ea2ecd8fff872a1e8f10fd3addf18c languageName: node linkType: hard "@babel/plugin-transform-export-namespace-from@npm:^7.25.9": - version: 7.27.1 - resolution: "@babel/plugin-transform-export-namespace-from@npm:7.27.1" + version: 7.29.7 + resolution: "@babel/plugin-transform-export-namespace-from@npm:7.29.7" dependencies: - "@babel/helper-plugin-utils": ^7.27.1 + "@babel/helper-plugin-utils": ^7.29.7 peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 85082923eca317094f08f4953d8ea2a6558b3117826c0b740676983902b7236df1f4213ad844cb38c2dae104753dbe8f1cc51f01567835d476d32f5f544a4385 + checksum: d157d62b144d1626b801e557dcede914db33e78f3f4230f487e5709c21efd40648f7f3a47a44a7fce694ad9cd117d2ac3ed796da0102275fd02b4fdb317d906b languageName: node linkType: hard -"@babel/plugin-transform-flow-strip-types@npm:^7.25.2, @babel/plugin-transform-flow-strip-types@npm:^7.27.1": - version: 7.27.1 - resolution: "@babel/plugin-transform-flow-strip-types@npm:7.27.1" +"@babel/plugin-transform-flow-strip-types@npm:^7.25.2, @babel/plugin-transform-flow-strip-types@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/plugin-transform-flow-strip-types@npm:7.29.7" dependencies: - "@babel/helper-plugin-utils": ^7.27.1 - "@babel/plugin-syntax-flow": ^7.27.1 + "@babel/helper-plugin-utils": ^7.29.7 + "@babel/plugin-syntax-flow": ^7.29.7 peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 0885028866fadefef35292d5a27f878d6a12b6f83778f8731481d4503b49c258507882a7de2aafda9b62d5f6350042f1a06355b998d5ed5e85d693bfcb77b939 + checksum: bc6e94f5398bb512b1a1d7a2bfb36dff9dd7d5150a3644e2ff4f361512b6be3c6a164828263fedbbc9eaa0015617b6c4e0e347117a0ae02acc184215cc27a6c2 languageName: node linkType: hard "@babel/plugin-transform-for-of@npm:^7.24.7": - version: 7.27.1 - resolution: "@babel/plugin-transform-for-of@npm:7.27.1" + version: 7.29.7 + resolution: "@babel/plugin-transform-for-of@npm:7.29.7" dependencies: - "@babel/helper-plugin-utils": ^7.27.1 - "@babel/helper-skip-transparent-expression-wrappers": ^7.27.1 + "@babel/helper-plugin-utils": ^7.29.7 + "@babel/helper-skip-transparent-expression-wrappers": ^7.29.7 peerDependencies: "@babel/core": ^7.0.0-0 - checksum: c9224e08de5d80b2c834383d4359aa9e519db434291711434dd996a4f86b7b664ad67b45d65459b7ec11fa582e3e11a3c769b8a8ca71594bdd4e2f0503f84126 + checksum: 7641d5eb4ef268df2f7d8736691a565646f42c55fdc2c86a65ed027276415a19ac6cafab2fc386621894c5d3202baacc9f222424bda37fa2ab9a20d7fec921f9 languageName: node linkType: hard "@babel/plugin-transform-function-name@npm:^7.25.1": - version: 7.27.1 - resolution: "@babel/plugin-transform-function-name@npm:7.27.1" + version: 7.29.7 + resolution: "@babel/plugin-transform-function-name@npm:7.29.7" dependencies: - "@babel/helper-compilation-targets": ^7.27.1 - "@babel/helper-plugin-utils": ^7.27.1 - "@babel/traverse": ^7.27.1 + "@babel/helper-compilation-targets": ^7.29.7 + "@babel/helper-plugin-utils": ^7.29.7 + "@babel/traverse": ^7.29.7 peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 26a2a183c3c52a96495967420a64afc5a09f743a230272a131668abf23001e393afa6371e6f8e6c60f4182bea210ed31d1caf866452d91009c1daac345a52f23 + checksum: 87a6329442ef8085fbc160e659f64562f0f5b63be65403b8bbb8e18c67dd7d03b82fb2e1371fdde734e2f05b13a72f88ed8d8cb03807150063954b9604d082cf languageName: node linkType: hard "@babel/plugin-transform-literals@npm:^7.25.2": - version: 7.27.1 - resolution: "@babel/plugin-transform-literals@npm:7.27.1" + version: 7.29.7 + resolution: "@babel/plugin-transform-literals@npm:7.29.7" dependencies: - "@babel/helper-plugin-utils": ^7.27.1 + "@babel/helper-plugin-utils": ^7.29.7 peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 0a76d12ab19f32dd139964aea7da48cecdb7de0b75e207e576f0f700121fe92367d788f328bf4fb44b8261a0f605c97b44e62ae61cddbb67b14e94c88b411f95 + checksum: aadb2b3fe85186c274a07d5486aeef9496ce374e534fbc7b54f77985c75513422d9acec4c532f67b027e939644d93a69c00505b8909e259184c3ee5c5c62c46b languageName: node linkType: hard "@babel/plugin-transform-logical-assignment-operators@npm:^7.24.7": - version: 7.28.6 - resolution: "@babel/plugin-transform-logical-assignment-operators@npm:7.28.6" + version: 7.29.7 + resolution: "@babel/plugin-transform-logical-assignment-operators@npm:7.29.7" dependencies: - "@babel/helper-plugin-utils": ^7.28.6 + "@babel/helper-plugin-utils": ^7.29.7 peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 36095d5d1cfc680e95298b5389a16016da800ae3379b130dabf557e94652c47b06610407e9fa44aaa03e9b0a5aa7b4b93348123985d44a45e369bf5f3497d149 + checksum: 374d83dfbb2de5e339d2966487c0f0d766cd67422addbd0172104ff2788b60317858172a7ab2cb6ee7d5bffad72ce07120fec009a2ef3b35551013fce4908686 languageName: node linkType: hard -"@babel/plugin-transform-modules-commonjs@npm:^7.13.8, @babel/plugin-transform-modules-commonjs@npm:^7.24.8, @babel/plugin-transform-modules-commonjs@npm:^7.27.1": - version: 7.28.6 - resolution: "@babel/plugin-transform-modules-commonjs@npm:7.28.6" +"@babel/plugin-transform-modules-commonjs@npm:^7.13.8, @babel/plugin-transform-modules-commonjs@npm:^7.24.8, @babel/plugin-transform-modules-commonjs@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/plugin-transform-modules-commonjs@npm:7.29.7" dependencies: - "@babel/helper-module-transforms": ^7.28.6 - "@babel/helper-plugin-utils": ^7.28.6 + "@babel/helper-module-transforms": ^7.29.7 + "@babel/helper-plugin-utils": ^7.29.7 peerDependencies: "@babel/core": ^7.0.0-0 - checksum: b48cab26fda72894c7002a9c783befbc8a643d827c52bdcc5adf83e418ca93224a15aaf7ed2d1e6284627be55913696cfa2119242686cfa77a473bf79314df26 + checksum: e9d8102deca4c2f06507a8d071ca0e161f01cec0e108151142edb39995bd830e312d55d21fac0ceb390ac79a1fee5fb768b719413b8fc5adda5f06ecd8845cd6 languageName: node linkType: hard "@babel/plugin-transform-named-capturing-groups-regex@npm:^7.24.7": - version: 7.29.0 - resolution: "@babel/plugin-transform-named-capturing-groups-regex@npm:7.29.0" + version: 7.29.7 + resolution: "@babel/plugin-transform-named-capturing-groups-regex@npm:7.29.7" dependencies: - "@babel/helper-create-regexp-features-plugin": ^7.28.5 - "@babel/helper-plugin-utils": ^7.28.6 + "@babel/helper-create-regexp-features-plugin": ^7.29.7 + "@babel/helper-plugin-utils": ^7.29.7 peerDependencies: "@babel/core": ^7.0.0 - checksum: ed8c27699ca82a6c01cbfd39f3de16b90cfea4f8146a358057f76df290d308a66a8bd2e6734e6a87f68c18576e15d2d70548a84cd474d26fdf256c3f5ae44d8c + checksum: 3a481be133e9ca5d25570b5ed62daae323a51663bacf30fed0d1980e912047ebd34d6326533182db625fc0bbd086d1a23ed68ebb6108e7a68a8650c228afc084 languageName: node linkType: hard "@babel/plugin-transform-nullish-coalescing-operator@npm:^7.24.7, @babel/plugin-transform-nullish-coalescing-operator@npm:^7.27.1": - version: 7.28.6 - resolution: "@babel/plugin-transform-nullish-coalescing-operator@npm:7.28.6" + version: 7.29.7 + resolution: "@babel/plugin-transform-nullish-coalescing-operator@npm:7.29.7" dependencies: - "@babel/helper-plugin-utils": ^7.28.6 + "@babel/helper-plugin-utils": ^7.29.7 peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 1cdd3ca48a8fffa13dbb9949748d3dd2183cf24110cd55d702da4549205611fc12978b49886be809ec1929ff6304ac4eecc747a33dca2484f9dc655928ab5a89 + checksum: f486e737ddcec3a88e2e0dc004c28e15156dd255f3b2d944903f3d75666257c96ee7ebf57d80d2cf42eda2bee497db8134a26d657ddc3defcc34b9583ecbd119 languageName: node linkType: hard "@babel/plugin-transform-numeric-separator@npm:^7.24.7": - version: 7.28.6 - resolution: "@babel/plugin-transform-numeric-separator@npm:7.28.6" + version: 7.29.7 + resolution: "@babel/plugin-transform-numeric-separator@npm:7.29.7" dependencies: - "@babel/helper-plugin-utils": ^7.28.6 + "@babel/helper-plugin-utils": ^7.29.7 peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 4b5ca60e481e22f0842761a3badca17376a230b5a7e5482338604eb95836c2d0c9c9bde53bdc5c2de1c6a12ae6c12de7464d098bf74b0943f85905ca358f0b68 + checksum: 6177df9e1a8a190bf4a360f8cbcfa614b70ededba61201bd0a38929770b6d00a8a54a19f8fda82cf60688d9bab938427a9fe5dae0a9e8cd8ed79c88a3b2dbcd7 languageName: node linkType: hard "@babel/plugin-transform-object-rest-spread@npm:^7.24.7": - version: 7.28.6 - resolution: "@babel/plugin-transform-object-rest-spread@npm:7.28.6" + version: 7.29.7 + resolution: "@babel/plugin-transform-object-rest-spread@npm:7.29.7" dependencies: - "@babel/helper-compilation-targets": ^7.28.6 - "@babel/helper-plugin-utils": ^7.28.6 - "@babel/plugin-transform-destructuring": ^7.28.5 - "@babel/plugin-transform-parameters": ^7.27.7 - "@babel/traverse": ^7.28.6 + "@babel/helper-compilation-targets": ^7.29.7 + "@babel/helper-plugin-utils": ^7.29.7 + "@babel/plugin-transform-destructuring": ^7.29.7 + "@babel/plugin-transform-parameters": ^7.29.7 + "@babel/traverse": ^7.29.7 peerDependencies: "@babel/core": ^7.0.0-0 - checksum: ab85b1321f86db91aba22ad9d8e6ab65448c983214998012229f5302468527d27b908ad6b14755991c317e35d2f54ec8459a2a094a755999651fe0ac9bd2e9a6 + checksum: e09bcc8800cf374962ab98bce5ccceb900266278a3a1e4a68391abec440fdf7371c8059f9091f407a1b167f3acf624c34c9b92188674fdcb7797e3f02509216b languageName: node linkType: hard "@babel/plugin-transform-optional-catch-binding@npm:^7.24.7": - version: 7.28.6 - resolution: "@babel/plugin-transform-optional-catch-binding@npm:7.28.6" + version: 7.29.7 + resolution: "@babel/plugin-transform-optional-catch-binding@npm:7.29.7" dependencies: - "@babel/helper-plugin-utils": ^7.28.6 + "@babel/helper-plugin-utils": ^7.29.7 peerDependencies: "@babel/core": ^7.0.0-0 - checksum: ee24a17defec056eb9ef01824d7e4a1f65d531af6b4b79acfd0bcb95ce0b47926e80c61897f36f8c01ce733b069c9acdb1c9ce5ec07a729d0dbf9e8d859fe992 + checksum: 4b6e41e1dc5dbd02cfe0b96214130cca5fd3bd879551fc82188bb3d9a2782af9bab50f2140af9ff946a8ee23b9478ee42810641fb99aa3e033884d7c6103d138 languageName: node linkType: hard "@babel/plugin-transform-optional-chaining@npm:^7.24.8, @babel/plugin-transform-optional-chaining@npm:^7.27.1": - version: 7.28.6 - resolution: "@babel/plugin-transform-optional-chaining@npm:7.28.6" + version: 7.29.7 + resolution: "@babel/plugin-transform-optional-chaining@npm:7.29.7" dependencies: - "@babel/helper-plugin-utils": ^7.28.6 - "@babel/helper-skip-transparent-expression-wrappers": ^7.27.1 + "@babel/helper-plugin-utils": ^7.29.7 + "@babel/helper-skip-transparent-expression-wrappers": ^7.29.7 peerDependencies: "@babel/core": ^7.0.0-0 - checksum: a40dbe709671a436bb69e14524805e10af81b44c422e4fc5dc905cb91adb92d650c9d266c3c2c0da0d410dea89ce784995d4118b7ab6a7544f4923e61590b386 + checksum: 6255d259bb0143f7938278ebb382aba22311c260919d3f08476509c76335a111b479b3ad7363e86de064f87af85ca4d7f03f08195f0646c525ea70fb587c0a2d languageName: node linkType: hard -"@babel/plugin-transform-parameters@npm:^7.24.7, @babel/plugin-transform-parameters@npm:^7.27.7": - version: 7.27.7 - resolution: "@babel/plugin-transform-parameters@npm:7.27.7" +"@babel/plugin-transform-parameters@npm:^7.24.7, @babel/plugin-transform-parameters@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/plugin-transform-parameters@npm:7.29.7" dependencies: - "@babel/helper-plugin-utils": ^7.27.1 + "@babel/helper-plugin-utils": ^7.29.7 peerDependencies: "@babel/core": ^7.0.0-0 - checksum: d51f195e1d6ac5d9fce583e9a70a5bfe403e62386e5eb06db9fbc6533f895a98ff7e7c3dcaa311a8e6fa7a9794466e81cdabcba6af9f59d787fb767bfe7868b4 + checksum: 615cdd72dc2d08051e6d4e7f0539661e40029257a047ce15c5da5f4394a372a53f6f6d6aece72ad15ccf0590e448c9a742f8576564321c9dcc16262ec6197c9b languageName: node linkType: hard "@babel/plugin-transform-private-methods@npm:^7.24.7": - version: 7.28.6 - resolution: "@babel/plugin-transform-private-methods@npm:7.28.6" + version: 7.29.7 + resolution: "@babel/plugin-transform-private-methods@npm:7.29.7" dependencies: - "@babel/helper-create-class-features-plugin": ^7.28.6 - "@babel/helper-plugin-utils": ^7.28.6 + "@babel/helper-create-class-features-plugin": ^7.29.7 + "@babel/helper-plugin-utils": ^7.29.7 peerDependencies: "@babel/core": ^7.0.0-0 - checksum: b80179b28f6a165674d0b0d6c6349b13a01dd282b18f56933423c0a33c23fc0626c8f011f859fc20737d021fe966eb8474a5233e4596401482e9ee7fb00e2aa2 + checksum: 5055af2b86a95acbf6bd1a14256edf439ba4f214707f6aa9f6a29d1168c882e5709853c4ff225f55575f88d3a58effbed952d25c5cd70f93785106541f992cdd languageName: node linkType: hard "@babel/plugin-transform-private-property-in-object@npm:^7.24.7": - version: 7.28.6 - resolution: "@babel/plugin-transform-private-property-in-object@npm:7.28.6" + version: 7.29.7 + resolution: "@babel/plugin-transform-private-property-in-object@npm:7.29.7" dependencies: - "@babel/helper-annotate-as-pure": ^7.27.3 - "@babel/helper-create-class-features-plugin": ^7.28.6 - "@babel/helper-plugin-utils": ^7.28.6 + "@babel/helper-annotate-as-pure": ^7.29.7 + "@babel/helper-create-class-features-plugin": ^7.29.7 + "@babel/helper-plugin-utils": ^7.29.7 peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 32a935e44872e90607851be5bc2cd3365f29c0e0e3853ef3e2b6a7da4d08c647379bf2f2dc4f14a9064d7d72e2cf75da85e55baeeec1ffc25cf6088fe24422f7 + checksum: 068ff9e35e103b269c707d16f50384646295a6613c5abfdebea24f0430bb18ea4ef40a299e1dcc915bb22f65e2217c3428c20bcd4a3fe5f8ac60ec212835646d languageName: node linkType: hard -"@babel/plugin-transform-react-display-name@npm:^7.24.7, @babel/plugin-transform-react-display-name@npm:^7.28.0": - version: 7.28.0 - resolution: "@babel/plugin-transform-react-display-name@npm:7.28.0" +"@babel/plugin-transform-react-display-name@npm:^7.24.7, @babel/plugin-transform-react-display-name@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/plugin-transform-react-display-name@npm:7.29.7" dependencies: - "@babel/helper-plugin-utils": ^7.27.1 + "@babel/helper-plugin-utils": ^7.29.7 peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 268b1a9192974439d17949e170b01cac2a2aa003c844e2fe3b8361146f42f66487178cffdfa8ce862aa9e6c814bc37f879a70300cb3f067815d15fa6aad04e6d + checksum: ded95cce1816f800db43e8f4e1f7fbb928091bf036438617b8ca7e9ce776079606045a0ca482904bfeff801c4fc726de633153843c441ef31980b8c41ace04c9 languageName: node linkType: hard -"@babel/plugin-transform-react-jsx-development@npm:^7.27.1": - version: 7.27.1 - resolution: "@babel/plugin-transform-react-jsx-development@npm:7.27.1" +"@babel/plugin-transform-react-jsx-development@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/plugin-transform-react-jsx-development@npm:7.29.7" dependencies: - "@babel/plugin-transform-react-jsx": ^7.27.1 + "@babel/plugin-transform-react-jsx": ^7.29.7 peerDependencies: "@babel/core": ^7.0.0-0 - checksum: b88865d5b8c018992f2332da939faa15c4d4a864c9435a5937beaff3fe43781432cc42e0a5d5631098e0bd4066fc33f5fa72203b388b074c3545fe7aaa21e474 + checksum: c535bb5ee09e07839a422f7a8e55849cd30525af57021888eceb84d33391290f6250207319bb4fbb4d4cbdcdb894b2a2b963f0769a3e95536370159b4b505855 languageName: node linkType: hard "@babel/plugin-transform-react-jsx-self@npm:^7.24.7": - version: 7.27.1 - resolution: "@babel/plugin-transform-react-jsx-self@npm:7.27.1" + version: 7.29.7 + resolution: "@babel/plugin-transform-react-jsx-self@npm:7.29.7" dependencies: - "@babel/helper-plugin-utils": ^7.27.1 + "@babel/helper-plugin-utils": ^7.29.7 peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 72cbae66a58c6c36f7e12e8ed79f292192d858dd4bb00e9e89d8b695e4c5cb6ef48eec84bffff421a5db93fd10412c581f1cccdb00264065df76f121995bdb68 + checksum: 779cde890f36a0160585a357f0850951d9e18d72e960099e32544420252e983b54bfe4a7c81c39b1668ad588231771c97e6b9e59056b21e5cda0953f26db1286 languageName: node linkType: hard "@babel/plugin-transform-react-jsx-source@npm:^7.24.7": - version: 7.27.1 - resolution: "@babel/plugin-transform-react-jsx-source@npm:7.27.1" + version: 7.29.7 + resolution: "@babel/plugin-transform-react-jsx-source@npm:7.29.7" dependencies: - "@babel/helper-plugin-utils": ^7.27.1 + "@babel/helper-plugin-utils": ^7.29.7 peerDependencies: "@babel/core": ^7.0.0-0 - checksum: e2843362adb53692be5ee9fa07a386d2d8883daad2063a3575b3c373fc14cdf4ea7978c67a183cb631b4c9c8d77b2f48c24c088f8e65cc3600cb8e97d72a7161 + checksum: 286641d64bfd1d91eb8fcc3a6a5c48cc7b8e04268c79f3ee9902addc723652a4aa1d967278208d3b0ef03db381853d68eb25ae609e5a305421ff3d3fd5f3cb77 languageName: node linkType: hard -"@babel/plugin-transform-react-jsx@npm:^7.25.2, @babel/plugin-transform-react-jsx@npm:^7.27.1": - version: 7.28.6 - resolution: "@babel/plugin-transform-react-jsx@npm:7.28.6" +"@babel/plugin-transform-react-jsx@npm:^7.25.2, @babel/plugin-transform-react-jsx@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/plugin-transform-react-jsx@npm:7.29.7" dependencies: - "@babel/helper-annotate-as-pure": ^7.27.3 - "@babel/helper-module-imports": ^7.28.6 - "@babel/helper-plugin-utils": ^7.28.6 - "@babel/plugin-syntax-jsx": ^7.28.6 - "@babel/types": ^7.28.6 + "@babel/helper-annotate-as-pure": ^7.29.7 + "@babel/helper-module-imports": ^7.29.7 + "@babel/helper-plugin-utils": ^7.29.7 + "@babel/plugin-syntax-jsx": ^7.29.7 + "@babel/types": ^7.29.7 peerDependencies: "@babel/core": ^7.0.0-0 - checksum: e7d093b5ed6c06563e801d44d1212b451445d7600756efd7b8b8e6db4585c27fa8145176dcb3350968c59381af6c566dae9b6dc97ec15d2837493b238904d1c2 + checksum: d50e5d6f12051c688280b118fc0cdc49f617f7c1f2c41b25733b606aa9a14d2dc84bc544163d115226b9d2cde9f147f49568350b9d100cb47988e5e76cf495c7 languageName: node linkType: hard -"@babel/plugin-transform-react-pure-annotations@npm:^7.27.1": - version: 7.27.1 - resolution: "@babel/plugin-transform-react-pure-annotations@npm:7.27.1" +"@babel/plugin-transform-react-pure-annotations@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/plugin-transform-react-pure-annotations@npm:7.29.7" dependencies: - "@babel/helper-annotate-as-pure": ^7.27.1 - "@babel/helper-plugin-utils": ^7.27.1 + "@babel/helper-annotate-as-pure": ^7.29.7 + "@babel/helper-plugin-utils": ^7.29.7 peerDependencies: "@babel/core": ^7.0.0-0 - checksum: a6f591c5e85a1ab0685d4a25afe591fe8d11dc0b73c677cf9560ff8d540d036a1cce9efcb729fc9092def4d854dc304ffdc063a89a9247900b69c516bf971a4c + checksum: 7b6bc9e9db06f2c40685b4f0a043af17a98a8bee833831aa28f70c89876dc649fdd682ae572445143b53fe091258964107bae9d3583480eb1c4f1d9c22780b38 languageName: node linkType: hard "@babel/plugin-transform-regenerator@npm:^7.24.7": - version: 7.29.0 - resolution: "@babel/plugin-transform-regenerator@npm:7.29.0" + version: 7.29.7 + resolution: "@babel/plugin-transform-regenerator@npm:7.29.7" dependencies: - "@babel/helper-plugin-utils": ^7.28.6 + "@babel/helper-plugin-utils": ^7.29.7 peerDependencies: "@babel/core": ^7.0.0-0 - checksum: f48bc814f11239f2bfe010a6e29d5ac2443e7b1d8004e7c022effa111b743491127acf8644cfef475edb86b91f123829585867bc13762652aabd9b85ed6ce61e + checksum: 1a136712ba92693402d7fafb96624d1a833e888cd59029a84ed24d8ff083304a3f1d101d8d5013d0400229041c8a1e4ddf08027268f3c876ea226e86734acd25 languageName: node linkType: hard "@babel/plugin-transform-runtime@npm:^7.24.7": - version: 7.29.0 - resolution: "@babel/plugin-transform-runtime@npm:7.29.0" + version: 7.29.7 + resolution: "@babel/plugin-transform-runtime@npm:7.29.7" dependencies: - "@babel/helper-module-imports": ^7.28.6 - "@babel/helper-plugin-utils": ^7.28.6 + "@babel/helper-module-imports": ^7.29.7 + "@babel/helper-plugin-utils": ^7.29.7 babel-plugin-polyfill-corejs2: ^0.4.14 babel-plugin-polyfill-corejs3: ^0.13.0 babel-plugin-polyfill-regenerator: ^0.6.5 semver: ^6.3.1 peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 1d3a5951396469372d954538fb188479b86afa8e02ca541da8f123250aaed8df65573b68f67087f4b15a5ccff9abc3a3fdb1d9a07fbb85bfcb807168d7364a37 + checksum: 7256db11b985bf58fdca84c16a742035f75adfbf49c68b14bbb3232843b5af5d8c9695c90d038a00ae22ce4f5b6b95002ba5833ce2bae9893378caa7e60da900 languageName: node linkType: hard "@babel/plugin-transform-shorthand-properties@npm:^7.24.7, @babel/plugin-transform-shorthand-properties@npm:^7.27.1": - version: 7.27.1 - resolution: "@babel/plugin-transform-shorthand-properties@npm:7.27.1" + version: 7.29.7 + resolution: "@babel/plugin-transform-shorthand-properties@npm:7.29.7" dependencies: - "@babel/helper-plugin-utils": ^7.27.1 + "@babel/helper-plugin-utils": ^7.29.7 peerDependencies: "@babel/core": ^7.0.0-0 - checksum: fbba6e2aef0b69681acb68202aa249c0598e470cc0853d7ff5bd0171fd6a7ec31d77cfabcce9df6360fc8349eded7e4a65218c32551bd3fc0caaa1ac899ac6d4 + checksum: c57ef27853f334a6147da9aa00f8a8f4c3a1c217eb2efa73cba2e118edda10754fa23cec2c0c7f7408279ad28fef92c1f663dfec137a7503813331569c3e02f9 languageName: node linkType: hard "@babel/plugin-transform-spread@npm:^7.24.7": - version: 7.28.6 - resolution: "@babel/plugin-transform-spread@npm:7.28.6" + version: 7.29.7 + resolution: "@babel/plugin-transform-spread@npm:7.29.7" dependencies: - "@babel/helper-plugin-utils": ^7.28.6 - "@babel/helper-skip-transparent-expression-wrappers": ^7.27.1 + "@babel/helper-plugin-utils": ^7.29.7 + "@babel/helper-skip-transparent-expression-wrappers": ^7.29.7 peerDependencies: "@babel/core": ^7.0.0-0 - checksum: e4782578904df68f7d2b3e865f20701c71d6aba0027c4794c1dc08a2f805a12892a078dab483714552398a689ad4ff6786cdf4e088b073452aee7db67e37a09c + checksum: eb25f24d9a5cac163fea91b5872ff2ddb892083624284a6e8a1fa8dfc4c4c6f6230f3b478405eb846df895a3f96631ac19e8c169af33012aafaa144e6192d5d3 languageName: node linkType: hard "@babel/plugin-transform-sticky-regex@npm:^7.24.7": - version: 7.27.1 - resolution: "@babel/plugin-transform-sticky-regex@npm:7.27.1" + version: 7.29.7 + resolution: "@babel/plugin-transform-sticky-regex@npm:7.29.7" dependencies: - "@babel/helper-plugin-utils": ^7.27.1 + "@babel/helper-plugin-utils": ^7.29.7 peerDependencies: "@babel/core": ^7.0.0-0 - checksum: e1414a502efba92c7974681767e365a8cda6c5e9e5f33472a9eaa0ce2e75cea0a9bef881ff8dda37c7810ad902f98d3c00ead92a3ac3b73a79d011df85b5a189 + checksum: 16b570c0270a59c2a29b2118c00e463882e9dda49b51a17dde3ae6b2886995d49f4bf2161a4c743ad1bca39d2aeb3ac963400e095682e105c7f751e6a2156c28 languageName: node linkType: hard "@babel/plugin-transform-template-literals@npm:^7.27.1": - version: 7.27.1 - resolution: "@babel/plugin-transform-template-literals@npm:7.27.1" + version: 7.29.7 + resolution: "@babel/plugin-transform-template-literals@npm:7.29.7" dependencies: - "@babel/helper-plugin-utils": ^7.27.1 + "@babel/helper-plugin-utils": ^7.29.7 peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 93aad782503b691faef7c0893372d5243df3219b07f1f22cfc32c104af6a2e7acd6102c128439eab15336d048f1b214ca134b87b0630d8cd568bf447f78b25ce + checksum: d1014dab020f0f802089de17bba82d929eda6ac87fde5f58fb9763885b8d645ce63fc1df97055c06a12d95a8788334a93b559fcaf1da6d7777a191e5f9c5646e languageName: node linkType: hard -"@babel/plugin-transform-typescript@npm:^7.25.2, @babel/plugin-transform-typescript@npm:^7.28.5": - version: 7.28.6 - resolution: "@babel/plugin-transform-typescript@npm:7.28.6" +"@babel/plugin-transform-typescript@npm:^7.25.2, @babel/plugin-transform-typescript@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/plugin-transform-typescript@npm:7.29.7" dependencies: - "@babel/helper-annotate-as-pure": ^7.27.3 - "@babel/helper-create-class-features-plugin": ^7.28.6 - "@babel/helper-plugin-utils": ^7.28.6 - "@babel/helper-skip-transparent-expression-wrappers": ^7.27.1 - "@babel/plugin-syntax-typescript": ^7.28.6 + "@babel/helper-annotate-as-pure": ^7.29.7 + "@babel/helper-create-class-features-plugin": ^7.29.7 + "@babel/helper-plugin-utils": ^7.29.7 + "@babel/helper-skip-transparent-expression-wrappers": ^7.29.7 + "@babel/plugin-syntax-typescript": ^7.29.7 peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 029add39a37e4a1960a43c3a109680462f631bc63cc8457ea65add2cce3271c9fd4d6a1782177c65ea5f77731e2f8e2bc65a9aec9cc826346ba540ecd0b97e5a + checksum: e95bce53fa2add836eec5ef5221e260cfa4ab889a146f7ba5e29cbd42bfe3183cb94e40b49bfb0d14a75f233982723903d3efad0f528b835ce771e38bd365440 languageName: node linkType: hard "@babel/plugin-transform-unicode-regex@npm:^7.24.7, @babel/plugin-transform-unicode-regex@npm:^7.27.1": - version: 7.27.1 - resolution: "@babel/plugin-transform-unicode-regex@npm:7.27.1" + version: 7.29.7 + resolution: "@babel/plugin-transform-unicode-regex@npm:7.29.7" dependencies: - "@babel/helper-create-regexp-features-plugin": ^7.27.1 - "@babel/helper-plugin-utils": ^7.27.1 + "@babel/helper-create-regexp-features-plugin": ^7.29.7 + "@babel/helper-plugin-utils": ^7.29.7 peerDependencies: "@babel/core": ^7.0.0-0 - checksum: a34d89a2b75fb78e66d97c3dc90d4877f7e31f43316b52176f95a5dee20e9bb56ecf158eafc42a001676ddf7b393d9e67650bad6b32f5405780f25fb83cd68e3 + checksum: 1ade0672ae5bbbf2ec1ea0a8de1b5d804ae414283215620097ab21cf7f05dae8916f5b0548a18c6f080ec17135018f5edd2d38f8fa9ca052af570cab5c712786 languageName: node linkType: hard "@babel/preset-flow@npm:^7.13.13": - version: 7.27.1 - resolution: "@babel/preset-flow@npm:7.27.1" + version: 7.29.7 + resolution: "@babel/preset-flow@npm:7.29.7" dependencies: - "@babel/helper-plugin-utils": ^7.27.1 - "@babel/helper-validator-option": ^7.27.1 - "@babel/plugin-transform-flow-strip-types": ^7.27.1 + "@babel/helper-plugin-utils": ^7.29.7 + "@babel/helper-validator-option": ^7.29.7 + "@babel/plugin-transform-flow-strip-types": ^7.29.7 peerDependencies: "@babel/core": ^7.0.0-0 - checksum: f3f25b390debf72a6ff0170a2d5198aea344ba96f05eaca0bae2c7072119706fd46321604d89646bda1842527cfc6eab8828a983ec90149218d2120b9cd26596 + checksum: 562fe8494d7e8e3a894cb9b1213acdc418b42d54f0fe316e97192bb235c8dfe7cde58d351057b9f7896bdc5ff42b605863c00cf6df952d5789cf3d865ab723ba languageName: node linkType: hard "@babel/preset-react@npm:^7.22.15": - version: 7.28.5 - resolution: "@babel/preset-react@npm:7.28.5" + version: 7.29.7 + resolution: "@babel/preset-react@npm:7.29.7" dependencies: - "@babel/helper-plugin-utils": ^7.27.1 - "@babel/helper-validator-option": ^7.27.1 - "@babel/plugin-transform-react-display-name": ^7.28.0 - "@babel/plugin-transform-react-jsx": ^7.27.1 - "@babel/plugin-transform-react-jsx-development": ^7.27.1 - "@babel/plugin-transform-react-pure-annotations": ^7.27.1 + "@babel/helper-plugin-utils": ^7.29.7 + "@babel/helper-validator-option": ^7.29.7 + "@babel/plugin-transform-react-display-name": ^7.29.7 + "@babel/plugin-transform-react-jsx": ^7.29.7 + "@babel/plugin-transform-react-jsx-development": ^7.29.7 + "@babel/plugin-transform-react-pure-annotations": ^7.29.7 peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 13bc1fe4dde0a29d00323e46749e5beb457844507cb3afa2fefbd85d283c2d4836f9e4a780be735de58a44c505870476dc2838f1f8faf9d6f056481e65f1a0fb + checksum: ec9d4d418df3825674ef807020b90a93d6bf6b786a723a59e673c3a32e31927b2f8a50071a17a50fff632f41eda8933e4196974130f2a5cdeb1a040fb2b62b76 languageName: node linkType: hard "@babel/preset-typescript@npm:^7.13.0, @babel/preset-typescript@npm:^7.23.0, @babel/preset-typescript@npm:^7.27.1": - version: 7.28.5 - resolution: "@babel/preset-typescript@npm:7.28.5" + version: 7.29.7 + resolution: "@babel/preset-typescript@npm:7.29.7" dependencies: - "@babel/helper-plugin-utils": ^7.27.1 - "@babel/helper-validator-option": ^7.27.1 - "@babel/plugin-syntax-jsx": ^7.27.1 - "@babel/plugin-transform-modules-commonjs": ^7.27.1 - "@babel/plugin-transform-typescript": ^7.28.5 + "@babel/helper-plugin-utils": ^7.29.7 + "@babel/helper-validator-option": ^7.29.7 + "@babel/plugin-syntax-jsx": ^7.29.7 + "@babel/plugin-transform-modules-commonjs": ^7.29.7 + "@babel/plugin-transform-typescript": ^7.29.7 peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 22f889835d9db1e627846e71ca2f02e2d24e2eb9ebcf9845b3b1d37bd3a53787967bafabbbcb342f06aaf7627399a7102ba6ca18f9a0e17066c865d680d2ceb9 + checksum: f2f58cbbbdb84f6b27e20c7835fbd1e2474e7e6075c97a6609c606139bc782c995f0c5eb5b64f5b613997f8a463f3b4cea10cd1f0d706a66bf1aa41fea666725 languageName: node linkType: hard "@babel/register@npm:^7.13.16": - version: 7.29.3 - resolution: "@babel/register@npm:7.29.3" + version: 7.29.7 + resolution: "@babel/register@npm:7.29.7" dependencies: clone-deep: ^4.0.1 find-cache-dir: ^2.0.0 @@ -1104,50 +1125,50 @@ __metadata: source-map-support: ^0.5.16 peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 6f84a5aa5df539dd7801affbf66b2ec8cd3125dac09151c9d309f932798783c3d2f3036ff84f090c47d587fc089dcd3489cfdd8affa1936347f75240c0b41b0a + checksum: 19c4babef8210106f28b9868f007be6d0dae4f16bb2be2f221ff836ede906599516d713de2c9f7ab32dcb4005f8c1d9e5b0eba3fe1840c132c8425c57ff02dbc languageName: node linkType: hard "@babel/runtime@npm:^7.20.0, @babel/runtime@npm:^7.25.0": - version: 7.29.2 - resolution: "@babel/runtime@npm:7.29.2" - checksum: d5548d1165de8995f8afc93a5694b8625409be16cd1f2250ac13e331335858ddac3cb9fd278e6c43956a130101a2203f09417938a1a96f9fb70f02b4b4172e1d + version: 7.29.7 + resolution: "@babel/runtime@npm:7.29.7" + checksum: bc311855c6dbf5356030979584ca0f8b6cee39fe058175b02e85a73e246fc76ae30248b0d40e70c2652101faeb43279468ed2d086a670673eb9783c1fee1df2b languageName: node linkType: hard -"@babel/template@npm:^7.25.0, @babel/template@npm:^7.28.6, @babel/template@npm:^7.3.3": - version: 7.28.6 - resolution: "@babel/template@npm:7.28.6" +"@babel/template@npm:^7.25.0, @babel/template@npm:^7.28.6, @babel/template@npm:^7.29.7, @babel/template@npm:^7.3.3": + version: 7.29.7 + resolution: "@babel/template@npm:7.29.7" dependencies: - "@babel/code-frame": ^7.28.6 - "@babel/parser": ^7.28.6 - "@babel/types": ^7.28.6 - checksum: 8ab6383053e226025d9491a6e795293f2140482d14f60c1244bece6bf53610ed1e251d5e164de66adab765629881c7d9416e1e540c716541d2fd0f8f36a013d7 + "@babel/code-frame": ^7.29.7 + "@babel/parser": ^7.29.7 + "@babel/types": ^7.29.7 + checksum: 521eb6a1fd4735074ca8dac0d70810860a80edf3bf78105851571993cd13701a2041987e7398ccc9376eb6235ea1258bf494ccaccf3b67fa98dbe954154a2e93 languageName: node linkType: hard -"@babel/traverse--for-generate-function-map@npm:@babel/traverse@^7.25.3, @babel/traverse@npm:^7.25.3, @babel/traverse@npm:^7.27.1, @babel/traverse@npm:^7.28.5, @babel/traverse@npm:^7.28.6, @babel/traverse@npm:^7.29.0": - version: 7.29.0 - resolution: "@babel/traverse@npm:7.29.0" +"@babel/traverse--for-generate-function-map@npm:@babel/traverse@^7.25.3, @babel/traverse@npm:^7.25.3, @babel/traverse@npm:^7.29.0, @babel/traverse@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/traverse@npm:7.29.7" dependencies: - "@babel/code-frame": ^7.29.0 - "@babel/generator": ^7.29.0 - "@babel/helper-globals": ^7.28.0 - "@babel/parser": ^7.29.0 - "@babel/template": ^7.28.6 - "@babel/types": ^7.29.0 + "@babel/code-frame": ^7.29.7 + "@babel/generator": ^7.29.7 + "@babel/helper-globals": ^7.29.7 + "@babel/parser": ^7.29.7 + "@babel/template": ^7.29.7 + "@babel/types": ^7.29.7 debug: ^4.3.1 - checksum: fbb5085aa525b5d4ecd9fe2f5885d88413fff6ad9c0fac244c37f96069b6d3af9ce825750cd16af1d97d26fa3d354b38dbbdb5f31430e0d99ed89660ab65430e + checksum: 6c4508fd2a308a6a41fbf40bd2590bccfdc3903de51c640a928c49e810220b9e27323a083cda604d44a27449b57265a701b549de01f479611390863734b4fd38 languageName: node linkType: hard -"@babel/types@npm:^7.0.0, @babel/types@npm:^7.20.7, @babel/types@npm:^7.21.3, @babel/types@npm:^7.25.2, @babel/types@npm:^7.26.0, @babel/types@npm:^7.27.1, @babel/types@npm:^7.27.3, @babel/types@npm:^7.28.2, @babel/types@npm:^7.28.5, @babel/types@npm:^7.28.6, @babel/types@npm:^7.29.0, @babel/types@npm:^7.3.3": - version: 7.29.0 - resolution: "@babel/types@npm:7.29.0" +"@babel/types@npm:^7.0.0, @babel/types@npm:^7.20.7, @babel/types@npm:^7.21.3, @babel/types@npm:^7.25.2, @babel/types@npm:^7.26.0, @babel/types@npm:^7.28.2, @babel/types@npm:^7.29.0, @babel/types@npm:^7.29.7, @babel/types@npm:^7.3.3": + version: 7.29.7 + resolution: "@babel/types@npm:7.29.7" dependencies: - "@babel/helper-string-parser": ^7.27.1 - "@babel/helper-validator-identifier": ^7.28.5 - checksum: 83f190438e94c22b2574aaeef7501830311ef266eaabfb06523409f64e2fe855e522951607085d71cad286719adef14e1ba37b671f334a7cd25b0f8506a01e0b + "@babel/helper-string-parser": ^7.29.7 + "@babel/helper-validator-identifier": ^7.29.7 + checksum: 71c46837d22c5c63a5ed571f3b68b4261ecabfc3d4be7251b336ccbd26bc52752ae68ae6d16d24ea512311b78b6f54efdfb00dde87a81a4dc3d19e4a45f05b20 languageName: node linkType: hard @@ -1166,15 +1187,15 @@ __metadata: linkType: hard "@dr.pogodin/react-native-fs@npm:^2.35.1": - version: 2.38.2 - resolution: "@dr.pogodin/react-native-fs@npm:2.38.2" + version: 2.39.1 + resolution: "@dr.pogodin/react-native-fs@npm:2.39.1" dependencies: buffer: ^6.0.3 http-status-codes: ^2.3.0 peerDependencies: react: "*" react-native: "*" - checksum: cc8b9b9817effdde062b9e56d12836d666ff78e5907c69ee817af74d960e83936f0aa457ff105d2e4786f743cb7a6ec5179cf3e020ce65994e77350cd9cca615 + checksum: 584c41db4e88c5dbe32bdc9d2948196bb95692d711fa8adc18c4be09da500dfb06ed7de4574856d129e273319485770408dbcf5cbc66fe07e475a3bf5cc9bfbf languageName: node linkType: hard @@ -1295,33 +1316,33 @@ __metadata: linkType: hard "@expo-google-fonts/material-symbols@npm:^0.4.1": - version: 0.4.34 - resolution: "@expo-google-fonts/material-symbols@npm:0.4.34" - checksum: 552de769e3b21e32346ec401ffee8b697aed0b7f1208ebf2be4f82b2993d5a8f69bb7c7f096a471ea06cdbf32558817c37d08fed1e0964efa643699a9590bac9 + version: 0.4.38 + resolution: "@expo-google-fonts/material-symbols@npm:0.4.38" + checksum: 7a32503acd64173f86acc4f9108709b054e6fcb5255951a56621decbc99aae4c3b29f559c0d070957236f53d823bc68dab3be1678364309ae4193778c88627aa languageName: node linkType: hard -"@expo/cli@npm:55.0.28": - version: 55.0.28 - resolution: "@expo/cli@npm:55.0.28" +"@expo/cli@npm:55.0.33": + version: 55.0.33 + resolution: "@expo/cli@npm:55.0.33" dependencies: "@expo/code-signing-certificates": ^0.0.6 - "@expo/config": ~55.0.15 - "@expo/config-plugins": ~55.0.8 + "@expo/config": ~55.0.18 + "@expo/config-plugins": ~55.0.10 "@expo/devcert": ^1.2.1 - "@expo/env": ~2.1.1 - "@expo/image-utils": ^0.8.13 - "@expo/json-file": ^10.0.13 - "@expo/log-box": 55.0.11 + "@expo/env": ~2.1.2 + "@expo/image-utils": ^0.8.14 + "@expo/json-file": ^10.0.15 + "@expo/log-box": 55.0.12 "@expo/metro": ~55.1.1 - "@expo/metro-config": ~55.0.19 - "@expo/osascript": ^2.4.2 - "@expo/package-manager": ^1.10.4 - "@expo/plist": ^0.5.2 - "@expo/prebuild-config": ^55.0.16 - "@expo/require-utils": ^55.0.4 - "@expo/router-server": ^55.0.15 - "@expo/schema-utils": ^55.0.3 + "@expo/metro-config": ~55.0.24 + "@expo/osascript": ^2.4.4 + "@expo/package-manager": ^1.10.6 + "@expo/plist": ^0.5.4 + "@expo/prebuild-config": ^55.0.19 + "@expo/require-utils": ^55.0.5 + "@expo/router-server": ^55.0.18 + "@expo/schema-utils": ^55.0.4 "@expo/spawn-async": ^1.7.2 "@expo/ws-tunnel": ^1.0.1 "@expo/xcpretty": ^4.4.0 @@ -1336,8 +1357,8 @@ __metadata: compression: ^1.7.4 connect: ^3.7.0 debug: ^4.3.4 - dnssd-advertise: ^1.1.4 - expo-server: ^55.0.8 + dnssd-advertise: ^1.1.6 + expo-server: ^55.0.11 fetch-nodeshim: ^0.4.10 getenv: ^2.0.0 glob: ^13.0.0 @@ -1373,7 +1394,7 @@ __metadata: optional: true bin: expo-internal: build/bin/cli - checksum: 109ddf50a639313e96fc01df91383c977b6ae41e1e8b70105a7bf9689b12f7fce77b6f9ba413131437e0cbda40837448fb40ffc7d43814629d793f2bcd16c373 + checksum: 1804798cf9f15ca48a9e20265713a8deb6b0d91409782e4941d245717b05430c0cb72626699bf0f686ece5eecc8e7d57cca22f16d4d572543c6dfb0ab08b82f3 languageName: node linkType: hard @@ -1386,13 +1407,13 @@ __metadata: languageName: node linkType: hard -"@expo/config-plugins@npm:^55.0.8, @expo/config-plugins@npm:~55.0.8": - version: 55.0.8 - resolution: "@expo/config-plugins@npm:55.0.8" +"@expo/config-plugins@npm:^55.0.10, @expo/config-plugins@npm:~55.0.10": + version: 55.0.10 + resolution: "@expo/config-plugins@npm:55.0.10" dependencies: "@expo/config-types": ^55.0.5 - "@expo/json-file": ~10.0.13 - "@expo/plist": ^0.5.2 + "@expo/json-file": ~10.0.15 + "@expo/plist": ^0.5.4 "@expo/sdk-runtime-versions": ^1.0.0 chalk: ^4.1.2 debug: ^4.3.5 @@ -1403,7 +1424,7 @@ __metadata: slugify: ^1.6.6 xcode: ^3.0.1 xml2js: 0.6.0 - checksum: 3a328e288cabcce508967106bdd766e05c4af5adb9bf279be7723d208127b363057a1c2820eddcd43265387dbb794d86c568ca2ff28851195f1974218da8c0c0 + checksum: aba139ca12c357a6e7e50f64af55224189fcf48e1ccfdcdc3143fed98dd30ea2bfa3736f3425018584c35e07065ba1dea31afdf42a97d25804a11ea31a8b5cb7 languageName: node linkType: hard @@ -1414,21 +1435,21 @@ __metadata: languageName: node linkType: hard -"@expo/config@npm:~55.0.15": - version: 55.0.15 - resolution: "@expo/config@npm:55.0.15" +"@expo/config@npm:~55.0.18": + version: 55.0.18 + resolution: "@expo/config@npm:55.0.18" dependencies: - "@expo/config-plugins": ~55.0.8 + "@expo/config-plugins": ~55.0.10 "@expo/config-types": ^55.0.5 - "@expo/json-file": ^10.0.13 - "@expo/require-utils": ^55.0.4 + "@expo/json-file": ^10.0.15 + "@expo/require-utils": ^55.0.5 deepmerge: ^4.3.1 getenv: ^2.0.0 glob: ^13.0.0 resolve-workspace-root: ^2.0.0 semver: ^7.6.0 slugify: ^1.3.4 - checksum: d60d9d262baa7e8e66da4c1d50a90fdb5d63bb68046d70ca155b74f0ac3d62c639fdc5b77abde9bdcb0d9f92cb5a010a84ec738f021c56353b94e2d247af64a3 + checksum: 93fb543509b1c17b005531799413462bbf86891026df97ddb5b6699d3e701b97bd25ec016bc42de2cc629a0ae9a062a939728503a41844f5a9da90a8065369b0 languageName: node linkType: hard @@ -1442,9 +1463,9 @@ __metadata: languageName: node linkType: hard -"@expo/devtools@npm:55.0.2": - version: 55.0.2 - resolution: "@expo/devtools@npm:55.0.2" +"@expo/devtools@npm:55.0.3": + version: 55.0.3 + resolution: "@expo/devtools@npm:55.0.3" dependencies: chalk: ^4.1.2 peerDependencies: @@ -1455,37 +1476,48 @@ __metadata: optional: true react-native: optional: true - checksum: 21e3a6c965b09682b2e223980fe6661ab56767593cc66b28a2815a14574c31296ebce360f5280d6051ca4374b003654d32b64015da8549aa1b355f22e0cda9e1 + checksum: d1fc7ef4934e1fd77d7c7697aaf467c4d8dd4cc1e6753e1d3b7764ad58c8bfd7fd0dc41a845e4a629f7290b917fb6412776bd479ddf17c9d276daf35f9b1941c languageName: node linkType: hard -"@expo/dom-webview@npm:^55.0.5": - version: 55.0.5 - resolution: "@expo/dom-webview@npm:55.0.5" +"@expo/dom-webview@npm:^55.0.6": + version: 55.0.6 + resolution: "@expo/dom-webview@npm:55.0.6" peerDependencies: expo: "*" react: "*" react-native: "*" - checksum: bcb1fba4fe0ea7c36951f1d8eabf28175746993749207b712624f75701384453e881463438562d74ff6e32adc6e2ffcff4a7c382f73f112c01102712d481283f + checksum: 956810d50cb3e647f8d60d93b48c9d1db28598d966eb543d6453e86d5094948d8d58d5af3087df963b2a43fe6dc58062bf702dd6e5d1a5eb883977056ef24b57 languageName: node linkType: hard -"@expo/env@npm:^2.0.11, @expo/env@npm:~2.1.1": - version: 2.1.1 - resolution: "@expo/env@npm:2.1.1" +"@expo/env@npm:^2.1.2": + version: 2.4.1 + resolution: "@expo/env@npm:2.4.1" dependencies: chalk: ^4.0.0 debug: ^4.3.4 getenv: ^2.0.0 - checksum: 03a7bb255880a8c5b71b8a9eff45b6f58630b5c641cbbfb90fb7b8330c88deec5649e1613ee0e8489ad508796065415868e9fe9a95c52e8d279408188f379b61 + checksum: 1fd9b0285bfc67c2314643684da9ca30bf2c539ed71df190c69d980a2d3c3582bb283493246ce89842354a7a1fad395dc60812d87d6d51a592355a71453c8937 languageName: node linkType: hard -"@expo/fingerprint@npm:0.16.6": - version: 0.16.6 - resolution: "@expo/fingerprint@npm:0.16.6" +"@expo/env@npm:~2.1.2": + version: 2.1.2 + resolution: "@expo/env@npm:2.1.2" dependencies: - "@expo/env": ^2.0.11 + chalk: ^4.0.0 + debug: ^4.3.4 + getenv: ^2.0.0 + checksum: 35101fe1cb5c11ef01ad9260787a945cd8ecb98eb3c78ce06ae8b1806f76fe2aeb22b95f0ad364bef5314f59e64dba2750810b5dd2b0fb2350d860f744fa357d + languageName: node + linkType: hard + +"@expo/fingerprint@npm:0.16.7": + version: 0.16.7 + resolution: "@expo/fingerprint@npm:0.16.7" + dependencies: + "@expo/env": ^2.1.2 "@expo/spawn-async": ^1.7.2 arg: ^5.0.2 chalk: ^4.1.2 @@ -1498,71 +1530,91 @@ __metadata: semver: ^7.6.0 bin: fingerprint: bin/cli.js - checksum: e83ecac8b8ee074f0e57b801619c8874445a4320d468f88c65d65e5db09e8cf137d866a0a3c8f142a342c0fdfe9c8c31ae3d9efafb2ce15d2022383d29e8c34d + checksum: 8dfbcc216bfdb47ff605058f9c3b28eacb75677ce9bf0b0adc90915a9a5fd60519780a93bc3bb19198c8b786c9f1593f3d8bddc34037cba6061efbcc25e13d52 languageName: node linkType: hard -"@expo/image-utils@npm:^0.8.13": - version: 0.8.13 - resolution: "@expo/image-utils@npm:0.8.13" +"@expo/image-utils@npm:^0.8.14": + version: 0.8.14 + resolution: "@expo/image-utils@npm:0.8.14" dependencies: - "@expo/require-utils": ^55.0.4 + "@expo/require-utils": ^55.0.5 "@expo/spawn-async": ^1.7.2 chalk: ^4.0.0 getenv: ^2.0.0 jimp-compact: 0.16.1 parse-png: ^2.1.0 semver: ^7.6.0 - checksum: 290641d0075120872e9a32e24f341ab9e0e78f04493e4f430b612d0a6aea885d0aacf18b8e5331a6519eed557bee666540134a069afc8092d7752c7d78f6f767 + checksum: 2c4e5c00c1a8b2d7c8c155c481b8684e5425d15ce2ebbc9503102dc09de27f45c6796b58581435345197932040b94d62311bfabfc5b461a1dd5a65361b7ebd9f languageName: node linkType: hard -"@expo/json-file@npm:^10.0.13, @expo/json-file@npm:~10.0.13": - version: 10.0.13 - resolution: "@expo/json-file@npm:10.0.13" +"@expo/json-file@npm:^10.0.15": + version: 10.2.0 + resolution: "@expo/json-file@npm:10.2.0" dependencies: "@babel/code-frame": ^7.20.0 json5: ^2.2.3 - checksum: 84fe31e4c9b94b978b3f3c9d8a3b3e8e1c5c785dce3f6c151d0a5c0bb3d1ba7337a87fb566ac10bf7c50d4384bd88cd7ed9d738c05115ef523583b5fd38dc49f + checksum: 89a0c8861024dce24f6b3b576e51d993ad89bb8c26bd70f885b190e52afc22f3b57209fab46cf50850edae7d99033f5ed826ed12b869c3606fdd072583546699 languageName: node linkType: hard -"@expo/local-build-cache-provider@npm:55.0.11": - version: 55.0.11 - resolution: "@expo/local-build-cache-provider@npm:55.0.11" +"@expo/json-file@npm:^11.0.0": + version: 11.0.0 + resolution: "@expo/json-file@npm:11.0.0" dependencies: - "@expo/config": ~55.0.15 + "@babel/code-frame": ^7.20.0 + json5: ^2.2.3 + checksum: 0426299412a1581377c33e6c69e47a5e557a8d715f813f5cb14d79836a4fd477973a05a375a010dcbefa5ae30d68c15f7e5d5773f2e2257f3cc6a3cb01b61a7f + languageName: node + linkType: hard + +"@expo/json-file@npm:~10.0.15": + version: 10.0.16 + resolution: "@expo/json-file@npm:10.0.16" + dependencies: + "@babel/code-frame": ~7.10.4 + json5: ^2.2.3 + checksum: d51baaa1427f399a90340a1780d6138e986c5fca2cec06b06b008fc85348ea731b5baa207f08bd12bc2198af57f7541286b4250a9bb62296899c6a619a3b4889 + languageName: node + linkType: hard + +"@expo/local-build-cache-provider@npm:55.0.14": + version: 55.0.14 + resolution: "@expo/local-build-cache-provider@npm:55.0.14" + dependencies: + "@expo/config": ~55.0.18 chalk: ^4.1.2 - checksum: 1fcb73bdfbf429160853de92397e77d03d243bc1c5fd1d70a3a88a1b5b3213d42ef72c59e95662bca304a105b267536b1d97441015f45f1c03afe2643a60a5bb + checksum: 77d94dbf166078dc972896e76f516960d6a77b6e3b415fe8fb75b1463e7660453a3063d1685042b28146b93d0f7e1675329826c966343a9d859615e3ebbf65cf languageName: node linkType: hard -"@expo/log-box@npm:55.0.11": - version: 55.0.11 - resolution: "@expo/log-box@npm:55.0.11" +"@expo/log-box@npm:55.0.12": + version: 55.0.12 + resolution: "@expo/log-box@npm:55.0.12" dependencies: - "@expo/dom-webview": ^55.0.5 + "@expo/dom-webview": ^55.0.6 anser: ^1.4.9 stacktrace-parser: ^0.1.10 peerDependencies: - "@expo/dom-webview": ^55.0.5 + "@expo/dom-webview": ^55.0.6 expo: "*" react: "*" react-native: "*" - checksum: da7cd20c09c97212a2b11e51ec27c204d932f7d7fd32e19f4427cce98424e1d470e3b9ef05eca1c754ffa0f98eb74b1b13af632087452b4578e6105377de0138 + checksum: 17d603749834767487a71e359638466c85f4d62527745e3086fc280cfda465082b063cc415b82c7c33548f6ad801e84acd05075daa27b5e5cba0dbed45f351e2 languageName: node linkType: hard -"@expo/metro-config@npm:55.0.19, @expo/metro-config@npm:~55.0.19": - version: 55.0.19 - resolution: "@expo/metro-config@npm:55.0.19" +"@expo/metro-config@npm:55.0.24, @expo/metro-config@npm:~55.0.24": + version: 55.0.24 + resolution: "@expo/metro-config@npm:55.0.24" dependencies: "@babel/code-frame": ^7.20.0 "@babel/core": ^7.20.0 "@babel/generator": ^7.20.5 - "@expo/config": ~55.0.15 - "@expo/env": ~2.1.1 - "@expo/json-file": ~10.0.13 + "@expo/config": ~55.0.18 + "@expo/env": ~2.1.2 + "@expo/json-file": ~10.0.15 "@expo/metro": ~55.1.1 "@expo/spawn-async": ^1.7.2 browserslist: ^4.25.0 @@ -1574,22 +1626,22 @@ __metadata: jsc-safe-url: ^0.2.4 lightningcss: ^1.30.1 picomatch: ^4.0.3 - postcss: ~8.4.32 + postcss: ^8.5.14 resolve-from: ^5.0.0 peerDependencies: expo: "*" peerDependenciesMeta: expo: optional: true - checksum: 9355cb4f802aff122d2ac8d78cd99da5c6a257775a9f76edecce3873b3851df49783e7fcbb1acc4ecbef8119df5b2563cb0e876df7ab2fac6e8ee7fff5bbe75b + checksum: 701002e5bb2a3829a2ce1b979802347c3f85a78f87c776cc5caefa41e3331c804c9c4b52ec0fcdc08cc0c406bb10c2ccec41ae68f99936423da2fe3de0ce3ec5 languageName: node linkType: hard -"@expo/metro-runtime@npm:^55.0.10": - version: 55.0.10 - resolution: "@expo/metro-runtime@npm:55.0.10" +"@expo/metro-runtime@npm:^55.0.11": + version: 55.0.11 + resolution: "@expo/metro-runtime@npm:55.0.11" dependencies: - "@expo/log-box": 55.0.11 + "@expo/log-box": 55.0.12 anser: ^1.4.9 pretty-format: ^29.7.0 stacktrace-parser: ^0.1.10 @@ -1602,7 +1654,7 @@ __metadata: peerDependenciesMeta: react-dom: optional: true - checksum: c07009dc4870ccfb97b49a9b3fae90fdf67702d126ba6385f81b29836936c0036aa4cf8aa9863e38e523a4d86828e13201cbb23487a6e719a03eca925091c903 + checksum: db65bad98379cb229269c058c386e3535e6aa9327e8da4182a95257945a0d830cfe44330be50fdefcfc53e704d9505c0d89f31352b3397102ea6508eac3254e1 languageName: node linkType: hard @@ -1628,49 +1680,49 @@ __metadata: languageName: node linkType: hard -"@expo/osascript@npm:^2.4.2": - version: 2.4.2 - resolution: "@expo/osascript@npm:2.4.2" +"@expo/osascript@npm:^2.4.4": + version: 2.7.0 + resolution: "@expo/osascript@npm:2.7.0" dependencies: - "@expo/spawn-async": ^1.7.2 - checksum: 5609b926bd68120b6a01edea0c7b14d4fa9fcd454bbcb49b89988f7acdb540f3b9c1c133acbbd3f9cd6a6937ce2a950c9cdde2a98ec8769d8a8b1481666a67d9 + "@expo/spawn-async": ^1.8.0 + checksum: 456ad05358eac001d2be43510b30422bd7d931023587a2211d2285d8b7adac498b76b18a2907a1bd95debdd315ebc9aa7717cdaa270b61b3631cd7c3ce773c50 languageName: node linkType: hard -"@expo/package-manager@npm:^1.10.4": - version: 1.10.4 - resolution: "@expo/package-manager@npm:1.10.4" +"@expo/package-manager@npm:^1.10.6": + version: 1.13.0 + resolution: "@expo/package-manager@npm:1.13.0" dependencies: - "@expo/json-file": ^10.0.13 - "@expo/spawn-async": ^1.7.2 + "@expo/json-file": ^11.0.0 + "@expo/spawn-async": ^1.8.0 chalk: ^4.0.0 npm-package-arg: ^11.0.0 ora: ^3.4.0 resolve-workspace-root: ^2.0.0 - checksum: bbbe93de910a6a06b5ea3d327e15ac16eced8b3c25e3f4b0e7b4186c3a06c7ef23cf1517b301170cfc7a1c629fe30b89d031b42c0469efa740e7aa59c05224b0 + checksum: 26edfda17318f606f7c181090284899e4da329ed4f18053db3fa49008186203ad2c2170a224245e0ee325340b6cdf9cad9bca552fde2fd5ad0d362027d6998ef languageName: node linkType: hard -"@expo/plist@npm:^0.5.2": - version: 0.5.2 - resolution: "@expo/plist@npm:0.5.2" +"@expo/plist@npm:^0.5.4": + version: 0.5.4 + resolution: "@expo/plist@npm:0.5.4" dependencies: "@xmldom/xmldom": ^0.8.8 base64-js: ^1.5.1 xmlbuilder: ^15.1.1 - checksum: 30c06ee9a1375df1d85ef7608c0b15444d6a084330a9769c02bf66e9ed7b79867753a888f1cd80c61867ad09d7c1b34d3ef9e3839a62536ae07a58bc95de5c6b + checksum: a69aa6a16eadc5d2536788aa13f794a1e18035b2c2bde714c35c6035b233aaee0997aa95683f9547b2f9bcdc00af85ed938d7ee3c3df5157220ceb6061549b2e languageName: node linkType: hard -"@expo/prebuild-config@npm:^55.0.16": - version: 55.0.16 - resolution: "@expo/prebuild-config@npm:55.0.16" +"@expo/prebuild-config@npm:^55.0.19": + version: 55.0.19 + resolution: "@expo/prebuild-config@npm:55.0.19" dependencies: - "@expo/config": ~55.0.15 - "@expo/config-plugins": ~55.0.8 + "@expo/config": ~55.0.18 + "@expo/config-plugins": ~55.0.10 "@expo/config-types": ^55.0.5 - "@expo/image-utils": ^0.8.13 - "@expo/json-file": ^10.0.13 + "@expo/image-utils": ^0.8.14 + "@expo/json-file": ^10.0.15 "@react-native/normalize-colors": 0.83.6 debug: ^4.3.1 resolve-from: ^5.0.0 @@ -1678,13 +1730,13 @@ __metadata: xml2js: 0.6.0 peerDependencies: expo: "*" - checksum: 2a908fca545b5e53d5bc6e757f2e38e6e52b06d1deb23449967c22638033e023865db86faa71f919e76d097f1176663b6d4ea949bb163489c4ff658f532d6a77 + checksum: f21135e7976ab9a644ee3462eeaf12b329e65280be524a2674c8f21865bc58d6816e050c580af59654b91886527d259328ec733d8f30d94545cdcac8e5f5b3ef languageName: node linkType: hard -"@expo/require-utils@npm:^55.0.4": - version: 55.0.4 - resolution: "@expo/require-utils@npm:55.0.4" +"@expo/require-utils@npm:^55.0.5": + version: 55.0.5 + resolution: "@expo/require-utils@npm:55.0.5" dependencies: "@babel/code-frame": ^7.20.0 "@babel/core": ^7.25.2 @@ -1694,22 +1746,22 @@ __metadata: peerDependenciesMeta: typescript: optional: true - checksum: 4aa0702f2bd82ffc964ac19105e2b9037173808d43c09ab4e57e48cf17d9ed407047a90f99de5323f662724ffe55f0797f39ed574a8f8661b7cd0a49ac6c8588 + checksum: d4fdb0a3b98e25f98051b245fdd296c225562c01f3bf9b92672ce661fe8e486d6e8ba64bc3569bfb41fbf58812ed05fa019657d66c3085030e8ef71d278ae26c languageName: node linkType: hard -"@expo/router-server@npm:^55.0.15": - version: 55.0.15 - resolution: "@expo/router-server@npm:55.0.15" +"@expo/router-server@npm:^55.0.18": + version: 55.0.18 + resolution: "@expo/router-server@npm:55.0.18" dependencies: debug: ^4.3.4 peerDependencies: - "@expo/metro-runtime": ^55.0.10 + "@expo/metro-runtime": ^55.0.11 expo: "*" - expo-constants: ^55.0.15 - expo-font: ^55.0.6 + expo-constants: ^55.0.16 + expo-font: ^55.0.8 expo-router: "*" - expo-server: ^55.0.8 + expo-server: ^55.0.11 react: "*" react-dom: "*" react-server-dom-webpack: ~19.0.1 || ~19.1.2 || ~19.2.1 @@ -1722,14 +1774,14 @@ __metadata: optional: true react-server-dom-webpack: optional: true - checksum: 3abffdeaf41830fa00dfb48aa53986836a2320ed370dba7396cc91eb1f4da08a906980715e8e33b6132406f77d8e47720984799a35aaa6a5120e84a7288d131b + checksum: cf12d96f3a04012565c06a8ebda9e2163cf5fc4cbfc940ec6f983591d14a94574b3bd38db55f4adfae8b43b9b2cb0791c2c95848c796f2e9a5f868766425e4eb languageName: node linkType: hard -"@expo/schema-utils@npm:^55.0.3": - version: 55.0.3 - resolution: "@expo/schema-utils@npm:55.0.3" - checksum: 0af91b7eb5046a367fac92934ea45a233cbef8fa72b8a6a14e7d98c821b0d56e162f800f20eb20fc52d23473f5ef61e312893a19f5efe64a02e80739493ca895 +"@expo/schema-utils@npm:^55.0.4": + version: 55.0.4 + resolution: "@expo/schema-utils@npm:55.0.4" + checksum: 4898207d90324973b73262464226912043bd817c2743a27119090aaf54bdafe9c1590f98f5a64c5b00b2b3c4e7c1611ce43eef5d9076b715571ca68feba0e50e languageName: node linkType: hard @@ -1740,12 +1792,12 @@ __metadata: languageName: node linkType: hard -"@expo/spawn-async@npm:^1.7.2": - version: 1.7.2 - resolution: "@expo/spawn-async@npm:1.7.2" +"@expo/spawn-async@npm:^1.7.2, @expo/spawn-async@npm:^1.8.0": + version: 1.8.0 + resolution: "@expo/spawn-async@npm:1.8.0" dependencies: - cross-spawn: ^7.0.3 - checksum: d99e5ff6d303ec9b0105f97c4fa6c65bca526c7d4d0987997c35cc745fa8224adf009942d01808192ebb9fa30619a53316641958631e85cf17b773d9eeda2597 + cross-spawn: ^7.0.6 + checksum: fc5202df888ee38915a215dfa177b0581358d378274077814f81ba5b3fe5ee2cd93271c091aba7429337a997af20efb7a38cec63cce6bb0b04395786853c1e30 languageName: node linkType: hard @@ -1775,21 +1827,21 @@ __metadata: linkType: hard "@expo/xcpretty@npm:^4.4.0": - version: 4.4.3 - resolution: "@expo/xcpretty@npm:4.4.3" + version: 4.4.4 + resolution: "@expo/xcpretty@npm:4.4.4" dependencies: "@babel/code-frame": ^7.20.0 chalk: ^4.1.0 js-yaml: ^4.1.0 bin: excpretty: build/cli.js - checksum: c86398e73f2aa3d711685f0278798cb7bce074475d92226e92e693e7a092eb6dcceffaff80096806f05696fc6b1af0fa01719938a7b3691f1abdeac3e474ffa0 + checksum: 9e1fb3292acf67787235f0698edcedfa32bed985ddff5863bda1f4e53b11deef07d89a9192e0142d0e5995441dd10b6698ac370ccf73414a10e768778d425201 languageName: node linkType: hard "@gorhom/bottom-sheet@npm:^5.2.9": - version: 5.2.13 - resolution: "@gorhom/bottom-sheet@npm:5.2.13" + version: 5.2.14 + resolution: "@gorhom/bottom-sheet@npm:5.2.14" dependencies: "@gorhom/portal": 1.0.14 invariant: ^2.2.4 @@ -1805,7 +1857,7 @@ __metadata: optional: true "@types/react-native": optional: true - checksum: 2c787e4617cad8abe91470bb25c8fb6193979a20dba0cc64135c7ceba28e0ac593c41465e362ebbca117d192bc5d804fdf6f79b297829f2087f8173f1f83a744 + checksum: c4387fa66b06e9c5dac713af7cdc9f9f5da42743f7dd6a2117d8c956e9410573c24e552fdb20232e251de5890ee83569894312eb5d4531f933f512b673ffeb53 languageName: node linkType: hard @@ -1822,9 +1874,9 @@ __metadata: linkType: hard "@huggingface/jinja@npm:^0.5.0": - version: 0.5.8 - resolution: "@huggingface/jinja@npm:0.5.8" - checksum: 13511762cebd882bf7d4aa0f52bba0cd3aa7b901e245030a07a95dfd7b341517c388aa0e604bc45638bde7965b17f87486cacf4316c9949efd813444c4ba9aa2 + version: 0.5.9 + resolution: "@huggingface/jinja@npm:0.5.9" + checksum: ca6e398a38d0524b6d520396e5cd0101c7572614f168828264a68a316a4e41ff9011dedc336f238adc605df8227b159bb3c8540280b0b6f6e2f1126062b39123 languageName: node linkType: hard @@ -1969,10 +2021,10 @@ __metadata: languageName: node linkType: hard -"@jest/diff-sequences@npm:30.3.0": - version: 30.3.0 - resolution: "@jest/diff-sequences@npm:30.3.0" - checksum: 715325e544f54cf5336b54fbfecd3e7e0e779b96c1f28a2ab42fdd4388f5f3751558a474d173b0c43bc5fb513fbb0464dbf1503cc69dab248515c6ed42feecb3 +"@jest/diff-sequences@npm:30.4.0": + version: 30.4.0 + resolution: "@jest/diff-sequences@npm:30.4.0" + checksum: a391acfbb6b349558c2c84643b4790be70e466d09bdca2eee8e4cb533cb0b5652930f591614fe05d39681e212cf984c790a770e282602c1645c561d85e8b64ee languageName: node linkType: hard @@ -2077,12 +2129,12 @@ __metadata: languageName: node linkType: hard -"@jest/schemas@npm:30.0.5": - version: 30.0.5 - resolution: "@jest/schemas@npm:30.0.5" +"@jest/schemas@npm:30.4.1": + version: 30.4.1 + resolution: "@jest/schemas@npm:30.4.1" dependencies: "@sinclair/typebox": ^0.34.0 - checksum: 7a4fc4166f688947c22d81e61aaf2cb22f178dbf6ee806b0931b75136899d426a72a8330762f27f0cf6f79da0d2a56f49a22fe09f5f80df95a683ed237a0f3b0 + checksum: 25d0db478805adff276e02f9e1b5a90d5962e51020503eede22edee432de3958654edddca0e66988c515fa7bc06461f5220826de9f76fcc89c5824e88d624842 languageName: node linkType: hard @@ -2262,8 +2314,8 @@ __metadata: linkType: hard "@op-engineering/op-sqlite@npm:^15.2.7": - version: 15.2.12 - resolution: "@op-engineering/op-sqlite@npm:15.2.12" + version: 15.2.14 + resolution: "@op-engineering/op-sqlite@npm:15.2.14" peerDependencies: "@sqlite.org/sqlite-wasm": "*" react: "*" @@ -2271,32 +2323,32 @@ __metadata: peerDependenciesMeta: "@sqlite.org/sqlite-wasm": optional: true - checksum: 9e73b4dcca95b7bc351775ec6138a8134d88d82c5abb4e9cd8cbecd55e265ace5afe4c520747dd36d2e9aef2902391fa26a2ff7dba7a738b4df5dc4e45feeea2 + checksum: e6cc55841bfe4556046b1e1bcf15e8fb68c5e6c17e69a01a09c75ccdeca6a973238fc6032eab4433f36bd4114180d6696f68282f47be052cb26b6d78ef36c45b languageName: node linkType: hard -"@pkgr/core@npm:^0.2.9": - version: 0.2.9 - resolution: "@pkgr/core@npm:0.2.9" - checksum: bb2fb86977d63f836f8f5b09015d74e6af6488f7a411dcd2bfdca79d76b5a681a9112f41c45bdf88a9069f049718efc6f3900d7f1de66a2ec966068308ae517f +"@pkgr/core@npm:^0.3.6": + version: 0.3.6 + resolution: "@pkgr/core@npm:0.3.6" + checksum: 29082aa13d36f13fc41cdc64cb1feb36b630de0d6ebde84e2ff68e3d7a7f1dce4462cca91f76176c46e50bbca6b1e7f1fd9cf907af12d5d70da83bc981ca4ccf languageName: node linkType: hard -"@radix-ui/primitive@npm:1.1.3": - version: 1.1.3 - resolution: "@radix-ui/primitive@npm:1.1.3" - checksum: ee27abbff0d6d305816e9314655eb35e72478ba47416bc9d5cb0581728be35e3408cfc0748313837561d635f0cb7dfaae26e61831f0e16c0fd7d669a612f2cb0 +"@radix-ui/primitive@npm:1.1.4": + version: 1.1.4 + resolution: "@radix-ui/primitive@npm:1.1.4" + checksum: 983f49f953b39eca8cc0fa26f429375bd153535371a222255814ee7b9d2ff89c6ab3c73399eb77c027944d76289e7bc4eec03121c1f782a93d27623de11ad135 languageName: node linkType: hard -"@radix-ui/react-collection@npm:1.1.7": - version: 1.1.7 - resolution: "@radix-ui/react-collection@npm:1.1.7" +"@radix-ui/react-collection@npm:1.1.11": + version: 1.1.11 + resolution: "@radix-ui/react-collection@npm:1.1.11" dependencies: - "@radix-ui/react-compose-refs": 1.1.2 - "@radix-ui/react-context": 1.1.2 - "@radix-ui/react-primitive": 2.1.3 - "@radix-ui/react-slot": 1.2.3 + "@radix-ui/react-compose-refs": 1.1.3 + "@radix-ui/react-context": 1.1.4 + "@radix-ui/react-primitive": 2.1.7 + "@radix-ui/react-slot": 1.3.0 peerDependencies: "@types/react": "*" "@types/react-dom": "*" @@ -2307,54 +2359,54 @@ __metadata: optional: true "@types/react-dom": optional: true - checksum: dd9bb015ef86205b4246f55bc84e5ad54519bb89b4825dd83e646fe95205191fe376bb31a9e847f9d66b710d0ef7fc9353c0b0ded7e8997a5c1f5be6addf94ef + checksum: c8ccc12fed57bc77db816369d31db416d68c03a15d7fce04135a2838141e094a85679baa5abd3344c17138377b6e5d9a04373af9f2a5db631a8a07f97bad600a languageName: node linkType: hard -"@radix-ui/react-compose-refs@npm:1.1.2": - version: 1.1.2 - resolution: "@radix-ui/react-compose-refs@npm:1.1.2" +"@radix-ui/react-compose-refs@npm:1.1.3": + version: 1.1.3 + resolution: "@radix-ui/react-compose-refs@npm:1.1.3" peerDependencies: "@types/react": "*" react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: "@types/react": optional: true - checksum: 9a91f0213014ffa40c5b8aae4debb993be5654217e504e35aa7422887eb2d114486d37e53c482d0fffb00cd44f51b5269fcdf397b280c71666fa11b7f32f165d + checksum: e6c296ee4f816f65feb5f8511217c3521ad822c8a995f320792fe5d909266a982ab3d14b62dab705eab0cb36d82eb0a2895886bb259268dc25898ea43d68a224 languageName: node linkType: hard -"@radix-ui/react-context@npm:1.1.2": - version: 1.1.2 - resolution: "@radix-ui/react-context@npm:1.1.2" +"@radix-ui/react-context@npm:1.1.4": + version: 1.1.4 + resolution: "@radix-ui/react-context@npm:1.1.4" peerDependencies: "@types/react": "*" react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: "@types/react": optional: true - checksum: 6d08437f23df362672259e535ae463e70bf7a0069f09bfa06c983a5a90e15250bde19da1d63ef8e3da06df1e1b4f92afa9d28ca6aa0297bb1c8aaf6ca83d28c5 + checksum: 01a4a4e59f868265aab95280c0a4d96433962f00592a0608f88d35e05901feff9adbf2b6926a49c3cae20f22db4630806ce4b8fc56d3e8a453666547a76485d1 languageName: node linkType: hard "@radix-ui/react-dialog@npm:^1.1.1": - version: 1.1.15 - resolution: "@radix-ui/react-dialog@npm:1.1.15" - dependencies: - "@radix-ui/primitive": 1.1.3 - "@radix-ui/react-compose-refs": 1.1.2 - "@radix-ui/react-context": 1.1.2 - "@radix-ui/react-dismissable-layer": 1.1.11 - "@radix-ui/react-focus-guards": 1.1.3 - "@radix-ui/react-focus-scope": 1.1.7 - "@radix-ui/react-id": 1.1.1 - "@radix-ui/react-portal": 1.1.9 - "@radix-ui/react-presence": 1.1.5 - "@radix-ui/react-primitive": 2.1.3 - "@radix-ui/react-slot": 1.2.3 - "@radix-ui/react-use-controllable-state": 1.2.2 + version: 1.1.18 + resolution: "@radix-ui/react-dialog@npm:1.1.18" + dependencies: + "@radix-ui/primitive": 1.1.4 + "@radix-ui/react-compose-refs": 1.1.3 + "@radix-ui/react-context": 1.1.4 + "@radix-ui/react-dismissable-layer": 1.1.14 + "@radix-ui/react-focus-guards": 1.1.4 + "@radix-ui/react-focus-scope": 1.1.11 + "@radix-ui/react-id": 1.1.2 + "@radix-ui/react-portal": 1.1.13 + "@radix-ui/react-presence": 1.1.6 + "@radix-ui/react-primitive": 2.1.7 + "@radix-ui/react-slot": 1.3.0 + "@radix-ui/react-use-controllable-state": 1.2.3 aria-hidden: ^1.2.4 - react-remove-scroll: ^2.6.3 + react-remove-scroll: ^2.7.2 peerDependencies: "@types/react": "*" "@types/react-dom": "*" @@ -2365,32 +2417,32 @@ __metadata: optional: true "@types/react-dom": optional: true - checksum: a0834338ec66866ce301ef46e0dad9d99accf496f03b5021eceec7e2b79d7286b4f2c5e35f2387891e2bf33ef9a11d381dde2c8fe936a2f30cd50ca4e9bf4cb5 + checksum: 4e4516fca2ebc5a316e5e845c57eda6f707a33bd74034c5e0e97c7ce534657ea79e6b7f8bd6eae7e3490d976c1ab3028bf3aeffc0c50291e87cc07cb68af579d languageName: node linkType: hard -"@radix-ui/react-direction@npm:1.1.1": - version: 1.1.1 - resolution: "@radix-ui/react-direction@npm:1.1.1" +"@radix-ui/react-direction@npm:1.1.2": + version: 1.1.2 + resolution: "@radix-ui/react-direction@npm:1.1.2" peerDependencies: "@types/react": "*" react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: "@types/react": optional: true - checksum: 8cc330285f1d06829568042ca9aabd3295be4690ae93683033fc8632b5c4dfc60f5c1312f6e2cae27c196189c719de3cfbcf792ff74800f9ccae0ab4abc1bc92 + checksum: c2c44fcecfb36dd1b4e75f523531185b4aeb4c46bc6b3512411de6f0b7414cdbf14cf6042fb571df81ae3ada722be94c1883520d7e7f12c02a17642335cfdc6a languageName: node linkType: hard -"@radix-ui/react-dismissable-layer@npm:1.1.11": - version: 1.1.11 - resolution: "@radix-ui/react-dismissable-layer@npm:1.1.11" +"@radix-ui/react-dismissable-layer@npm:1.1.14": + version: 1.1.14 + resolution: "@radix-ui/react-dismissable-layer@npm:1.1.14" dependencies: - "@radix-ui/primitive": 1.1.3 - "@radix-ui/react-compose-refs": 1.1.2 - "@radix-ui/react-primitive": 2.1.3 - "@radix-ui/react-use-callback-ref": 1.1.1 - "@radix-ui/react-use-escape-keydown": 1.1.1 + "@radix-ui/primitive": 1.1.4 + "@radix-ui/react-compose-refs": 1.1.3 + "@radix-ui/react-primitive": 2.1.7 + "@radix-ui/react-use-callback-ref": 1.1.2 + "@radix-ui/react-use-effect-event": 0.0.3 peerDependencies: "@types/react": "*" "@types/react-dom": "*" @@ -2401,30 +2453,30 @@ __metadata: optional: true "@types/react-dom": optional: true - checksum: 8fc9f027c9f68940c69c9cc117c43e1313d1a78ae4109cf809868b82837e5e2a7d410adf78e97328d9d5a080a63e399918414985658ab029a8df7d775af23b68 + checksum: 51304bec83e3680e437c1db8555be0f9f76fb943ece0a948ec4b83e340d2e2a6510f8d9e68b77d0c74f0b7e138498060c53adfc607fec1e037f7261dbe14c3d3 languageName: node linkType: hard -"@radix-ui/react-focus-guards@npm:1.1.3": - version: 1.1.3 - resolution: "@radix-ui/react-focus-guards@npm:1.1.3" +"@radix-ui/react-focus-guards@npm:1.1.4": + version: 1.1.4 + resolution: "@radix-ui/react-focus-guards@npm:1.1.4" peerDependencies: "@types/react": "*" react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: "@types/react": optional: true - checksum: b57878f6cf0ebc3e8d7c5c6bbaad44598daac19c921551ca541c104201048a9a902f3d69196e7a09995fd46e998c309aab64dc30fa184b3609d67d187a6a9c24 + checksum: da18237044a8f270fd69f5634fb0d812c0dc5e035e5e6ca9c7c4b5ab4a6f1bce746ed25bacfdf001cb37ce1b362a92b2802d354d18b7fc048d3a9474f84e577c languageName: node linkType: hard -"@radix-ui/react-focus-scope@npm:1.1.7": - version: 1.1.7 - resolution: "@radix-ui/react-focus-scope@npm:1.1.7" +"@radix-ui/react-focus-scope@npm:1.1.11": + version: 1.1.11 + resolution: "@radix-ui/react-focus-scope@npm:1.1.11" dependencies: - "@radix-ui/react-compose-refs": 1.1.2 - "@radix-ui/react-primitive": 2.1.3 - "@radix-ui/react-use-callback-ref": 1.1.1 + "@radix-ui/react-compose-refs": 1.1.3 + "@radix-ui/react-primitive": 2.1.7 + "@radix-ui/react-use-callback-ref": 1.1.2 peerDependencies: "@types/react": "*" "@types/react-dom": "*" @@ -2435,31 +2487,31 @@ __metadata: optional: true "@types/react-dom": optional: true - checksum: bb642d192d3da8431f8b39f64959b493a7ba743af8501b76699ef93357c96507c11fb76d468824b52b0e024eaee130a641f3a213268ac7c9af34883b45610c9b + checksum: 112316e6186e1245bc0eb8d8fa8372442d005478508eaafb33337b200417beeb744812e6788a997c60ce8190da14b0464bb1165444a988ea4f7beab8866a712d languageName: node linkType: hard -"@radix-ui/react-id@npm:1.1.1": - version: 1.1.1 - resolution: "@radix-ui/react-id@npm:1.1.1" +"@radix-ui/react-id@npm:1.1.2": + version: 1.1.2 + resolution: "@radix-ui/react-id@npm:1.1.2" dependencies: - "@radix-ui/react-use-layout-effect": 1.1.1 + "@radix-ui/react-use-layout-effect": 1.1.2 peerDependencies: "@types/react": "*" react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: "@types/react": optional: true - checksum: 8d68e200778eb3038906870fc869b3d881f4a46715fb20cddd9c76cba42fdaaa4810a3365b6ec2daf0f185b9201fc99d009167f59c7921bc3a139722c2e976db + checksum: d58d0cb53238cef22a880c9fa49ad5510022f0b52aa852842c5ff29abc9dd205e547b58a747682f778adceb9d6f2a548d0897bd27964f4edc903a96707b6e472 languageName: node linkType: hard -"@radix-ui/react-portal@npm:1.1.9": - version: 1.1.9 - resolution: "@radix-ui/react-portal@npm:1.1.9" +"@radix-ui/react-portal@npm:1.1.13": + version: 1.1.13 + resolution: "@radix-ui/react-portal@npm:1.1.13" dependencies: - "@radix-ui/react-primitive": 2.1.3 - "@radix-ui/react-use-layout-effect": 1.1.1 + "@radix-ui/react-primitive": 2.1.7 + "@radix-ui/react-use-layout-effect": 1.1.2 peerDependencies: "@types/react": "*" "@types/react-dom": "*" @@ -2470,16 +2522,15 @@ __metadata: optional: true "@types/react-dom": optional: true - checksum: bd6be39bf021d5c917e2474ecba411e2625171f7ef96862b9af04bbd68833bb3662a7f1fbdeb5a7a237111b10e811e76d2cd03e957dadd6e668ef16541bfbd68 + checksum: b896dd705a93c869e27280af25bade70068bf27e504ed27ee93b669a4250169f8628626010dd82502e51da148e19511cf921b42d8ebfb61e61edd0d61794afa6 languageName: node linkType: hard -"@radix-ui/react-presence@npm:1.1.5": - version: 1.1.5 - resolution: "@radix-ui/react-presence@npm:1.1.5" +"@radix-ui/react-presence@npm:1.1.6": + version: 1.1.6 + resolution: "@radix-ui/react-presence@npm:1.1.6" dependencies: - "@radix-ui/react-compose-refs": 1.1.2 - "@radix-ui/react-use-layout-effect": 1.1.1 + "@radix-ui/react-use-layout-effect": 1.1.2 peerDependencies: "@types/react": "*" "@types/react-dom": "*" @@ -2490,15 +2541,15 @@ __metadata: optional: true "@types/react-dom": optional: true - checksum: 05f1b8e80d3d878efab44304ce55d0b9e6c7050e8345f9da95d0597a716121fb2467c3247c847c51a6cb27edd00e86ac36b2635e4c00ea79d91cfc26c930da81 + checksum: a1ad63411854d7e3ef1adcffad776705e79d777ec6c2222f9d1ca31b6bbea0945adf7bfc7c71383e078c7ea439aecad35d7e14cded9273a243b5343ceb39003c languageName: node linkType: hard -"@radix-ui/react-primitive@npm:2.1.3": - version: 2.1.3 - resolution: "@radix-ui/react-primitive@npm:2.1.3" +"@radix-ui/react-primitive@npm:2.1.7": + version: 2.1.7 + resolution: "@radix-ui/react-primitive@npm:2.1.7" dependencies: - "@radix-ui/react-slot": 1.2.3 + "@radix-ui/react-slot": 1.3.0 peerDependencies: "@types/react": "*" "@types/react-dom": "*" @@ -2509,23 +2560,23 @@ __metadata: optional: true "@types/react-dom": optional: true - checksum: 01f82e4bad76b57767198762c905e5bcea04f4f52129749791e31adfcb1b36f6fdc89c73c40017d812b6e25e4ac925d837214bb280cfeaa5dc383457ce6940b0 + checksum: 59d06ef7a6a2ef81b69ca649e2aef085b0e30f31c5746d907cc13cc23ee40164f935b345b0dbb2ca68eb9c4f482b0e66cb1af615950d46bdd66ceb6aa9dcaa8f languageName: node linkType: hard -"@radix-ui/react-roving-focus@npm:1.1.11": - version: 1.1.11 - resolution: "@radix-ui/react-roving-focus@npm:1.1.11" - dependencies: - "@radix-ui/primitive": 1.1.3 - "@radix-ui/react-collection": 1.1.7 - "@radix-ui/react-compose-refs": 1.1.2 - "@radix-ui/react-context": 1.1.2 - "@radix-ui/react-direction": 1.1.1 - "@radix-ui/react-id": 1.1.1 - "@radix-ui/react-primitive": 2.1.3 - "@radix-ui/react-use-callback-ref": 1.1.1 - "@radix-ui/react-use-controllable-state": 1.2.2 +"@radix-ui/react-roving-focus@npm:1.1.14": + version: 1.1.14 + resolution: "@radix-ui/react-roving-focus@npm:1.1.14" + dependencies: + "@radix-ui/primitive": 1.1.4 + "@radix-ui/react-collection": 1.1.11 + "@radix-ui/react-compose-refs": 1.1.3 + "@radix-ui/react-context": 1.1.4 + "@radix-ui/react-direction": 1.1.2 + "@radix-ui/react-id": 1.1.2 + "@radix-ui/react-primitive": 2.1.7 + "@radix-ui/react-use-callback-ref": 1.1.2 + "@radix-ui/react-use-controllable-state": 1.2.3 peerDependencies: "@types/react": "*" "@types/react-dom": "*" @@ -2536,52 +2587,37 @@ __metadata: optional: true "@types/react-dom": optional: true - checksum: 62af05c244803359c36beea278dac89caee37d20c31b84bcba3a20c462df33b7395c2e1b08b3a8ebb471c29cec4b3fb4f97488b6a167b1b275cedf994cf436e6 - languageName: node - linkType: hard - -"@radix-ui/react-slot@npm:1.2.3": - version: 1.2.3 - resolution: "@radix-ui/react-slot@npm:1.2.3" - dependencies: - "@radix-ui/react-compose-refs": 1.1.2 - peerDependencies: - "@types/react": "*" - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - "@types/react": - optional: true - checksum: 2731089e15477dd5eef98a5757c36113dd932d0c52ff05123cd89f05f0412e95e5b205229185d1cd705cda4a674a838479cce2b3b46ed903f82f5d23d9e3f3c2 + checksum: af4ef8dbddfc34d34094233d518fc9545fceb1c01d7332d9e5434ec509b341e3a458a9edb89242fdfede607bc07490b988f2c46d07a746ff91805a6914cc00fc languageName: node linkType: hard -"@radix-ui/react-slot@npm:^1.2.0": - version: 1.2.4 - resolution: "@radix-ui/react-slot@npm:1.2.4" +"@radix-ui/react-slot@npm:1.3.0, @radix-ui/react-slot@npm:^1.2.0": + version: 1.3.0 + resolution: "@radix-ui/react-slot@npm:1.3.0" dependencies: - "@radix-ui/react-compose-refs": 1.1.2 + "@radix-ui/react-compose-refs": 1.1.3 peerDependencies: "@types/react": "*" react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: "@types/react": optional: true - checksum: 6e1cda512f649ca9df8e746a5059c69aa7539b43870ff6def360103590d68d1ea3adf8a216092107b3e6476d55a3d71707e162b5be04574126bcc6fdfffefe6a + checksum: 5a78dff8d88232468e488321b274fd578f39e03f8c2118096f48e76fc999c1ec321bbc29e6a47e76edf0caa5fa152d49d5dba206b9850cc08be910c9ff581679 languageName: node linkType: hard "@radix-ui/react-tabs@npm:^1.1.12": - version: 1.1.13 - resolution: "@radix-ui/react-tabs@npm:1.1.13" - dependencies: - "@radix-ui/primitive": 1.1.3 - "@radix-ui/react-context": 1.1.2 - "@radix-ui/react-direction": 1.1.1 - "@radix-ui/react-id": 1.1.1 - "@radix-ui/react-presence": 1.1.5 - "@radix-ui/react-primitive": 2.1.3 - "@radix-ui/react-roving-focus": 1.1.11 - "@radix-ui/react-use-controllable-state": 1.2.2 + version: 1.1.16 + resolution: "@radix-ui/react-tabs@npm:1.1.16" + dependencies: + "@radix-ui/primitive": 1.1.4 + "@radix-ui/react-context": 1.1.4 + "@radix-ui/react-direction": 1.1.2 + "@radix-ui/react-id": 1.1.2 + "@radix-ui/react-presence": 1.1.6 + "@radix-ui/react-primitive": 2.1.7 + "@radix-ui/react-roving-focus": 1.1.14 + "@radix-ui/react-use-controllable-state": 1.2.3 peerDependencies: "@types/react": "*" "@types/react-dom": "*" @@ -2592,79 +2628,64 @@ __metadata: optional: true "@types/react-dom": optional: true - checksum: 6bb8fa404d65ddb1be12cb03912abff8d924fb9b3435da71b39836585df6b55bd25341bd989324c330724af942c0f0cdf4c51503057b0532359da40c64b08081 + checksum: 29c7996ad25fef7d137070f286eda771df8bd9531faffc70d2de86ffa1ebbf4c8a2fad24891a7bbb6e28b929c3f1d6cd75791037a591e3c513d66716afe509a6 languageName: node linkType: hard -"@radix-ui/react-use-callback-ref@npm:1.1.1": - version: 1.1.1 - resolution: "@radix-ui/react-use-callback-ref@npm:1.1.1" - peerDependencies: - "@types/react": "*" - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - "@types/react": - optional: true - checksum: cde8c40f1d4e79e6e71470218163a746858304bad03758ac84dc1f94247a046478e8e397518350c8d6609c84b7e78565441d7505bb3ed573afce82cfdcd19faf - languageName: node - linkType: hard - -"@radix-ui/react-use-controllable-state@npm:1.2.2": - version: 1.2.2 - resolution: "@radix-ui/react-use-controllable-state@npm:1.2.2" - dependencies: - "@radix-ui/react-use-effect-event": 0.0.2 - "@radix-ui/react-use-layout-effect": 1.1.1 +"@radix-ui/react-use-callback-ref@npm:1.1.2": + version: 1.1.2 + resolution: "@radix-ui/react-use-callback-ref@npm:1.1.2" peerDependencies: "@types/react": "*" react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: "@types/react": optional: true - checksum: b438ee199d0630bf95eaafe8bf4bce219e73b371cfc8465f47548bfa4ee231f1134b5c6696b242890a01a0fd25fa34a7b172346bbfc5ee25cfb28b3881b1dc92 + checksum: d2a06a7e1f5e2778f49c4ae2579d6fb4d10edcaf71368364988159a54fe8ac4f384c6597541c0d37294ec5ebf43ce4613944fa8c9810f7ab5c4bb110dfc69cdf languageName: node linkType: hard -"@radix-ui/react-use-effect-event@npm:0.0.2": - version: 0.0.2 - resolution: "@radix-ui/react-use-effect-event@npm:0.0.2" +"@radix-ui/react-use-controllable-state@npm:1.2.3": + version: 1.2.3 + resolution: "@radix-ui/react-use-controllable-state@npm:1.2.3" dependencies: - "@radix-ui/react-use-layout-effect": 1.1.1 + "@radix-ui/react-use-effect-event": 0.0.3 + "@radix-ui/react-use-layout-effect": 1.1.2 peerDependencies: "@types/react": "*" react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: "@types/react": optional: true - checksum: 5a1950a30a399ea7e4b98154da9f536737a610de80189b7aacd4f064a89a3cd0d2a48571d527435227252e72e872bdb544ff6ffcfbdd02de2efd011be4aaa902 + checksum: 25d6957b7c4c29225da1ca26cd07b56517be8e655dfcef63f5928cb70f52355489257572ea777f15038e0c6de8bbbfc160e9f15e0c2a08e2d5183ad9ff0a951e languageName: node linkType: hard -"@radix-ui/react-use-escape-keydown@npm:1.1.1": - version: 1.1.1 - resolution: "@radix-ui/react-use-escape-keydown@npm:1.1.1" +"@radix-ui/react-use-effect-event@npm:0.0.3": + version: 0.0.3 + resolution: "@radix-ui/react-use-effect-event@npm:0.0.3" dependencies: - "@radix-ui/react-use-callback-ref": 1.1.1 + "@radix-ui/react-use-layout-effect": 1.1.2 peerDependencies: "@types/react": "*" react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: "@types/react": optional: true - checksum: 0eb0756c2c55ddcde9ff01446ab01c085ab2bf799173e97db7ef5f85126f9e8600225570801a1f64740e6d14c39ffe8eed7c14d29737345a5797f4622ac96f6f + checksum: 53b4e813cb68b5c603cb2ccaa5822474f846ee46a7aa5e172f29842bea135521c20d69bf12a14e5522cc385ee3724b9e3bfbf8d2d01f1933e472bd9fa80478ca languageName: node linkType: hard -"@radix-ui/react-use-layout-effect@npm:1.1.1": - version: 1.1.1 - resolution: "@radix-ui/react-use-layout-effect@npm:1.1.1" +"@radix-ui/react-use-layout-effect@npm:1.1.2": + version: 1.1.2 + resolution: "@radix-ui/react-use-layout-effect@npm:1.1.2" peerDependencies: "@types/react": "*" react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: "@types/react": optional: true - checksum: bad2ba4f206e6255263582bedfb7868773c400836f9a1b423c0b464ffe4a17e13d3f306d1ce19cf7a19a492e9d0e49747464f2656451bb7c6a99f5a57bd34de2 + checksum: 4ecffaec7a1b40634ffa90b5e0b5492980532a59c3315e2e57f91fb54736abed6dcb60acddbe5bb01a8a6b43a226e97d7581e816f061c8df619ed0f188308355 languageName: node linkType: hard @@ -3103,27 +3124,27 @@ __metadata: linkType: hard "@react-navigation/bottom-tabs@npm:^7.15.5": - version: 7.15.11 - resolution: "@react-navigation/bottom-tabs@npm:7.15.11" + version: 7.18.6 + resolution: "@react-navigation/bottom-tabs@npm:7.18.6" dependencies: - "@react-navigation/elements": ^2.9.15 + "@react-navigation/elements": ^2.9.28 color: ^4.2.3 sf-symbols-typescript: ^2.1.0 peerDependencies: - "@react-navigation/native": ^7.2.2 + "@react-navigation/native": ^7.3.6 react: ">= 18.2.0" react-native: "*" react-native-safe-area-context: ">= 4.0.0" react-native-screens: ">= 4.0.0" - checksum: 15968ae491fe76a94b98d00ca861ea29875f7d41bc3a631be6042655c746e73ecd9c4572723cdaba141e9191b06843ad212485aa290321891ca12346ccc8a25f + checksum: 89891c0b606897dd7a80a0d5f8009950a33d0bd84824cdf9e507940aaf4bb35fd9e590eb90cf13e436157a10ca7653b7ecfd73ce0330122a67b2c7b7c81f8822 languageName: node linkType: hard -"@react-navigation/core@npm:^7.17.2": - version: 7.17.2 - resolution: "@react-navigation/core@npm:7.17.2" +"@react-navigation/core@npm:^7.21.4": + version: 7.21.4 + resolution: "@react-navigation/core@npm:7.21.4" dependencies: - "@react-navigation/routers": ^7.5.3 + "@react-navigation/routers": ^7.6.0 escape-string-regexp: ^4.0.0 fast-deep-equal: ^3.1.3 nanoid: ^3.3.11 @@ -3133,90 +3154,91 @@ __metadata: use-sync-external-store: ^1.5.0 peerDependencies: react: ">= 18.2.0" - checksum: ee0641481b7e272ebe9aafa4e5985a9de497b719275a153687ecd8cf80b30dce03e53270d9d5606f6321fea801a7030578f0bc731e569f421cfbb76065e8b8e1 + checksum: 0c8b1f0b27c8632358f2bccbad30ecbec48e988dce8d4dd671c3943581537f13a7fe34e46d916d9f53d913f433569f5d44fdf9e596a2cd5698a9c1910837e52b languageName: node linkType: hard "@react-navigation/drawer@npm:^7.9.8": - version: 7.9.9 - resolution: "@react-navigation/drawer@npm:7.9.9" + version: 7.12.5 + resolution: "@react-navigation/drawer@npm:7.12.5" dependencies: - "@react-navigation/elements": ^2.9.15 + "@react-navigation/elements": ^2.9.28 color: ^4.2.3 - react-native-drawer-layout: ^4.2.2 + react-native-drawer-layout: ^4.2.5 use-latest-callback: ^0.2.4 peerDependencies: - "@react-navigation/native": ^7.2.2 + "@react-navigation/native": ^7.3.6 react: ">= 18.2.0" react-native: "*" react-native-gesture-handler: ">= 2.0.0" react-native-reanimated: ">= 2.0.0" react-native-safe-area-context: ">= 4.0.0" react-native-screens: ">= 4.0.0" - checksum: 4f05c555b19056153df8ebcdc99d6ae8c4f2fe584f01fe811da95d3259f91b46d920401fc883548555bc668ffab7a7e8b89a58f5c9fc0916447f46c5f05641fc + checksum: a5b46c432739a7b198b921485f0bfcf12cc3d29ad80f8f653acc2a7eb9bb2236eaeceb3e259f3c838b94df91e1976a75901ec55f4179923379cf71983913e916 languageName: node linkType: hard -"@react-navigation/elements@npm:^2.9.15": - version: 2.9.15 - resolution: "@react-navigation/elements@npm:2.9.15" +"@react-navigation/elements@npm:^2.9.28": + version: 2.9.28 + resolution: "@react-navigation/elements@npm:2.9.28" dependencies: color: ^4.2.3 use-latest-callback: ^0.2.4 use-sync-external-store: ^1.5.0 peerDependencies: "@react-native-masked-view/masked-view": ">= 0.2.0" - "@react-navigation/native": ^7.2.2 + "@react-navigation/native": ^7.3.6 react: ">= 18.2.0" react-native: "*" react-native-safe-area-context: ">= 4.0.0" peerDependenciesMeta: "@react-native-masked-view/masked-view": optional: true - checksum: fa00ffee3fef3e8f15181fa8c87383b9826aee0b4e63ddf6d7c56e73cf2439af0aca7c5eea2dbb32af35ad82ae94ebae1d60b73ef719fa7945d85f611e7f229f + checksum: 0a1a1b7f435910fec621e9f618cc773f7f16a95a2e882ac307d92cb2410f7af31d235442ca42ba0cea540e1ebc47744ea9b10d091d259bfb3f7f8dafdb9a26a1 languageName: node linkType: hard "@react-navigation/native-stack@npm:^7.14.5": - version: 7.14.12 - resolution: "@react-navigation/native-stack@npm:7.14.12" + version: 7.17.8 + resolution: "@react-navigation/native-stack@npm:7.17.8" dependencies: - "@react-navigation/elements": ^2.9.15 + "@react-navigation/elements": ^2.9.28 color: ^4.2.3 sf-symbols-typescript: ^2.1.0 warn-once: ^0.1.1 peerDependencies: - "@react-navigation/native": ^7.2.2 + "@react-navigation/native": ^7.3.6 react: ">= 18.2.0" react-native: "*" react-native-safe-area-context: ">= 4.0.0" react-native-screens: ">= 4.0.0" - checksum: 749b3f01f3e947706e724ba28f4cc36499b10da24588032ef3cb9e8066a26e5b7a41b2c97fb80bd201bf26a6df3dd5bd20225017bbb1122d8d856af97ffcffd3 + checksum: ba2fc1f0cc02e781f92718883005a2d5f480fdabaf134b3a9b9670e28575b261e91c8bcbfdc350dec06d38d9fc3e1d6f1902f37b717c8b8a25d8b864457eb6f8 languageName: node linkType: hard "@react-navigation/native@npm:^7.1.33": - version: 7.2.2 - resolution: "@react-navigation/native@npm:7.2.2" + version: 7.3.6 + resolution: "@react-navigation/native@npm:7.3.6" dependencies: - "@react-navigation/core": ^7.17.2 + "@react-navigation/core": ^7.21.4 escape-string-regexp: ^4.0.0 fast-deep-equal: ^3.1.3 nanoid: ^3.3.11 + standard-navigation: ^0.0.7 use-latest-callback: ^0.2.4 peerDependencies: react: ">= 18.2.0" react-native: "*" - checksum: a7be7b67bbfb18f04f009b64dcfe432690b56dbbe3c03c3ecfb874b8ba6aaebebc312075e5c57eb9d1aa239066e55a3dba4e3650ef2cea20c1550a712a3c2f7b + checksum: 7d73dfc9bbeea6fe04357f8b1ac43ec91393e0b250141bdeb6e50d20dd6cb08f8ddabaf83a6abbac5dc96a1f164aea4a6b01b5ec404df5b85367cc896b00b15f languageName: node linkType: hard -"@react-navigation/routers@npm:^7.5.3": - version: 7.5.3 - resolution: "@react-navigation/routers@npm:7.5.3" +"@react-navigation/routers@npm:^7.6.0": + version: 7.6.0 + resolution: "@react-navigation/routers@npm:7.6.0" dependencies: nanoid: ^3.3.11 - checksum: 1b8397ade6bbab51a60d2671fd88eca2e0cf22b9cd10bee16d3537bc5f05deea7dad8c116a809f580c87c5a6cceae7c4fc9f20644f45076ee8f00524e903fc4b + checksum: 8e2f29cf88d609db888c0888b9c8171e319aad6fc9dbe53cf77e417dc932b808333612c67db3c62775a2152c0d4b296d9db30428ac414b674dcfd474c9d3368f languageName: node linkType: hard @@ -3478,9 +3500,9 @@ __metadata: linkType: hard "@types/estree@npm:^1.0.6": - version: 1.0.8 - resolution: "@types/estree@npm:1.0.8" - checksum: bd93e2e415b6f182ec4da1074e1f36c480f1d26add3e696d54fb30c09bc470897e41361c8fd957bf0985024f8fbf1e6e2aff977d79352ef7eb93a5c6dcff6c11 + version: 1.0.9 + resolution: "@types/estree@npm:1.0.9" + checksum: 752c0afee3ec82b8e24484bf6a27dfa093bbf3de4ef1c20ed0364fb6ad2c0c7971e7504ed9a7aaff103a47e2d945ce7a17f74951743dd944782a0735f53170de languageName: node linkType: hard @@ -3554,20 +3576,20 @@ __metadata: linkType: hard "@types/node@npm:*": - version: 25.6.0 - resolution: "@types/node@npm:25.6.0" + version: 26.1.0 + resolution: "@types/node@npm:26.1.0" dependencies: - undici-types: ~7.19.0 - checksum: 98945eb59909a08868ccac203022f122b5549448ef8628de9eac3fe20481467cd6ec32af819fd432695f67ac21ebbbc69c8a141de6c6455edaf6e717e2cb89c9 + undici-types: ~8.3.0 + checksum: bbced3378635cb48e739e384e53ff4c7d3e18194a2e003fb5240fc4f6c18fdc9f2452539a9f6fa608ca7b510a40f844d483c23f5d8bd2cf2c245b16d43ce4236 languageName: node linkType: hard "@types/react@npm:~19.2.10": - version: 19.2.14 - resolution: "@types/react@npm:19.2.14" + version: 19.2.17 + resolution: "@types/react@npm:19.2.17" dependencies: csstype: ^3.2.2 - checksum: ddd330292abf2dc2cfa65188e1c5f67cc6e90f8d8ffb088f753a38db9d123f942c23d324a6b7e8027ff04f22b395492150f54b9b520b6cbec1e8841e669f2c19 + checksum: 9704dc2001b6bcc32efc6e3fe144e18c411d5ee8a5c8dfe572535e44bd300424c4e7bddc9314299eeec28efb547816368b5c9851c93a3082e8ad1265786f3d31 languageName: node linkType: hard @@ -3615,105 +3637,105 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/eslint-plugin@npm:8.59.2, @typescript-eslint/eslint-plugin@npm:^8.36.0": - version: 8.59.2 - resolution: "@typescript-eslint/eslint-plugin@npm:8.59.2" +"@typescript-eslint/eslint-plugin@npm:8.62.1, @typescript-eslint/eslint-plugin@npm:^8.36.0": + version: 8.62.1 + resolution: "@typescript-eslint/eslint-plugin@npm:8.62.1" dependencies: "@eslint-community/regexpp": ^4.12.2 - "@typescript-eslint/scope-manager": 8.59.2 - "@typescript-eslint/type-utils": 8.59.2 - "@typescript-eslint/utils": 8.59.2 - "@typescript-eslint/visitor-keys": 8.59.2 + "@typescript-eslint/scope-manager": 8.62.1 + "@typescript-eslint/type-utils": 8.62.1 + "@typescript-eslint/utils": 8.62.1 + "@typescript-eslint/visitor-keys": 8.62.1 ignore: ^7.0.5 natural-compare: ^1.4.0 ts-api-utils: ^2.5.0 peerDependencies: - "@typescript-eslint/parser": ^8.59.2 + "@typescript-eslint/parser": ^8.62.1 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: ">=4.8.4 <6.1.0" - checksum: c18238066dc1c2641bf463be4f21bcf3f03c97557f5b147bf5306293b7fc498890b79ee99aab518c21acd1c5dc1d44485a30dc6a351176304213b1a6110889b0 + checksum: c40f293bd5040ddac92bf5c8fcb0e2a18ab0eb87fec759c7b3a5f47f2957667632313b59746f37cb4b6a00734da05181e85901b491ef86735d8a433210901027 languageName: node linkType: hard -"@typescript-eslint/parser@npm:8.59.2, @typescript-eslint/parser@npm:^8.36.0": - version: 8.59.2 - resolution: "@typescript-eslint/parser@npm:8.59.2" +"@typescript-eslint/parser@npm:8.62.1, @typescript-eslint/parser@npm:^8.36.0": + version: 8.62.1 + resolution: "@typescript-eslint/parser@npm:8.62.1" dependencies: - "@typescript-eslint/scope-manager": 8.59.2 - "@typescript-eslint/types": 8.59.2 - "@typescript-eslint/typescript-estree": 8.59.2 - "@typescript-eslint/visitor-keys": 8.59.2 + "@typescript-eslint/scope-manager": 8.62.1 + "@typescript-eslint/types": 8.62.1 + "@typescript-eslint/typescript-estree": 8.62.1 + "@typescript-eslint/visitor-keys": 8.62.1 debug: ^4.4.3 peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: ">=4.8.4 <6.1.0" - checksum: 03a784319dff60a51514bf786c20a41b3b2118d3d68aef06e5f1fddcb15da745a6dd30ed7066a505f270ef810f21bde391930c3efae6a62ed3d8901b868e42fe + checksum: 5c11f014c5c2ae525e8412babf189e4bdbe0b818440ed899df3e277fba6f36b14600a212f6fddbaecafbb4723725504e267379b2dbac69a0036b4242b314a2e2 languageName: node linkType: hard -"@typescript-eslint/project-service@npm:8.59.2": - version: 8.59.2 - resolution: "@typescript-eslint/project-service@npm:8.59.2" +"@typescript-eslint/project-service@npm:8.62.1": + version: 8.62.1 + resolution: "@typescript-eslint/project-service@npm:8.62.1" dependencies: - "@typescript-eslint/tsconfig-utils": ^8.59.2 - "@typescript-eslint/types": ^8.59.2 + "@typescript-eslint/tsconfig-utils": ^8.62.1 + "@typescript-eslint/types": ^8.62.1 debug: ^4.4.3 peerDependencies: typescript: ">=4.8.4 <6.1.0" - checksum: 2c4d1cc23ef90f17f1054905d936f9471b8d93e8277ba2e33f08f7106b765dff03922432c9878af262ac62d049b28e2d611a740a1a9102f161af566a954758d1 + checksum: 65ac343a55832b693988a77843e0bfce3a44be57f28bae50c7b6cd76c807a96af3df53a035735f6f05a1aef50ffa386ede90f9d64863b7732539937f48898b74 languageName: node linkType: hard -"@typescript-eslint/scope-manager@npm:8.59.2": - version: 8.59.2 - resolution: "@typescript-eslint/scope-manager@npm:8.59.2" +"@typescript-eslint/scope-manager@npm:8.62.1": + version: 8.62.1 + resolution: "@typescript-eslint/scope-manager@npm:8.62.1" dependencies: - "@typescript-eslint/types": 8.59.2 - "@typescript-eslint/visitor-keys": 8.59.2 - checksum: 7a55a62a194dda2f91cf182d393c85700e92cb4f9b381b8ed3af06578b606462daa1ce92356e8ced581f0d55afe3e81c743844bc1f99df05846eefa7ec9ced4f + "@typescript-eslint/types": 8.62.1 + "@typescript-eslint/visitor-keys": 8.62.1 + checksum: c11359d59d84ce4a13563a06bde517bdb77fc792042726ecb69e500e4d6f730feb15799662d472f8d5d5cb85c5fb59114a6617e11a6e8873e117d1f9df5007a6 languageName: node linkType: hard -"@typescript-eslint/tsconfig-utils@npm:8.59.2, @typescript-eslint/tsconfig-utils@npm:^8.59.2": - version: 8.59.2 - resolution: "@typescript-eslint/tsconfig-utils@npm:8.59.2" +"@typescript-eslint/tsconfig-utils@npm:8.62.1, @typescript-eslint/tsconfig-utils@npm:^8.62.1": + version: 8.62.1 + resolution: "@typescript-eslint/tsconfig-utils@npm:8.62.1" peerDependencies: typescript: ">=4.8.4 <6.1.0" - checksum: 7bfbe041ce29db2bc08a6895ba2bbbba37259710978987999a401ea072939fb96f2c11b5a9c5ab3089dbbbbd9089f2b3d2f07f4b7d056f741e8db05f9351305a + checksum: d42585127930631284eed4ba136a1a17fb4f298cf2476748c4221ed63dc1bb45113561167b5fed232bfd38b9e7a7a4e2d2c1de23e1f5e9ffd5a9d4e15fb3b3ce languageName: node linkType: hard -"@typescript-eslint/type-utils@npm:8.59.2": - version: 8.59.2 - resolution: "@typescript-eslint/type-utils@npm:8.59.2" +"@typescript-eslint/type-utils@npm:8.62.1": + version: 8.62.1 + resolution: "@typescript-eslint/type-utils@npm:8.62.1" dependencies: - "@typescript-eslint/types": 8.59.2 - "@typescript-eslint/typescript-estree": 8.59.2 - "@typescript-eslint/utils": 8.59.2 + "@typescript-eslint/types": 8.62.1 + "@typescript-eslint/typescript-estree": 8.62.1 + "@typescript-eslint/utils": 8.62.1 debug: ^4.4.3 ts-api-utils: ^2.5.0 peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: ">=4.8.4 <6.1.0" - checksum: 01361634da6cb8b0f3dac39c06c79d58b806c4c3d838456a7b8efc714473461239dd7e45da485092c3f5dd01c559aba80926cb85d2dd0333ab80200e7af8f527 + checksum: 0ded0807fb9f7be448849da8795c531aabce53109572e7f35526d832f770756969af39cc53e47e51057ab322dd06f4e7764cb1da353a64577f05b3027cf8270b languageName: node linkType: hard -"@typescript-eslint/types@npm:8.59.2, @typescript-eslint/types@npm:^8.59.2": - version: 8.59.2 - resolution: "@typescript-eslint/types@npm:8.59.2" - checksum: eb0fa7423293ccfea4b8649258cb30f3911b1d33cb584d6f6897067cf90ac7c4e66533a6f7a922435646b0d5d7f063ab3d3cf164aec95b9ad07d27c069a244e8 +"@typescript-eslint/types@npm:8.62.1, @typescript-eslint/types@npm:^8.62.1": + version: 8.62.1 + resolution: "@typescript-eslint/types@npm:8.62.1" + checksum: a0e7efd615068c3aaa5bd57334e7fb0a04783560c7dbcbd134e45e07968f14e8d2ccc02566be3f0759e854ddfbfcf817061beab360610f0d7f7943fbedf96640 languageName: node linkType: hard -"@typescript-eslint/typescript-estree@npm:8.59.2": - version: 8.59.2 - resolution: "@typescript-eslint/typescript-estree@npm:8.59.2" +"@typescript-eslint/typescript-estree@npm:8.62.1": + version: 8.62.1 + resolution: "@typescript-eslint/typescript-estree@npm:8.62.1" dependencies: - "@typescript-eslint/project-service": 8.59.2 - "@typescript-eslint/tsconfig-utils": 8.59.2 - "@typescript-eslint/types": 8.59.2 - "@typescript-eslint/visitor-keys": 8.59.2 + "@typescript-eslint/project-service": 8.62.1 + "@typescript-eslint/tsconfig-utils": 8.62.1 + "@typescript-eslint/types": 8.62.1 + "@typescript-eslint/visitor-keys": 8.62.1 debug: ^4.4.3 minimatch: ^10.2.2 semver: ^7.7.3 @@ -3721,39 +3743,39 @@ __metadata: ts-api-utils: ^2.5.0 peerDependencies: typescript: ">=4.8.4 <6.1.0" - checksum: 58639a5013c6622cae960631ca7e41ca8ff44081d3568ff5e7836f3487d63edf086b9b88d92a27de369e2f20876a14597bc17179a64b9be340a7042b744bff23 + checksum: 41e06bac1fc61d5c1dbf1f657a11fd3ee43cd0ed5d62999a9a5cae54b4eff0fbecd3391e88e7f26f72e9a8d63d5bc420dfb505b196f6685a655d7a8f89fe227e languageName: node linkType: hard -"@typescript-eslint/utils@npm:8.59.2, @typescript-eslint/utils@npm:^8.0.0": - version: 8.59.2 - resolution: "@typescript-eslint/utils@npm:8.59.2" +"@typescript-eslint/utils@npm:8.62.1, @typescript-eslint/utils@npm:^8.0.0": + version: 8.62.1 + resolution: "@typescript-eslint/utils@npm:8.62.1" dependencies: "@eslint-community/eslint-utils": ^4.9.1 - "@typescript-eslint/scope-manager": 8.59.2 - "@typescript-eslint/types": 8.59.2 - "@typescript-eslint/typescript-estree": 8.59.2 + "@typescript-eslint/scope-manager": 8.62.1 + "@typescript-eslint/types": 8.62.1 + "@typescript-eslint/typescript-estree": 8.62.1 peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: ">=4.8.4 <6.1.0" - checksum: 2abd76745be94881b0636bb77bf0fe111bcf1f1fffedf822a67f41bd609a466d8c9cb7f57b553e212721eab123229c26b3ad0e603cbe771f1ddaefde08b64f6e + checksum: 1d68ecf102c9235086e97e4a00b2d090e199f94c71b44b7f69b8d62afcf0cdaffb735df47934df0fe64689c80b772772d0dae956b52484922b27d8dcc4cc9c95 languageName: node linkType: hard -"@typescript-eslint/visitor-keys@npm:8.59.2": - version: 8.59.2 - resolution: "@typescript-eslint/visitor-keys@npm:8.59.2" +"@typescript-eslint/visitor-keys@npm:8.62.1": + version: 8.62.1 + resolution: "@typescript-eslint/visitor-keys@npm:8.62.1" dependencies: - "@typescript-eslint/types": 8.59.2 + "@typescript-eslint/types": 8.62.1 eslint-visitor-keys: ^5.0.0 - checksum: c0fefa9649c932cf364c67ca0077852229b41e79999bae2e869021621d5ab4dadc3d79bc20a64ed470e693876e11bca38e084e2c91d9b00c0980329c871e6a58 + checksum: 231766bad81adb5e785b8ab7a1a4c643d37be9388160eb3ac4ef73d89ef60de03ec252effcaa1f3050a6c53080298d6ca040095df05d3e229d58c162a521d910 languageName: node linkType: hard "@ungap/structured-clone@npm:^1.3.0": - version: 1.3.0 - resolution: "@ungap/structured-clone@npm:1.3.0" - checksum: 64ed518f49c2b31f5b50f8570a1e37bde3b62f2460042c50f132430b2d869c4a6586f13aa33a58a4722715b8158c68cae2827389d6752ac54da2893c83e480fc + version: 1.3.2 + resolution: "@ungap/structured-clone@npm:1.3.2" + checksum: 5502f0ee4bcc839e9cc1ef146b7db8d605fae32925c6e121beaf568bec0affff2db4bea618167891f450527412efc1adf081e6a6935b9257ef75ccc61dfce2e7 languageName: node linkType: hard @@ -3778,10 +3800,10 @@ __metadata: languageName: node linkType: hard -"abbrev@npm:^4.0.0": - version: 4.0.0 - resolution: "abbrev@npm:4.0.0" - checksum: d0344b63d28e763f259b4898c41bdc92c08e9d06d0da5617d0bbe4d78244e46daea88c510a2f9472af59b031d9060ec1a999653144e793fd029a59dae2f56dc8 +"abbrev@npm:^5.0.0": + version: 5.0.0 + resolution: "abbrev@npm:5.0.0" + checksum: 40526a57545197f1ee0a5cb369508acbd33dbb8a19967850d6d60d9bb392d4034fc56d2572ad863b6a8da810f32eb5553cd05d5af265ea829e168697d9963cde languageName: node linkType: hard @@ -3843,11 +3865,11 @@ __metadata: linkType: hard "acorn@npm:^8.1.0, acorn@npm:^8.11.0, acorn@npm:^8.15.0, acorn@npm:^8.8.1": - version: 8.16.0 - resolution: "acorn@npm:8.16.0" + version: 8.17.0 + resolution: "acorn@npm:8.17.0" bin: acorn: bin/acorn - checksum: bbfa466cd0dbd18b4460a85e9d0fc2f35db999380892403c573261beda91f23836db2aa71fd3ae65e94424ad14ff8e2b7bd37c7a2624278fd89137cd6e448c41 + checksum: 98f0800a48a06e3427cf77a10a0ef7b86366d4ba2fdcde7f72e30430d75e3179d3cbcb253bec263f002e2ea537cbb017c007689370deb4c4ffee74b388d66612 languageName: node linkType: hard @@ -4315,9 +4337,9 @@ __metadata: languageName: node linkType: hard -"babel-preset-expo@npm:~55.0.20": - version: 55.0.20 - resolution: "babel-preset-expo@npm:55.0.20" +"babel-preset-expo@npm:~55.0.23": + version: 55.0.23 + resolution: "babel-preset-expo@npm:55.0.23" dependencies: "@babel/generator": ^7.20.5 "@babel/helper-module-imports": ^7.25.9 @@ -4345,7 +4367,7 @@ __metadata: peerDependencies: "@babel/runtime": ^7.20.0 expo: "*" - expo-widgets: ^55.0.16 + expo-widgets: ^55.0.20 react-refresh: ">=0.14.0 <1.0.0" peerDependenciesMeta: "@babel/runtime": @@ -4354,7 +4376,7 @@ __metadata: optional: true expo-widgets: optional: true - checksum: 35c8b24f594836055acf5896bc66de3b3fc61a94cb45463002364daf06a534f23e894e7369038e43212009826253165ae6e5da3856e6289974916bf3a8bd842a + checksum: 24591065db25ac17aa09ebdc13d16f519079e0dc7437abdb245362ed6d7c4e0e9ed3ea6ffe3fdf94e4b2bbd7fe51f0c800df07150b2641e91670655f8ba1cff8 languageName: node linkType: hard @@ -4391,12 +4413,12 @@ __metadata: languageName: node linkType: hard -"baseline-browser-mapping@npm:^2.10.12": - version: 2.10.27 - resolution: "baseline-browser-mapping@npm:2.10.27" +"baseline-browser-mapping@npm:^2.10.38": + version: 2.10.41 + resolution: "baseline-browser-mapping@npm:2.10.41" bin: baseline-browser-mapping: dist/cli.cjs - checksum: 7b89ecfcf34132b11a8f07249e21ede07363f02136f61b9750d953da22e376002a0a8f01163dfeb57ec7cee9eb32569f24113659f692b194125d89c64ea8156d + checksum: 62a73067953b1b72d1a4b8a7e54bffa542471390c1ea04cc1427b61e6420c204e8ec2ad6129ac264b36eaba67bd7f98fd5ec53e7cf60fbbe6f000ea22fc6dc98 languageName: node linkType: hard @@ -4451,21 +4473,21 @@ __metadata: linkType: hard "brace-expansion@npm:^1.1.7": - version: 1.1.14 - resolution: "brace-expansion@npm:1.1.14" + version: 1.1.15 + resolution: "brace-expansion@npm:1.1.15" dependencies: balanced-match: ^1.0.0 concat-map: 0.0.1 - checksum: 2de747a5891ea0d3a1946ea1ae26e056a47f7ea8d42a3009e1736ec3a31a5aa69a3c5da59d998426773553afe4c258e5b12d7953b534fa7f2cf12ce92eed4931 + checksum: f2a950034e670523cc186da61aabe3beab74b1b8a7c74a756bf6b172dad1917312f255d9ec46906c9f0cab530868095d8c143918576930dd0e1323c3803850f1 languageName: node linkType: hard "brace-expansion@npm:^5.0.5": - version: 5.0.5 - resolution: "brace-expansion@npm:5.0.5" + version: 5.0.7 + resolution: "brace-expansion@npm:5.0.7" dependencies: balanced-match: ^4.0.2 - checksum: 4481b7ffa467b34c14e258167dbd8d9485a2d31d03060e8e8b38142dcde32cdc89c8f55b04d3ae7aae9304fa7eac1dfafd602787cf09c019cc45de3bb6950ffc + checksum: 5739c92d984dfb4b8460e46e52e2a75baa7364261f700f739e0925e9ce7414776d5eb0b10eb19bb10427c444c4114875e1ff1e829fe6a6c3fc490ca4294eb3a0 languageName: node linkType: hard @@ -4479,17 +4501,17 @@ __metadata: linkType: hard "browserslist@npm:^4.24.0, browserslist@npm:^4.25.0, browserslist@npm:^4.28.1": - version: 4.28.2 - resolution: "browserslist@npm:4.28.2" + version: 4.28.4 + resolution: "browserslist@npm:4.28.4" dependencies: - baseline-browser-mapping: ^2.10.12 - caniuse-lite: ^1.0.30001782 - electron-to-chromium: ^1.5.328 - node-releases: ^2.0.36 + baseline-browser-mapping: ^2.10.38 + caniuse-lite: ^1.0.30001799 + electron-to-chromium: ^1.5.376 + node-releases: ^2.0.48 update-browserslist-db: ^1.2.3 bin: browserslist: cli.js - checksum: 702cdd3462b5eb6f8a9bb3bf7bdc6d6a4141ced6935bb44edb7f3d40edd66198775f2b4a9178682535391293e04e625ba2b5943546d692f42ea080323cecb25e + checksum: 5e703da6436325b6cc68c59ea4d84dd04b939b08b5e86e88a8885933a02cf8b9e83e8115ad06a13625ae10e88fa67efb351be5ae04be934e8f4b6bfa6532af2e languageName: node linkType: hard @@ -4604,10 +4626,10 @@ __metadata: languageName: node linkType: hard -"caniuse-lite@npm:^1.0.30001782": - version: 1.0.30001791 - resolution: "caniuse-lite@npm:1.0.30001791" - checksum: 9b2f55d51b85abbb270a0d58c28b8e799ccb8fd5ef017179db3d328c8fead4f1738d95a354e75169af31c0424ffb1b691533722522ff280d4aab05d0fe4eddbd +"caniuse-lite@npm:^1.0.30001799": + version: 1.0.30001800 + resolution: "caniuse-lite@npm:1.0.30001800" + checksum: 347e1394aa05a29f06bfcddc27fc90abdece41fc7be56a2c0ff88ef6b53811ac67d27ae58634893a1f15f4de8a020b29e637ae74c627b7b0e12deb345214b9b4 languageName: node linkType: hard @@ -4944,11 +4966,11 @@ __metadata: linkType: hard "console-table-printer@npm:^2.12.1": - version: 2.15.0 - resolution: "console-table-printer@npm:2.15.0" + version: 2.16.1 + resolution: "console-table-printer@npm:2.16.1" dependencies: simple-wcswidth: ^1.1.2 - checksum: a878e446303eabaa86a2fd7f0d956d24252e0837be23249e7ad24daf3f304ce7bf155ca79b25ccd4f380f31fa35e427adc762e49ff29236f82a96c4b1d88551e + checksum: b953f1a73ebe0162b289567d217aaa4f4075ae73440fb782aa2da17a4f0a83c2f44ccc694e553aaf6f10ebdbcc85f7b851e4e708fd5177cba54da2d4c8aa6ac7 languageName: node linkType: hard @@ -5322,10 +5344,10 @@ __metadata: languageName: node linkType: hard -"dnssd-advertise@npm:^1.1.4": - version: 1.1.4 - resolution: "dnssd-advertise@npm:1.1.4" - checksum: f660bdc0c2716a3ff63c31da2fe64376188f113c763985f791298e70d6f557c9bba9e04bda231df6c0ba40e1a49903b95231e45f717e6b521f9c810bcc333cc0 +"dnssd-advertise@npm:^1.1.6": + version: 1.1.6 + resolution: "dnssd-advertise@npm:1.1.6" + checksum: 5bed966305f6e490029a70840deeed9d5a9d364df747f9f8895ccb9e661bf75de2efdc33d6e2c64f4c41b76605ccdf1d47cf2a6ae384ba7811cd7c2bee6065c9 languageName: node linkType: hard @@ -5413,10 +5435,10 @@ __metadata: languageName: node linkType: hard -"electron-to-chromium@npm:^1.5.328": - version: 1.5.349 - resolution: "electron-to-chromium@npm:1.5.349" - checksum: b2e5f52c63789514ab29d27f830bf7f8a4c44cd58e652e201d48531eb49528a3acaa2809e6dfda913eb0b8c314afaa53ac382b970f599d254ba62802cdcc4080 +"electron-to-chromium@npm:^1.5.376": + version: 1.5.385 + resolution: "electron-to-chromium@npm:1.5.385" + checksum: 34c3f97168401fd396b3a281c961c1b14e78b71598d9d8d0732e5b69526842ee4ff332fa67498f92704056f87aa291d07c23b522563fb288b352f6ff2eb56211 languageName: node linkType: hard @@ -5501,6 +5523,18 @@ __metadata: languageName: node linkType: hard +"es-abstract-get@npm:^1.0.0": + version: 1.0.0 + resolution: "es-abstract-get@npm:1.0.0" + dependencies: + es-errors: ^1.3.0 + es-object-atoms: ^1.1.2 + is-callable: ^1.2.7 + object-inspect: ^1.13.4 + checksum: 625bb67d41ebc22e7585875a6ebb90dc9c153998c9866dc7d50ff7b1b9e178e216c0d23914e5dbfd0addb38aab344c142ad27ff4375c6d2acf095432a4d85fe3 + languageName: node + linkType: hard + "es-abstract@npm:^1.17.5, es-abstract@npm:^1.23.2, es-abstract@npm:^1.23.3, es-abstract@npm:^1.23.5, es-abstract@npm:^1.23.6, es-abstract@npm:^1.23.9, es-abstract@npm:^1.24.0, es-abstract@npm:^1.24.2": version: 1.24.2 resolution: "es-abstract@npm:1.24.2" @@ -5578,8 +5612,8 @@ __metadata: linkType: hard "es-iterator-helpers@npm:^1.2.1": - version: 1.3.2 - resolution: "es-iterator-helpers@npm:1.3.2" + version: 1.3.3 + resolution: "es-iterator-helpers@npm:1.3.3" dependencies: call-bind: ^1.0.9 call-bound: ^1.0.4 @@ -5597,16 +5631,16 @@ __metadata: internal-slot: ^1.1.0 iterator.prototype: ^1.1.5 math-intrinsics: ^1.1.0 - checksum: 3e7f4323af19ac11558e36f2a6fa8f6856d6eab09daf12dc43347695976028ac36561de68767d38b093c29255cb81da3e0d5e53d70302a2a543810999e154b3c + checksum: 0429329d6a77850c4b9b4cd546a7c4e3e49888d6f5ee63255e8516726e56394b1ba7e86aca9dd64523867b0dd744d6a70fdf4cd93be28e9a56d9bb4dfb1fcba2 languageName: node linkType: hard -"es-object-atoms@npm:^1.0.0, es-object-atoms@npm:^1.1.1": - version: 1.1.1 - resolution: "es-object-atoms@npm:1.1.1" +"es-object-atoms@npm:^1.0.0, es-object-atoms@npm:^1.1.1, es-object-atoms@npm:^1.1.2": + version: 1.1.2 + resolution: "es-object-atoms@npm:1.1.2" dependencies: es-errors: ^1.3.0 - checksum: 214d3767287b12f36d3d7267ef342bbbe1e89f899cfd67040309fc65032372a8e60201410a99a1645f2f90c1912c8c49c8668066f6bdd954bcd614dda2e3da97 + checksum: b821f39e4f48bd85b13fee80aea9068a674bcb78b95ff01267aa4da1111f83e6944049b3329b2ffdb3acaa266fb5b8b885476fe8cada05034dc7c87c8016c86d languageName: node linkType: hard @@ -5632,13 +5666,16 @@ __metadata: linkType: hard "es-to-primitive@npm:^1.3.0": - version: 1.3.0 - resolution: "es-to-primitive@npm:1.3.0" + version: 1.3.4 + resolution: "es-to-primitive@npm:1.3.4" dependencies: + es-abstract-get: ^1.0.0 + es-define-property: ^1.0.1 + es-errors: ^1.3.0 is-callable: ^1.2.7 - is-date-object: ^1.0.5 - is-symbol: ^1.0.4 - checksum: 966965880356486cd4d1fe9a523deda2084c81b3702d951212c098f5f2ee93605d1b7c1840062efb48a07d892641c7ed1bc194db563645c0dd2b919cb6d65b93 + is-date-object: ^1.1.0 + is-symbol: ^1.1.1 + checksum: b152ec48ee2f962760751c1c181ef09d2c4483af50fbdd08c0d41004d71014c90ad4339b14d0328a14209d9ee638d13b4190aadc066f19466071cf2af5785d05 languageName: node linkType: hard @@ -5743,8 +5780,8 @@ __metadata: linkType: hard "eslint-plugin-jest@npm:^29.0.1": - version: 29.15.2 - resolution: "eslint-plugin-jest@npm:29.15.2" + version: 29.15.4 + resolution: "eslint-plugin-jest@npm:29.15.4" dependencies: "@typescript-eslint/utils": ^8.0.0 peerDependencies: @@ -5759,16 +5796,16 @@ __metadata: optional: true typescript: optional: true - checksum: a19b13afeb90329860a196f1debb35c696723b3e7c1e308b21c5260cbea94c961e885fbb936d29506f41e644e2d386450089e5caef466c9b51f33ff625d72396 + checksum: 9bfd198df80932230b261b7cbce7d74ade5c71bc234a88ef4c14b46b5d65574047c9ab055c32f2d6b82e33539b03df70f5737630421f70b97062f582ca8ff168 languageName: node linkType: hard "eslint-plugin-prettier@npm:^5.5.5": - version: 5.5.5 - resolution: "eslint-plugin-prettier@npm:5.5.5" + version: 5.5.6 + resolution: "eslint-plugin-prettier@npm:5.5.6" dependencies: prettier-linter-helpers: ^1.0.1 - synckit: ^0.11.12 + synckit: ^0.11.13 peerDependencies: "@types/eslint": ">=8.0.0" eslint: ">=8.0.0" @@ -5779,7 +5816,7 @@ __metadata: optional: true eslint-config-prettier: optional: true - checksum: 49b1c25d75ded255a8707d5f06288ae86e8ab4f8e273d4aabdabf73cd0903848916d5a3598ba8be82f2c8dd06769c5e6c172503b3b9cfb2636b6fc23b9c024fb + checksum: 9e2d94d6631af110966733d19aea15699ef684b543de23d1812423b35a02203ac87ef2d8784f236950ecc4026828e98f00e7c2b41879ee9c974a0a0563f1c754 languageName: node linkType: hard @@ -6078,108 +6115,108 @@ __metadata: linkType: hard "expo-application@npm:~55.0.13": - version: 55.0.14 - resolution: "expo-application@npm:55.0.14" + version: 55.0.16 + resolution: "expo-application@npm:55.0.16" peerDependencies: expo: "*" - checksum: ba4f4dc3f35edeaf142ea676875c3bf179bc4755f1a14c8a43022c080978b04125616d5c7ccb07ddb77ef0590fd16b534abbd420458c3ec1c033d171c5e5744a + checksum: b9893f38c5fa412b00f9bd0942f86950e84b300f72528d2e1f872f57c5b32c44103dff872b021d704108fe33bd55c87a69b153dc97081c4b7145d65b0d01c855 languageName: node linkType: hard -"expo-asset@npm:~55.0.13, expo-asset@npm:~55.0.16": - version: 55.0.16 - resolution: "expo-asset@npm:55.0.16" +"expo-asset@npm:~55.0.13, expo-asset@npm:~55.0.17": + version: 55.0.17 + resolution: "expo-asset@npm:55.0.17" dependencies: - "@expo/image-utils": ^0.8.13 - expo-constants: ~55.0.15 + "@expo/image-utils": ^0.8.14 + expo-constants: ~55.0.16 peerDependencies: expo: "*" react: "*" react-native: "*" - checksum: 4cb2e766709e1dfef927ee2f1e2fceb2badb00ca201034c0703e2c7d981938c0e446708c99b6c24494dd48b4ab38e94be3d9997112c20e2da597165b0e35d84b + checksum: 9a6631af85c9d1cd8e8a70739574c51f096cd6d5565ce75ffd42b56f62d902a4f402ebb48296e12d8cde2c74bcc51eaa129831e703a8668305e03b7343b58da2 languageName: node linkType: hard "expo-clipboard@npm:~55.0.12": - version: 55.0.13 - resolution: "expo-clipboard@npm:55.0.13" + version: 55.0.14 + resolution: "expo-clipboard@npm:55.0.14" peerDependencies: expo: "*" react: "*" react-native: "*" - checksum: f808528b6564ef56e162e3226a9c7f9e80c8f38c893825b864c155f7aa6603c33979701a46a3d63e97dc9d3a7597fa2315af558ca8c14d70a6577d0ba61c3d5e + checksum: 99798863654a69c6f24aa903eb04f4c6e8e781089b27545ba2282422f20f258ea0445b062854a6e0543c914dba745316275eccce72446650424257492324d8e9 languageName: node linkType: hard -"expo-constants@npm:~55.0.12, expo-constants@npm:~55.0.15": - version: 55.0.15 - resolution: "expo-constants@npm:55.0.15" +"expo-constants@npm:~55.0.12, expo-constants@npm:~55.0.16": + version: 55.0.16 + resolution: "expo-constants@npm:55.0.16" dependencies: - "@expo/env": ~2.1.1 + "@expo/env": ~2.1.2 peerDependencies: expo: "*" react-native: "*" - checksum: 1dc50dcd2176c5cc28bb6dcc2a9ad69f47027991bde31577490d9c4a3a03574d473e59d59f9d8ff848fbf4f86d3e1f511ffaeb4113ff6241c53b5d41186bbf4e + checksum: 1a63cf32ae5e6cff91e8b0a10aec05b7eac02a7379339d2879abed24ef29fc14ea64cda60e0175c1c771f2393c9faaf66352a4d2acfd8c681f3944ae3a573f13 languageName: node linkType: hard "expo-device@npm:~55.0.13": - version: 55.0.15 - resolution: "expo-device@npm:55.0.15" + version: 55.0.18 + resolution: "expo-device@npm:55.0.18" dependencies: ua-parser-js: ^0.7.33 peerDependencies: expo: "*" - checksum: a8bbc9a098f9539dbc4f55727542d0800fbf0030fd639c03f837d70080d39ab512772d2720a072ee272b1cc81c22b4241b2598a64451282fd33e97d68aa171a3 + checksum: 6d682cec97de8559c374b1d670e7cdc00de93900db4d68ccdd0d46115c2e4a1b7e9be000495ec565ce3283dec21eac79578f9fb4e929df65c729c919e763976f languageName: node linkType: hard "expo-document-picker@npm:~55.0.12": - version: 55.0.13 - resolution: "expo-document-picker@npm:55.0.13" + version: 55.0.14 + resolution: "expo-document-picker@npm:55.0.14" peerDependencies: expo: "*" - checksum: cb4ab011acbd618e81e82f6e78c5a8b4fbe675be0dafca82b86acbc8b2f25b92fa9619863890b6bd5726335032badea38a67b82bb938257ade5f17c51b8f7e53 + checksum: e4ed85f0edb64aaee41a1125d27193b2372683551025e0d9909b63ab4f7c46eb6e280b6315cdb36f6425ff19aa11ee34c4d5ffc6c2c1559edd5ad7c2964773bd languageName: node linkType: hard -"expo-file-system@npm:~55.0.15, expo-file-system@npm:~55.0.17": - version: 55.0.17 - resolution: "expo-file-system@npm:55.0.17" +"expo-file-system@npm:~55.0.15, expo-file-system@npm:~55.0.23": + version: 55.0.23 + resolution: "expo-file-system@npm:55.0.23" peerDependencies: expo: "*" react-native: "*" - checksum: a99a3adada21f28fb4cb79f74ecb9340ae92fb1783c8fe3f3b6e190fd17b1acd383c0fdd7a211cb908b70d91e75cc1d4b256cde15f82dc94a686939c0833fdf1 + checksum: c20f79958336613b4030b963d8f11def801890f4565d621fd95448edbd88adf4f7b499af171f7504e5f743be8bd04e095bd141f99eb64885840437a768e013a5 languageName: node linkType: hard -"expo-font@npm:~55.0.6": - version: 55.0.6 - resolution: "expo-font@npm:55.0.6" +"expo-font@npm:~55.0.6, expo-font@npm:~55.0.8": + version: 55.0.8 + resolution: "expo-font@npm:55.0.8" dependencies: fontfaceobserver: ^2.1.0 peerDependencies: expo: "*" react: "*" react-native: "*" - checksum: 6a2e237e94cd5fd9217fdb2c5e09b53f2ebc5fd50bc60266eb33c3c14df2e235220bd4a33d4c08e3fe0a8d8bcfdd00cd21f527fe3af5c5fe5398442b4ebbbd83 + checksum: d07da9d1903e0809733fbd11d606127b7be45db88be922c423ea0b70b5504934e65517e432a4245cf980ffb93c3e4f27148de3fd1a2bba8107080a51190bf6a6 languageName: node linkType: hard -"expo-glass-effect@npm:^55.0.10": - version: 55.0.10 - resolution: "expo-glass-effect@npm:55.0.10" +"expo-glass-effect@npm:^55.0.11": + version: 55.0.11 + resolution: "expo-glass-effect@npm:55.0.11" peerDependencies: expo: "*" react: "*" react-native: "*" - checksum: 33723ce6393baefb592816848acc36a64f7a6debfc08fb29b493e08733cddc890e10eeca38dca3d9d49c1f93a677d731d018673a370454a02d4dbe55ac54e180 + checksum: c50f669d09ebf77a290dae785f743ad3dde404f805b360245bdae4f5fe34a6e5f54e606ea5b0d4902058f34e949409fbcb5debfd2e67fd2b982f802a3ed7f31f languageName: node linkType: hard -"expo-image@npm:^55.0.9": - version: 55.0.9 - resolution: "expo-image@npm:55.0.9" +"expo-image@npm:^55.0.11": + version: 55.0.11 + resolution: "expo-image@npm:55.0.11" dependencies: sf-symbols-typescript: ^2.2.0 peerDependencies: @@ -6190,73 +6227,73 @@ __metadata: peerDependenciesMeta: react-native-web: optional: true - checksum: 0eff68e47ff8dd97908cdbbd26ea8670b25178269d3e877980e9d9f347da41553c221b10e1d3102a4906ea2764d128138522ab73799ff673256eaf3fa08f5102 + checksum: b16a23f067f28f27d065aefb67e9000b3321739b96f1cca4fba607f51c60999d9aafe0087ed239a80fd337fb942b331d7d6213a0201a34f1bb0f456e9126e5bb languageName: node linkType: hard -"expo-keep-awake@npm:~55.0.7": - version: 55.0.7 - resolution: "expo-keep-awake@npm:55.0.7" +"expo-keep-awake@npm:~55.0.8": + version: 55.0.8 + resolution: "expo-keep-awake@npm:55.0.8" peerDependencies: expo: "*" react: "*" - checksum: ebf0e671cc6e8682694a9bff8b4883014cbf770257658d0b382b24c33bb41b6ad872bc06b801474e4ad52f3be561ed369bc28ee010da65744e49194a62e60256 + checksum: 9887b54578f3b4a00a6160db6a6834df8b56c1a3fee817e0178177c73a0aea12fa1aa8c1fb35f2981497c4954e1388c6467ff7aeed0050d6adda5514606bbb81 languageName: node linkType: hard "expo-linear-gradient@npm:~55.0.13": - version: 55.0.13 - resolution: "expo-linear-gradient@npm:55.0.13" + version: 55.0.15 + resolution: "expo-linear-gradient@npm:55.0.15" peerDependencies: expo: "*" react: "*" react-native: "*" - checksum: 4a77242b3d8d12ad1c3e7254c1e95dc66f8c8552bf0d0cc5f31f716933a60242c5cf269d7958a69a05824a0f185e81a71c5a0a3181328d9029fd57c1d64aef0f + checksum: a83b8afb25d81c48c55b1b8d014da34a83c985eb0d3cdb9b436ca970351ec66c0ad93708511952140f08284577782e29a16a45ca4d3e62cebb7a9dcd9c278628 languageName: node linkType: hard "expo-linking@npm:~55.0.11": - version: 55.0.14 - resolution: "expo-linking@npm:55.0.14" + version: 55.0.16 + resolution: "expo-linking@npm:55.0.16" dependencies: - expo-constants: ~55.0.15 + expo-constants: ~55.0.16 invariant: ^2.2.4 peerDependencies: react: "*" react-native: "*" - checksum: cdd706a133c3a84e4329d2968d46b42a06ab46d2b8db9725013727dfbeeb65587683a6e8be70c8bcb01e61e453ca2141625ef7e02eb0f4cd6e3fb3bc53a10b09 + checksum: 3c43d0e80b0bcfb54d1b34dfc07735ce8920fe0237983c1c5b35362c96c1545f2bd80a7eb3929de333b88299771caa2f1937016bcc36afdcb90bce0c8355ac7d languageName: node linkType: hard "expo-localization@npm:~55.0.12": - version: 55.0.13 - resolution: "expo-localization@npm:55.0.13" + version: 55.0.16 + resolution: "expo-localization@npm:55.0.16" dependencies: rtl-detect: ^1.0.2 peerDependencies: expo: "*" react: "*" - checksum: 0d7dfdcc37a61962563a3a974987b6ae56c1e42cd872bd73e0bc1f533dc98ee29ef5230212ab9b5f2488fc24bba519a3d8041e62d6f827681b6b4ef803816199 + checksum: 6fa5674032ed0367cb6623c7841081e1821d2b707f12a7228987d86f6e47c3ee2ac149bd34d608ff6ae1a5472efdb68b1c75cd8528ffb696c3163b526c47409c languageName: node linkType: hard -"expo-modules-autolinking@npm:55.0.19": - version: 55.0.19 - resolution: "expo-modules-autolinking@npm:55.0.19" +"expo-modules-autolinking@npm:55.0.24": + version: 55.0.24 + resolution: "expo-modules-autolinking@npm:55.0.24" dependencies: - "@expo/require-utils": ^55.0.4 + "@expo/require-utils": ^55.0.5 "@expo/spawn-async": ^1.7.2 chalk: ^4.1.0 commander: ^7.2.0 bin: expo-modules-autolinking: bin/expo-modules-autolinking.js - checksum: 94198ba663500d1004e14236170fbd6ffc5cd914a47fa8ef7932818185204fe4d37c38b30d6e27014a6306bc250c9a57d131ab3fd235be6b66a01248e1e68100 + checksum: 07e19dc23bd2cbd12c14c327b8bca7472d95c3bb97845709d187c884185ca3886057bdaf1c9193fc1d020b75cfafa878d87a8ec244ce061f549f8165ca0e6f47 languageName: node linkType: hard -"expo-modules-core@npm:55.0.24": - version: 55.0.24 - resolution: "expo-modules-core@npm:55.0.24" +"expo-modules-core@npm:55.0.25": + version: 55.0.25 + resolution: "expo-modules-core@npm:55.0.25" dependencies: invariant: ^2.2.4 peerDependencies: @@ -6266,7 +6303,7 @@ __metadata: peerDependenciesMeta: react-native-worklets: optional: true - checksum: b9091a109f8adfa658c189fe2ed84cee47a58c7a7910ec7fcaec127f0a4d72e8e773b7f5e7b1ed77d65056f00ace3725b2889860fbfb42f1be43cfe5bde01287 + checksum: cce87b2db8ad38629dc16378eb2b61d2f05adb6bf9c64f1c5dc946d9affc70b11b4e9fc624219998c2e329a7a0c98bb36422e14fb4db7403b48eb1cd82ffb0b9 languageName: node linkType: hard @@ -6282,11 +6319,11 @@ __metadata: linkType: hard "expo-router@npm:~55.0.11": - version: 55.0.13 - resolution: "expo-router@npm:55.0.13" + version: 55.0.16 + resolution: "expo-router@npm:55.0.16" dependencies: - "@expo/metro-runtime": ^55.0.10 - "@expo/schema-utils": ^55.0.3 + "@expo/metro-runtime": ^55.0.11 + "@expo/schema-utils": ^55.0.4 "@radix-ui/react-slot": ^1.2.0 "@radix-ui/react-tabs": ^1.1.12 "@react-navigation/bottom-tabs": ^7.15.5 @@ -6295,10 +6332,10 @@ __metadata: client-only: ^0.0.1 debug: ^4.3.4 escape-string-regexp: ^4.0.0 - expo-glass-effect: ^55.0.10 - expo-image: ^55.0.9 - expo-server: ^55.0.8 - expo-symbols: ^55.0.7 + expo-glass-effect: ^55.0.11 + expo-image: ^55.0.11 + expo-server: ^55.0.11 + expo-symbols: ^55.0.9 fast-deep-equal: ^3.1.3 invariant: ^2.2.4 nanoid: ^3.3.8 @@ -6312,13 +6349,13 @@ __metadata: use-latest-callback: ^0.2.1 vaul: ^1.1.2 peerDependencies: - "@expo/log-box": 55.0.11 - "@expo/metro-runtime": ^55.0.10 + "@expo/log-box": 55.0.12 + "@expo/metro-runtime": ^55.0.11 "@react-navigation/drawer": ^7.9.4 "@testing-library/react-native": ">= 13.2.0" expo: "*" - expo-constants: ^55.0.15 - expo-linking: ^55.0.14 + expo-constants: ^55.0.16 + expo-linking: ^55.0.15 react: "*" react-dom: "*" react-native: "*" @@ -6343,81 +6380,81 @@ __metadata: optional: true react-server-dom-webpack: optional: true - checksum: 44f480720a04a948c5ed41fbcbe4c82fb4a359fcec797cb37ccb854f08e7ff595ded557234cad49069f46bebdc44eb6ef2fc08732aab41e277efd5d03edc1e6f + checksum: c2168aa0dc34573d5006557ebaa56df61d9bb1e2f329330d97da3b8137de505ade9173361d6cace50e00e96e7efd95c7876ef9c933196ebbc3256aa17da252f4 languageName: node linkType: hard -"expo-server@npm:^55.0.8": - version: 55.0.8 - resolution: "expo-server@npm:55.0.8" - checksum: 7cc0f3178b3a6a7499c19a7d890f1748fea5a46f504a6d96aa9f99b71dd28e3afb1d10427b024c1aae08bab2a660abeffb7bc36a7a188ccdf1432ad4bd3728a6 +"expo-server@npm:^55.0.11": + version: 55.0.11 + resolution: "expo-server@npm:55.0.11" + checksum: 01e3dcf3fd6289c4ffefbc93d9da6ca957ca0b31fe5f4c808c5643a595c8b90c56abc32deb42c00a9ca0e183ae9449af2d5f0b647d3b026900a047c252c8e292 languageName: node linkType: hard "expo-sharing@npm:~55.0.17": - version: 55.0.18 - resolution: "expo-sharing@npm:55.0.18" + version: 55.0.21 + resolution: "expo-sharing@npm:55.0.21" dependencies: - "@expo/config-plugins": ^55.0.8 + "@expo/config-plugins": ^55.0.10 "@expo/config-types": ^55.0.5 - "@expo/plist": ^0.5.2 + "@expo/plist": ^0.5.4 peerDependencies: expo: "*" react: "*" react-native: "*" - checksum: bdb5507b4f45d00324c821df165f5df3f8b1f9b10a8de4ef41732ac406ccf74d6376096c77821250d6a08c3b59291b513bbcb3ee0b0b108378e93eb0c1906a7d + checksum: 01dc173ed0da4b26b71b0444f58eea5bf15c88003226448ab73e046d853028aa329ba3bba54acb47253b049a9ece0261e3f1829c7d626eaab657cdd4dd0dbcf7 languageName: node linkType: hard "expo-splash-screen@npm:~55.0.16": - version: 55.0.19 - resolution: "expo-splash-screen@npm:55.0.19" + version: 55.0.22 + resolution: "expo-splash-screen@npm:55.0.22" dependencies: - "@expo/prebuild-config": ^55.0.16 + "@expo/prebuild-config": ^55.0.19 peerDependencies: expo: "*" - checksum: 2044e2921e55d6a96537f2fa70a10cb16c34521f61cf94cc4f306a2177a771eceb475e1ab8f20643294c99a9dfee06e4586adbfd38e44f54da0ec19ce0d5f290 + checksum: acb75a4807d599363caf63d19edfe7c6d62d5b046c66625edb0edf228fc92c91978df8893d50e1b6c7e96332837d84200415e97fd628bcdf74b760f543e02b68 languageName: node linkType: hard "expo-sqlite@npm:~55.0.14": - version: 55.0.15 - resolution: "expo-sqlite@npm:55.0.15" + version: 55.0.17 + resolution: "expo-sqlite@npm:55.0.17" dependencies: await-lock: ^2.2.2 peerDependencies: expo: "*" react: "*" react-native: "*" - checksum: 540789025301373928fbf6d9af0c7db486e923ac1539993e2cc5658bb6eae4d68a9fa91e7a34dfb0c7bf11d10163edbfb4a3d40b2ca8f7cfcf04fcba8c653d20 + checksum: bab30afecc6f45e30cf69e94599ee2207345fa4c682a96a19dfd8d2e5c33416dad7983398d111f6ab7013172e522bd092e2dfeb5d587b1f36e5e973b975f45fc languageName: node linkType: hard "expo-status-bar@npm:~55.0.5": - version: 55.0.5 - resolution: "expo-status-bar@npm:55.0.5" + version: 55.0.6 + resolution: "expo-status-bar@npm:55.0.6" dependencies: react-native-is-edge-to-edge: ^1.2.1 peerDependencies: react: "*" react-native: "*" - checksum: df1ff626f879432e8064a15f312da255d11520cd6a0a8c1e38ba603388ef7237d745f323a7c23a0ce0b59ee4a444d93269ab0cedd7f5fc0ad3d26e167333b887 + checksum: e380f16cc3475a674c20280f784d2bbaced0292634408d4defdfed50ad7bc029f791e5a143c91bf9e24564c7a21697b337ac96d5ff60acf17f9f0c17ab5a7976 languageName: node linkType: hard "expo-store-review@npm:~55.0.12": - version: 55.0.13 - resolution: "expo-store-review@npm:55.0.13" + version: 55.0.15 + resolution: "expo-store-review@npm:55.0.15" peerDependencies: expo: "*" react-native: "*" - checksum: 73de754f7cc1373bdc660cd702be11f9f06d97a609e237bb8085a1b3983ccb470711c94d4adc1490a37085ef45af3caddcfbb022d877721a7f276c7d82dc9a95 + checksum: 56ac12cd458cdefd1d9c63da8f004890b0f2b872016f6f9d64f1eb732a19f31bd65e658bfd38c238e0c5dff1aeba6a91fddfe09f1442db6bd38f1883b40c4ec2 languageName: node linkType: hard -"expo-symbols@npm:^55.0.7": - version: 55.0.7 - resolution: "expo-symbols@npm:55.0.7" +"expo-symbols@npm:^55.0.9": + version: 55.0.9 + resolution: "expo-symbols@npm:55.0.9" dependencies: "@expo-google-fonts/material-symbols": ^0.4.1 sf-symbols-typescript: ^2.0.0 @@ -6426,37 +6463,37 @@ __metadata: expo-font: "*" react: "*" react-native: "*" - checksum: 9afe72809d6873dbc3bc02ed369abe50ac473f3174278b047ef550af06b0e504a1e53306998efc6c97967d26868f8833666b2b3499e2e6ed267f286632f96eb7 + checksum: 49b9fa1bf75661386e98b40e734792f1e626bf625533e860e47fd15a008fbd3b6c13b56dd398be85ff19fbab09880ff323f57ad1b56cf948e8f9fab5782706c9 languageName: node linkType: hard "expo@npm:^55.0.0": - version: 55.0.20 - resolution: "expo@npm:55.0.20" + version: 55.0.27 + resolution: "expo@npm:55.0.27" dependencies: "@babel/runtime": ^7.20.0 - "@expo/cli": 55.0.28 - "@expo/config": ~55.0.15 - "@expo/config-plugins": ~55.0.8 - "@expo/devtools": 55.0.2 - "@expo/fingerprint": 0.16.6 - "@expo/local-build-cache-provider": 55.0.11 - "@expo/log-box": 55.0.11 + "@expo/cli": 55.0.33 + "@expo/config": ~55.0.18 + "@expo/config-plugins": ~55.0.10 + "@expo/devtools": 55.0.3 + "@expo/fingerprint": 0.16.7 + "@expo/local-build-cache-provider": 55.0.14 + "@expo/log-box": 55.0.12 "@expo/metro": ~55.1.1 - "@expo/metro-config": 55.0.19 + "@expo/metro-config": 55.0.24 "@expo/vector-icons": ^15.0.2 "@ungap/structured-clone": ^1.3.0 - babel-preset-expo: ~55.0.20 - expo-asset: ~55.0.16 - expo-constants: ~55.0.15 - expo-file-system: ~55.0.17 - expo-font: ~55.0.6 - expo-keep-awake: ~55.0.7 - expo-modules-autolinking: 55.0.19 - expo-modules-core: 55.0.24 + babel-preset-expo: ~55.0.23 + expo-asset: ~55.0.17 + expo-constants: ~55.0.16 + expo-file-system: ~55.0.23 + expo-font: ~55.0.8 + expo-keep-awake: ~55.0.8 + expo-modules-autolinking: 55.0.24 + expo-modules-core: 55.0.25 pretty-format: ^29.7.0 react-refresh: ^0.14.2 - whatwg-url-minimum: ^0.1.1 + whatwg-url-minimum: ^0.1.2 peerDependencies: "@expo/dom-webview": "*" "@expo/metro-runtime": "*" @@ -6474,7 +6511,7 @@ __metadata: expo: bin/cli expo-modules-autolinking: bin/autolinking fingerprint: bin/fingerprint - checksum: 820d845d15507af54e845d67c63073a50599f066f80fa21e68f1fa22937987f109ae767f452f1ee9438d36ac1aee7605c788bbc2cb3c5c40a270f7028ffec4ba + checksum: ebfc534488a4fa4296b732135462afcefedddb619976e461b624f6845e21a3fe765e237d2ba8c399733b8606a8163ed26f602911f93f14828f450f68d4d06b7b languageName: node linkType: hard @@ -6654,10 +6691,19 @@ __metadata: languageName: node linkType: hard +"flow-estree@npm:0.321.0": + version: 0.321.0 + resolution: "flow-estree@npm:0.321.0" + checksum: 01e1119b21a8be08e7dad9749eadac7d06cae3a01bf0bdf5bbc3da0f2036cbb3ff2fb5f6bcced280cd2c1142e3c272d7a07c53571e4f7e001b54274485205491 + languageName: node + linkType: hard + "flow-parser@npm:0.*": - version: 0.312.1 - resolution: "flow-parser@npm:0.312.1" - checksum: 9753525da80a7a5342092fbf18f72950664eec28ca56daed384de0dc2a32f983baa8a0a0c7da36bea098d69b920c457a6656cc1e69b61367192aadd2faa061c8 + version: 0.321.0 + resolution: "flow-parser@npm:0.321.0" + dependencies: + flow-estree: 0.321.0 + checksum: a3b6290dbf4125e785920f3603597013de2f1992626d95ee8fdca4fac4dbe5c0fbac343b408dae134696a31be5fd40032beea1f1081d2b578cf4228569f6d70a languageName: node linkType: hard @@ -6678,15 +6724,15 @@ __metadata: linkType: hard "form-data@npm:^4.0.0": - version: 4.0.5 - resolution: "form-data@npm:4.0.5" + version: 4.0.6 + resolution: "form-data@npm:4.0.6" dependencies: asynckit: ^0.4.0 combined-stream: ^1.0.8 es-set-tostringtag: ^2.1.0 - hasown: ^2.0.2 - mime-types: ^2.1.12 - checksum: af8328413c16d0cded5fccc975a44d227c5120fd46a9e81de8acf619d43ed838414cc6d7792195b30b248f76a65246949a129a4dadd148721948f90cd6d4fb69 + hasown: ^2.0.4 + mime-types: ^2.1.35 + checksum: e51b9e97678c250c872cd4ec3e5eaa8fa43bee4b1acf8274c337308aebc6aebb0553091ce0810612826601ffafed9dace12504a63c6ef16c57fffcd7dcfec457 languageName: node linkType: hard @@ -6731,16 +6777,19 @@ __metadata: linkType: hard "function.prototype.name@npm:^1.1.6, function.prototype.name@npm:^1.1.8": - version: 1.1.8 - resolution: "function.prototype.name@npm:1.1.8" + version: 1.2.0 + resolution: "function.prototype.name@npm:1.2.0" dependencies: - call-bind: ^1.0.8 - call-bound: ^1.0.3 - define-properties: ^1.2.1 + call-bind: ^1.0.9 + call-bound: ^1.0.4 + es-define-property: ^1.0.1 + es-errors: ^1.3.0 functions-have-names: ^1.2.3 - hasown: ^2.0.2 + has-property-descriptors: ^1.0.2 + hasown: ^2.0.4 is-callable: ^1.2.7 - checksum: 3a366535dc08b25f40a322efefa83b2da3cd0f6da41db7775f2339679120ef63b6c7e967266182609e655b8f0a8f65596ed21c7fd72ad8bd5621c2340edd4010 + is-document.all: ^1.0.0 + checksum: 297f6966f6fe1ff756ec7f51956420273d55186697769ff8e815578c10bb9cc27de5e4383cc0da703873b988ad634b74b167f1f9f8ef6a04f7f096f8088fde5d languageName: node linkType: hard @@ -6773,9 +6822,9 @@ __metadata: linkType: hard "get-east-asian-width@npm:^1.0.0, get-east-asian-width@npm:^1.3.1, get-east-asian-width@npm:^1.5.0": - version: 1.5.0 - resolution: "get-east-asian-width@npm:1.5.0" - checksum: 60bc34cd1e975055ab99f0f177e31bed3e516ff7cee9c536474383954a976abaa6b94a51d99ad158ef1e372790fa096cab7d07f166bb0778f6587954c0fbe946 + version: 1.6.0 + resolution: "get-east-asian-width@npm:1.6.0" + checksum: 88faf98aaade2b24ad260be369b026d979ff4d0e6201bfd991654da17f2aa720bbddb812ce5612110eef6b469d8370c863f69a2f9818d61733f1eb2a83d779b8 languageName: node linkType: hard @@ -6969,12 +7018,12 @@ __metadata: languageName: node linkType: hard -"hasown@npm:^2.0.2": - version: 2.0.3 - resolution: "hasown@npm:2.0.3" +"hasown@npm:^2.0.2, hasown@npm:^2.0.3, hasown@npm:^2.0.4": + version: 2.0.4 + resolution: "hasown@npm:2.0.4" dependencies: function-bind: ^1.1.2 - checksum: bb06756a13dc4e6d1f45993c86c23f12d167c6c30a7dcc907aec5042300b4eb255615a0e5ed2c65014b93bf8bfcff111d991032c5c01ddefb340aa64b329bd55 + checksum: 4bd8f916b629e06324853593ffbdd45e200022952a85ad0c967f3bd4c2e4c7e1f9a9766fbe6186f60bd394e0afc73e719730caa1da15cd9bd832b7cdf53fd26c languageName: node linkType: hard @@ -7354,12 +7403,12 @@ __metadata: languageName: node linkType: hard -"is-core-module@npm:^2.16.1": - version: 2.16.1 - resolution: "is-core-module@npm:2.16.1" +"is-core-module@npm:^2.16.1, is-core-module@npm:^2.16.2": + version: 2.16.2 + resolution: "is-core-module@npm:2.16.2" dependencies: - hasown: ^2.0.2 - checksum: 6ec5b3c42d9cbf1ac23f164b16b8a140c3cec338bf8f884c076ca89950c7cc04c33e78f02b8cae7ff4751f3247e3174b2330f1fe4de194c7210deb8b1ea316a7 + hasown: ^2.0.3 + checksum: 9317844b4959f8fb268bfc1b4e24033d60058235c2e7273499c2abfd8e4510e7059b1339bd9109766293747daa3e0b5a89095fb2825a866a4093563fe8fdf16f languageName: node linkType: hard @@ -7374,7 +7423,7 @@ __metadata: languageName: node linkType: hard -"is-date-object@npm:^1.0.5, is-date-object@npm:^1.1.0": +"is-date-object@npm:^1.1.0": version: 1.1.0 resolution: "is-date-object@npm:1.1.0" dependencies: @@ -7400,6 +7449,15 @@ __metadata: languageName: node linkType: hard +"is-document.all@npm:^1.0.0": + version: 1.0.0 + resolution: "is-document.all@npm:1.0.0" + dependencies: + call-bound: ^1.0.4 + checksum: 383175789df98503dc0c15d39e80932b21cbec839b8840d7b75dc36db942e9dd2da0f36c464ea1aa2e751f5808c38e97bbf288b76d8114d5e570f49df26ed930 + languageName: node + linkType: hard + "is-extglob@npm:^2.1.1": version: 2.1.1 resolution: "is-extglob@npm:2.1.1" @@ -7560,7 +7618,7 @@ __metadata: languageName: node linkType: hard -"is-symbol@npm:^1.0.4, is-symbol@npm:^1.1.1": +"is-symbol@npm:^1.1.1": version: 1.1.1 resolution: "is-symbol@npm:1.1.1" dependencies: @@ -7825,15 +7883,15 @@ __metadata: languageName: node linkType: hard -"jest-diff@npm:30.3.0": - version: 30.3.0 - resolution: "jest-diff@npm:30.3.0" +"jest-diff@npm:30.4.1": + version: 30.4.1 + resolution: "jest-diff@npm:30.4.1" dependencies: - "@jest/diff-sequences": 30.3.0 + "@jest/diff-sequences": 30.4.0 "@jest/get-type": 30.1.0 chalk: ^4.1.2 - pretty-format: 30.3.0 - checksum: ad49d2c602a8006725cb507143ffa6f19eb355a56ad7dffc10361ce51f74dee103db9233e1a1aa7020d8dae138ec071034ba05391bc1b0e738b69a4a994dbf29 + pretty-format: 30.4.1 + checksum: f3e58eb102992d6cf2ab6737337f2f2ea7fdf28dbdaf2d7ed8ded08ef954a947ee7f641ef5e6e0f8b14b3c93c99c9428084e1d217e4febd2e3683373a6e57323 languageName: node linkType: hard @@ -7907,11 +7965,11 @@ __metadata: linkType: hard "jest-expo@npm:~55.0.14": - version: 55.0.16 - resolution: "jest-expo@npm:55.0.16" + version: 55.0.19 + resolution: "jest-expo@npm:55.0.19" dependencies: - "@expo/config": ~55.0.15 - "@expo/json-file": ^10.0.13 + "@expo/config": ~55.0.18 + "@expo/json-file": ^10.0.15 "@jest/create-cache-key-function": ^29.2.1 "@jest/globals": ^29.2.1 babel-jest: ^29.2.1 @@ -7933,7 +7991,7 @@ __metadata: optional: true bin: jest: bin/jest.js - checksum: 465c1b5fc758cd9499cf3b6a256c064836589b58fa0d63165ca8cd27e0e2ad3d930d322df94aca41a25ffef75a0490de8202919ec724e352e8fbe4df7179e69a + checksum: 74e73f7e78173b285e197a9ced521647336773c91d0fbf418bc1b43be7286ec210610a60d70dadb040a365245c896a445c9fec01a75185a352e4d33784f234f0 languageName: node linkType: hard @@ -7990,14 +8048,14 @@ __metadata: linkType: hard "jest-matcher-utils@npm:^30.0.5": - version: 30.3.0 - resolution: "jest-matcher-utils@npm:30.3.0" + version: 30.4.1 + resolution: "jest-matcher-utils@npm:30.4.1" dependencies: "@jest/get-type": 30.1.0 chalk: ^4.1.2 - jest-diff: 30.3.0 - pretty-format: 30.3.0 - checksum: 3bc01ef81d001519fef75a32a0420c207664a829acbdc668bfa3c51e0a3ac2ddbb19c633e1e006ff63840bf231d915dfbe8dccef71e5ee842221ba1ff0da1946 + jest-diff: 30.4.1 + pretty-format: 30.4.1 + checksum: 18bc7a8ee2ce2fec06823e5ca231bd3c68f44f36143f6b738f6e191a60373dd3e5f595780f1b098d6945267b662368d1cd418f899fd6dbf07c3a9f3ba0673566 languageName: node linkType: hard @@ -8289,25 +8347,25 @@ __metadata: linkType: hard "js-yaml@npm:^3.13.1": - version: 3.14.2 - resolution: "js-yaml@npm:3.14.2" + version: 3.15.0 + resolution: "js-yaml@npm:3.15.0" dependencies: argparse: ^1.0.7 esprima: ^4.0.0 bin: js-yaml: bin/js-yaml.js - checksum: 626fc207734a3452d6ba84e1c8c226240e6d431426ed94d0ab043c50926d97c509629c08b1d636f5d27815833b7cfd225865631da9fb33cb957374490bf3e90b + checksum: 0209830c3ce51cec4c2cefee4b2b68c547b56b36920004efbf7c6a91ae7eee784bf64341738ef1acffa975d16e7882e9b69234b9a4411f455b72b33d4d74771e languageName: node linkType: hard "js-yaml@npm:^4.1.0, js-yaml@npm:^4.1.1": - version: 4.1.1 - resolution: "js-yaml@npm:4.1.1" + version: 4.3.0 + resolution: "js-yaml@npm:4.3.0" dependencies: argparse: ^2.0.1 bin: js-yaml: bin/js-yaml.js - checksum: ea2339c6930fe048ec31b007b3c90be2714ab3e7defcc2c27ebf30c74fd940358f29070b4345af0019ef151875bf3bc3f8644bea1bab0372652b5044813ac02d + checksum: 1268fb22ae8504646cedc0823c18816d4af056029578e855b9a4d3e19bcafc3e9ba9e391f70dea4a29b023bb09f23639766071199cb89e853266b90a6ec25f9a languageName: node linkType: hard @@ -8442,11 +8500,11 @@ __metadata: linkType: hard "jsonrepair@npm:^3.12.0": - version: 3.14.0 - resolution: "jsonrepair@npm:3.14.0" + version: 3.14.1 + resolution: "jsonrepair@npm:3.14.1" bin: jsonrepair: bin/cli.js - checksum: 750431a74488052baa281d9104b96f850e978925191d35266b45acd2e2070ec125f21147e375a1c14cc65bc8369f370f933c75f0f4c256a58309021799966ba7 + checksum: a1526116217740a48320a032fe2a565a70fa16e2933ea0f28a72a64834803b477f83033198aa0d641855320e822e1d3bdc6a9ad153f456f59c72c505b9967759 languageName: node linkType: hard @@ -8819,9 +8877,9 @@ __metadata: linkType: hard "lru-cache@npm:^11.0.0": - version: 11.3.6 - resolution: "lru-cache@npm:11.3.6" - checksum: 0bc3ad1cb4c91c8baa532f3bede807a63d36156cc066a6f0e0a44d2dc317f94aa19a92bc11ce0f9933d00ea90995e2e61a0b12a21e1c5f3cc38d4b28956870d4 + version: 11.5.1 + resolution: "lru-cache@npm:11.5.1" + checksum: d92b10275051c5139ee8087bd71b18718f0bd1a964f80b9ddc65dc31eecee0bcedc59b29e7f876fcc3ffce7a9823d7789977ebc4ab247a382a6e55f8895c85a2 languageName: node linkType: hard @@ -9392,7 +9450,7 @@ __metadata: languageName: node linkType: hard -"mime-types@npm:^2.1.12, mime-types@npm:^2.1.27, mime-types@npm:~2.1.34": +"mime-types@npm:^2.1.27, mime-types@npm:^2.1.35, mime-types@npm:~2.1.34": version: 2.1.35 resolution: "mime-types@npm:2.1.35" dependencies: @@ -9538,12 +9596,12 @@ __metadata: languageName: node linkType: hard -"nanoid@npm:^3.3.1, nanoid@npm:^3.3.11, nanoid@npm:^3.3.7, nanoid@npm:^3.3.8": - version: 3.3.12 - resolution: "nanoid@npm:3.3.12" +"nanoid@npm:^3.3.1, nanoid@npm:^3.3.11, nanoid@npm:^3.3.12, nanoid@npm:^3.3.8": + version: 3.3.15 + resolution: "nanoid@npm:3.3.15" bin: nanoid: bin/nanoid.cjs - checksum: 38699257447dc59e21e73e0510d0dfb16b7a610d9ca80633d5c3a68f9b4298c990513d30404ca8f163c2d03225ee01695ff8898bea6179183f38f0477b7635ac + checksum: 0f645685aefba48aae06acfb390be660041d91b4ec63d85544ed01b619c6d661c985170bfbcd29b7265b6c1c3369c631e5dcb2f34c34e2c0023108bc158531d1 languageName: node linkType: hard @@ -9602,14 +9660,14 @@ __metadata: linkType: hard "node-exports-info@npm:^1.6.0": - version: 1.6.0 - resolution: "node-exports-info@npm:1.6.0" + version: 1.6.2 + resolution: "node-exports-info@npm:1.6.2" dependencies: array.prototype.flatmap: ^1.3.3 es-errors: ^1.3.0 object.entries: ^1.1.9 semver: ^6.3.1 - checksum: 6bb93ec7ae95717aa2a2c315a5df1f7efa9f0592ee6fcde83256e112db33b59f0942d4188e154e84ec03f9de2d5ea62aa278e2d57b8624f6434168e8d7701e44 + checksum: d8642f3dc0c03023a0249ab737ab052796b7ae6d51401b49cfcd0c8c41a22dc464e8aeb3207d6d2ea1f3807f37000bc88bcfad111e4ec191d0f9e1eafa768804 languageName: node linkType: hard @@ -9621,22 +9679,22 @@ __metadata: linkType: hard "node-gyp@npm:latest": - version: 12.3.0 - resolution: "node-gyp@npm:12.3.0" + version: 13.0.1 + resolution: "node-gyp@npm:13.0.1" dependencies: env-paths: ^2.2.0 exponential-backoff: ^3.1.1 graceful-fs: ^4.2.6 - nopt: ^9.0.0 - proc-log: ^6.0.0 + nopt: ^10.0.0 + proc-log: ^7.0.0 semver: ^7.3.5 tar: ^7.5.4 tinyglobby: ^0.2.12 - undici: ^6.25.0 - which: ^6.0.0 + undici: ^8.4.1 + which: ^7.0.0 bin: node-gyp: bin/node-gyp.js - checksum: b02e8776908a83f25b8df88f0b79c46b425f9b7f442ebe3c4a50b9820128c1b44df6b386214c73d509964995d820edbda94bb0c811b6b60a686231afb699acf7 + checksum: 9d4e0eeaa415ca33a85278ecf2bdb61a7c32649e9423ec7743912b97e58169606ef44504f7d3a746cd9179f50c8d37d5bb70ae76fbc4803122f7747e819453f6 languageName: node linkType: hard @@ -9647,21 +9705,21 @@ __metadata: languageName: node linkType: hard -"node-releases@npm:^2.0.36": - version: 2.0.38 - resolution: "node-releases@npm:2.0.38" - checksum: fe5af7b5928d06783534b38d0c55e3467b719a8a53acc2fd15f7f2d2ef4cedb38ae411cce59e2c10027827650c81897c41045e742131b9b5e4d118ce1b307025 +"node-releases@npm:^2.0.48": + version: 2.0.50 + resolution: "node-releases@npm:2.0.50" + checksum: fec78f8154182875bb35f3bb93eb178e2f51523b64f674ce5a0b72b73d3c058021c0224e97a707b45c82985f7ba24db68fff6b2bb9b08e7661d3a3e131cf85ea languageName: node linkType: hard -"nopt@npm:^9.0.0": - version: 9.0.0 - resolution: "nopt@npm:9.0.0" +"nopt@npm:^10.0.0": + version: 10.0.1 + resolution: "nopt@npm:10.0.1" dependencies: - abbrev: ^4.0.0 + abbrev: ^5.0.0 bin: nopt: bin/nopt.js - checksum: 7a5d9ab0629eaec1944a95438cc4efa6418ed2834aa8eb21a1bea579a7d8ac3e30120131855376a96ef59ab0e23ad8e0bc94d3349770a95e5cb7119339f7c7fb + checksum: c2903b9171a3293b731189ace4eaeacc2ed8191bb8f8f74ebfb61babc0de2ff158d97a215fd561070ed0dac567f3b8741955f81a534009e57eb37fdc419d5d4e languageName: node linkType: hard @@ -9710,9 +9768,9 @@ __metadata: linkType: hard "nwsapi@npm:^2.2.2": - version: 2.2.23 - resolution: "nwsapi@npm:2.2.23" - checksum: 7af519de08381df9dc0c913d817255cb21e33671641603f6cdabe8cb04b18b32aca1477fdc5dfe08b2039125afa3216d3ef01a3c2603a97d114e842d9414e0c3 + version: 2.2.24 + resolution: "nwsapi@npm:2.2.24" + checksum: c0c4016cc6117748d35a97ca12076b472de0626fa55d1e2d1e54401d24e38f1b9cef743955cbcdb9eb0fcc37ec16924a8a8b66bada52d295dbdf6da3f5108c58 languageName: node linkType: hard @@ -10144,9 +10202,9 @@ __metadata: linkType: hard "picomatch@npm:^4.0.3, picomatch@npm:^4.0.4": - version: 4.0.4 - resolution: "picomatch@npm:4.0.4" - checksum: 76b387b5157951422fa6049a96bdd1695e39dd126cd99df34d343638dc5cdb8bcdc83fff288c23eddcf7c26657c35e3173d4d5f488c4f28b889b314472e0a662 + version: 4.0.5 + resolution: "picomatch@npm:4.0.5" + checksum: 44f305aa44f33c15d304f2e887409961d1c14eebd465b5553d8aefddcfe7b5e40d208148adef8c21a21183dda2fdba1e83c8a56918e3b5e5d3db6bcaba5b7e12 languageName: node linkType: hard @@ -10200,21 +10258,21 @@ __metadata: languageName: node linkType: hard -"possible-typed-array-names@npm:^1.0.0": +"possible-typed-array-names@npm:^1.0.0, possible-typed-array-names@npm:^1.1.0": version: 1.1.0 resolution: "possible-typed-array-names@npm:1.1.0" checksum: cfcd4f05264eee8fd184cd4897a17890561d1d473434b43ab66ad3673d9c9128981ec01e0cb1d65a52cd6b1eebfb2eae1e53e39b2e0eca86afc823ede7a4f41b languageName: node linkType: hard -"postcss@npm:~8.4.32": - version: 8.4.49 - resolution: "postcss@npm:8.4.49" +"postcss@npm:^8.5.14": + version: 8.5.16 + resolution: "postcss@npm:8.5.16" dependencies: - nanoid: ^3.3.7 + nanoid: ^3.3.12 picocolors: ^1.1.1 source-map-js: ^1.2.1 - checksum: eb5d6cbdca24f50399aafa5d2bea489e4caee4c563ea1edd5a2485bc5f84e9ceef3febf170272bc83a99c31d23a316ad179213e853f34c2a7a8ffa534559d63a + checksum: 636fd45f028f487d4873d1d30a541b910e2477d77e5ca368e9f8535fad12b2c20bfc508516fd35258e33214d51dd84b1e089539b9d78737aed528abb1db036a5 languageName: node linkType: hard @@ -10235,22 +10293,23 @@ __metadata: linkType: hard "prettier@npm:^3.8.1": - version: 3.8.3 - resolution: "prettier@npm:3.8.3" + version: 3.9.4 + resolution: "prettier@npm:3.9.4" bin: prettier: bin/prettier.cjs - checksum: f696d5b93b6aa7da53b7e94fd1ac8789915297a67d142159a367901a57a417a25f54500823d26b5580573d388f634d7860bef10922944c47a231194d1613394a + checksum: 96b0125a8af78e1e3c2c3f916a768934a542d5fa64bf1819cbef5c9cb081b793ebe99eb202f98e230741595be9d14d73819380bd91988a7df422d704a97bb38c languageName: node linkType: hard -"pretty-format@npm:30.3.0, pretty-format@npm:^30.0.5": - version: 30.3.0 - resolution: "pretty-format@npm:30.3.0" +"pretty-format@npm:30.4.1, pretty-format@npm:^30.0.5": + version: 30.4.1 + resolution: "pretty-format@npm:30.4.1" dependencies: - "@jest/schemas": 30.0.5 + "@jest/schemas": 30.4.1 ansi-styles: ^5.2.0 - react-is: ^18.3.1 - checksum: 99bb09b51551fb710143a2c0b8270acc2f7723d51cdb5824fe55a0061e04af8bf07c47acd565121cb5b266dfc7a3ecfe77bf85b4f96aacbbeb6f2df0ba246a9c + react-is-18: "npm:react-is@^18.3.1" + react-is-19: "npm:react-is@^19.2.5" + checksum: 9602635027892d7a2f430b0a51972780226d30ce4072643349ad376ccee967539eb8180a656976976c0673d550604847da638b992eefc95c84bab7cebdf9faba languageName: node linkType: hard @@ -10356,10 +10415,10 @@ __metadata: languageName: node linkType: hard -"proc-log@npm:^6.0.0": - version: 6.1.0 - resolution: "proc-log@npm:6.1.0" - checksum: ac450ff8244e95b0c9935b52d629fef92ae69b7e39aea19972a8234259614d644402dd62ce9cb094f4a637d8a4514cba90c1456ad785a40ad5b64d502875a817 +"proc-log@npm:^7.0.0": + version: 7.0.0 + resolution: "proc-log@npm:7.0.0" + checksum: ded2e976dbfa428777496158db93efdd27523ce17cf7eae0e87c6dbba0f30bb46bbe6409ccdcf5ecea7bdea864ed409afc3b5c60b79f008d42dff79fdd481c27 languageName: node linkType: hard @@ -10484,24 +10543,24 @@ __metadata: languageName: node linkType: hard -"react-is@npm:^16.13.1, react-is@npm:^16.7.0": - version: 16.13.1 - resolution: "react-is@npm:16.13.1" - checksum: f7a19ac3496de32ca9ae12aa030f00f14a3d45374f1ceca0af707c831b2a6098ef0d6bdae51bd437b0a306d7f01d4677fcc8de7c0d331eb47ad0f46130e53c5f - languageName: node - linkType: hard - -"react-is@npm:^18.0.0, react-is@npm:^18.3.1": +"react-is-18@npm:react-is@^18.3.1, react-is@npm:^18.0.0": version: 18.3.1 resolution: "react-is@npm:18.3.1" checksum: e20fe84c86ff172fc8d898251b7cc2c43645d108bf96d0b8edf39b98f9a2cae97b40520ee7ed8ee0085ccc94736c4886294456033304151c3f94978cec03df21 languageName: node linkType: hard -"react-is@npm:^19.1.0, react-is@npm:^19.2.0": - version: 19.2.5 - resolution: "react-is@npm:19.2.5" - checksum: 303f022cdec5e3dee3c0ad731ed54b3db41b8aa8ef9503d37904a7acb404dcc656049b19119d37af4d2526a6921c7ad1675b8b3da76402664af56f8e198fd38b +"react-is-19@npm:react-is@^19.2.5, react-is@npm:^19.1.0, react-is@npm:^19.2.0": + version: 19.2.7 + resolution: "react-is@npm:19.2.7" + checksum: 148ee03b481ace8370cb9b96de386598423ed44ad36090c45ce3f905aec496db6f2675da720bbe43f39d66c43c9a16757cb71e9e55217825707dae97f0cf200f + languageName: node + linkType: hard + +"react-is@npm:^16.13.1, react-is@npm:^16.7.0": + version: 16.13.1 + resolution: "react-is@npm:16.13.1" + checksum: f7a19ac3496de32ca9ae12aa030f00f14a3d45374f1ceca0af707c831b2a6098ef0d6bdae51bd437b0a306d7f01d4677fcc8de7c0d331eb47ad0f46130e53c5f languageName: node linkType: hard @@ -10528,9 +10587,9 @@ __metadata: languageName: node linkType: hard -"react-native-drawer-layout@npm:^4.2.2": - version: 4.2.2 - resolution: "react-native-drawer-layout@npm:4.2.2" +"react-native-drawer-layout@npm:^4.2.5": + version: 4.2.5 + resolution: "react-native-drawer-layout@npm:4.2.5" dependencies: color: ^4.2.3 use-latest-callback: ^0.2.4 @@ -10539,7 +10598,7 @@ __metadata: react-native: "*" react-native-gesture-handler: ">= 2.0.0" react-native-reanimated: ">= 2.0.0" - checksum: aa15d18270ecaf88dcffdc6c144bc703b4dff26b79f28fbe3d770480ff237ff89e6d58b15daaa72f2db667d8c27ec5e6cb69228fd4c18156d97b2f2d6d140a89 + checksum: 9b29b0736d62f5aa6f8d367f026e5c4b32365277c15d7d03f0cb75001ea621123753ca40eb38b603c0630b939d355c61b700e83594e720569e1c808dcff76af4 languageName: node linkType: hard @@ -10653,13 +10712,13 @@ __metadata: linkType: hard "react-native-pulsar@npm:^1.4.0": - version: 1.4.0 - resolution: "react-native-pulsar@npm:1.4.0" + version: 1.6.1 + resolution: "react-native-pulsar@npm:1.6.1" peerDependencies: react: "*" react-native: "*" react-native-worklets: "*" - checksum: dc816947c205c301b15a219d0126ae32e7c0a46908839014b6f4ab9b13e49ef6ff4fa578e088652b77ceabea99f4439a741f0b688dcc9e4d4ddde723a5d0a55f + checksum: 020ffd948b3541a500f8ddbbf96fe5002a1b97e14fa9cf6e3637d7f5a2b55424f121c6436122f932cc45a4a2e4b1004485dcfd17ed585cbc47fe29a255a6468c languageName: node linkType: hard @@ -10876,7 +10935,7 @@ __metadata: languageName: node linkType: hard -"react-remove-scroll@npm:^2.6.3": +"react-remove-scroll@npm:^2.7.2": version: 2.7.2 resolution: "react-remove-scroll@npm:2.7.2" dependencies: @@ -10952,7 +11011,7 @@ __metadata: languageName: node linkType: hard -"reflect.getprototypeof@npm:^1.0.6, reflect.getprototypeof@npm:^1.0.9": +"reflect.getprototypeof@npm:^1.0.10, reflect.getprototypeof@npm:^1.0.9": version: 1.0.10 resolution: "reflect.getprototypeof@npm:1.0.10" dependencies: @@ -11027,13 +11086,13 @@ __metadata: linkType: hard "regjsparser@npm:^0.13.0": - version: 0.13.1 - resolution: "regjsparser@npm:0.13.1" + version: 0.13.2 + resolution: "regjsparser@npm:0.13.2" dependencies: jsesc: ~3.1.0 bin: regjsparser: bin/parser - checksum: 7a4e60e1487b6a0702e35540f882c0c6e0151f7f567c6a4c480c5397a3cab05f6d2bf5f64cdbcdf341e41caf232cae801a4db9b531c26eed3ca946b3c50ccb34 + checksum: 20ece45a3e9f63c8a3fa50fbcde7a3cc35d97c244ca9bc1c94d0907d4f6eb3f50bb7326e60f848649024d7cd6ff89c9a75a36d6faded0cf225677a3d13914a31 languageName: node linkType: hard @@ -11117,18 +11176,18 @@ __metadata: linkType: hard "resolve@npm:^2.0.0-next.5": - version: 2.0.0-next.6 - resolution: "resolve@npm:2.0.0-next.6" + version: 2.0.0-next.7 + resolution: "resolve@npm:2.0.0-next.7" dependencies: es-errors: ^1.3.0 - is-core-module: ^2.16.1 + is-core-module: ^2.16.2 node-exports-info: ^1.6.0 object-keys: ^1.1.1 path-parse: ^1.0.7 supports-preserve-symlinks-flag: ^1.0.0 bin: resolve: bin/resolve - checksum: bc5a4f8f4dd7e1a3d2d8cdd2818b7cc3334283d2ef067f462d2ab3a4ab8f969d69438d7553268f59a2b5b4c1b42d18fabb3241a6d0279276ab578ba74455822e + checksum: 62d58c4b2fcd820da8ef4e0f37f14daaac3f1dd1bdc73b4c1b750c4705676a28f30281784d340a60240206f3398a7001d0feca35735bacdc90e01de6ebf606b8 languageName: node linkType: hard @@ -11147,18 +11206,18 @@ __metadata: linkType: hard "resolve@patch:resolve@^2.0.0-next.5#~builtin": - version: 2.0.0-next.6 - resolution: "resolve@patch:resolve@npm%3A2.0.0-next.6#~builtin::version=2.0.0-next.6&hash=c3c19d" + version: 2.0.0-next.7 + resolution: "resolve@patch:resolve@npm%3A2.0.0-next.7#~builtin::version=2.0.0-next.7&hash=c3c19d" dependencies: es-errors: ^1.3.0 - is-core-module: ^2.16.1 + is-core-module: ^2.16.2 node-exports-info: ^1.6.0 object-keys: ^1.1.1 path-parse: ^1.0.7 supports-preserve-symlinks-flag: ^1.0.0 bin: resolve: bin/resolve - checksum: 514c6d4e5e7249f8a93e776724b22c72090ecedb3cb6846ba14c591e918716bb41b2f857e4ce47c8bd88e068aca85f6a8f70f1c5abecc16d345bf00f3a587fb9 + checksum: 6385a1797ae12043ecdbdeed8d39da9099417edf3504495df4f395889cc3548afc1f71b51ae05b3235e82cef3551215b5e5b89cf5c9fb6360cd804df519b76bf languageName: node linkType: hard @@ -11315,11 +11374,11 @@ __metadata: linkType: hard "semver@npm:^7.1.3, semver@npm:^7.3.5, semver@npm:^7.5.3, semver@npm:^7.5.4, semver@npm:^7.6.0, semver@npm:^7.6.3, semver@npm:^7.7.3": - version: 7.7.4 - resolution: "semver@npm:7.7.4" + version: 7.8.5 + resolution: "semver@npm:7.8.5" bin: semver: bin/semver.js - checksum: 9b4a6a58e98b9723fafcafa393c9d4e8edefaa60b8dfbe39e30892a3604cf1f45f52df9cfb1ae1a22b44c8b3d57fec8a9bb7b3e1645431587cb272399ede152e + checksum: 0c580f17e88e2b45806dc5cf3d824f719c946999d3554bf30307c2b68b3300ab3c8bfcd84d8f489b93b4cbb5362f5fc8de4d2858954f18d834253c4450fc9f6b languageName: node linkType: hard @@ -11463,13 +11522,13 @@ __metadata: linkType: hard "shell-quote@npm:^1.6.1": - version: 1.8.3 - resolution: "shell-quote@npm:1.8.3" - checksum: 550dd84e677f8915eb013d43689c80bb114860649ec5298eb978f40b8f3d4bc4ccb072b82c094eb3548dc587144bb3965a8676f0d685c1cf4c40b5dc27166242 + version: 1.9.0 + resolution: "shell-quote@npm:1.9.0" + checksum: 2ec85fb903121c684c3536d051e377e98746cf88525e08c298baaa7b42c168de75c3201cef00db66556e89e340a8cf26bd4c18e7a3fd78799449a21cac43da2e languageName: node linkType: hard -"side-channel-list@npm:^1.0.0": +"side-channel-list@npm:^1.0.1": version: 1.0.1 resolution: "side-channel-list@npm:1.0.1" dependencies: @@ -11505,15 +11564,15 @@ __metadata: linkType: hard "side-channel@npm:^1.1.0": - version: 1.1.0 - resolution: "side-channel@npm:1.1.0" + version: 1.1.1 + resolution: "side-channel@npm:1.1.1" dependencies: es-errors: ^1.3.0 - object-inspect: ^1.13.3 - side-channel-list: ^1.0.0 + object-inspect: ^1.13.4 + side-channel-list: ^1.0.1 side-channel-map: ^1.0.1 side-channel-weakmap: ^1.0.2 - checksum: bf73d6d6682034603eb8e99c63b50155017ed78a522d27c2acec0388a792c3ede3238b878b953a08157093b85d05797217d270b7666ba1f111345fbe933380ff + checksum: e0f217140c463636ee556260bbc8f47ba2931f1248826f58e1502704135814c763b325dd9ab122a04176aa45b4a4f8cc556415bcab7a0179d85250fb7812f063 languageName: node linkType: hard @@ -11733,6 +11792,13 @@ __metadata: languageName: node linkType: hard +"standard-navigation@npm:^0.0.7": + version: 0.0.7 + resolution: "standard-navigation@npm:0.0.7" + checksum: 7c04912a93cd09d3e19c8c47a52f7808b53bc1b6fb897e21261e3401cb4340d505e9e4e387ac50662aa0534cdb2d091c03aeb5efc72fcd678925a3af5bbfce74 + languageName: node + linkType: hard + "statuses@npm:~1.5.0": version: 1.5.0 resolution: "statuses@npm:1.5.0" @@ -11869,29 +11935,30 @@ __metadata: linkType: hard "string.prototype.trim@npm:^1.2.10": - version: 1.2.10 - resolution: "string.prototype.trim@npm:1.2.10" + version: 1.2.11 + resolution: "string.prototype.trim@npm:1.2.11" dependencies: - call-bind: ^1.0.8 - call-bound: ^1.0.2 + call-bind: ^1.0.9 + call-bound: ^1.0.4 define-data-property: ^1.1.4 define-properties: ^1.2.1 - es-abstract: ^1.23.5 - es-object-atoms: ^1.0.0 + es-abstract: ^1.24.2 + es-object-atoms: ^1.1.2 has-property-descriptors: ^1.0.2 - checksum: 87659cd8561237b6c69f5376328fda934693aedde17bb7a2c57008e9d9ff992d0c253a391c7d8d50114e0e49ff7daf86a362f7961cf92f7564cd01342ca2e385 + safe-regex-test: ^1.1.0 + checksum: 1aa0868afe15a54e781cd7fdcc851af4b0d71522108006f136ba35ecfde65b042496ca6926f85192923e6e9287750b772afb13860db15574bf1da454343db0ca languageName: node linkType: hard "string.prototype.trimend@npm:^1.0.9": - version: 1.0.9 - resolution: "string.prototype.trimend@npm:1.0.9" + version: 1.0.10 + resolution: "string.prototype.trimend@npm:1.0.10" dependencies: - call-bind: ^1.0.8 - call-bound: ^1.0.2 + call-bind: ^1.0.9 + call-bound: ^1.0.4 define-properties: ^1.2.1 - es-object-atoms: ^1.0.0 - checksum: cb86f639f41d791a43627784be2175daa9ca3259c7cb83e7a207a729909b74f2ea0ec5d85de5761e6835e5f443e9420c6ff3f63a845378e4a61dd793177bc287 + es-object-atoms: ^1.1.2 + checksum: 17684796ccd12accafaef0f7cafe7a88891e4a57ff8deb5a40665dbe627fb3bed2252f1b9f3b16da2229aa41ba24e082178b44afe91a0302d570149730be1ccb languageName: node linkType: hard @@ -12045,25 +12112,25 @@ __metadata: languageName: node linkType: hard -"synckit@npm:^0.11.12": - version: 0.11.12 - resolution: "synckit@npm:0.11.12" +"synckit@npm:^0.11.13": + version: 0.11.13 + resolution: "synckit@npm:0.11.13" dependencies: - "@pkgr/core": ^0.2.9 - checksum: a53fb563d01ba8912a111b883fc3c701e267896ff8273e7aba9001f5f74711e125888f4039e93060795cd416122cf492ae419eb10a6a3e3b00e830917669d2cf + "@pkgr/core": ^0.3.6 + checksum: ec989ed45f3df2e7eb3141f00060669fbc6cac5649846d0579f336cee4ab047c8bac65baa75b4df991e1c1bd224675b10efbe69af3596ead5180ec81a6d4ec06 languageName: node linkType: hard "tar@npm:^7.5.4": - version: 7.5.13 - resolution: "tar@npm:7.5.13" + version: 7.5.19 + resolution: "tar@npm:7.5.19" dependencies: "@isaacs/fs-minipass": ^4.0.0 chownr: ^3.0.0 minipass: ^7.1.2 minizlib: ^3.1.0 yallist: ^5.0.0 - checksum: adcc2a9179dab1b36ecb26575e698d2df8491a1df2cc83e2a0fdd8eaefd076da60dd2e20383a37760b5790bee34e9291aa2b2a9b3deef37ff03c1046219e5df7 + checksum: 72bedc26089d2b3372f5c833f2e69b1ce9a2e8138a461cbb4b2eab48bef2453b3a12e17d47f6a1883a6929cdc348c622179b016ffed765ce21da51e43f2f4209 languageName: node linkType: hard @@ -12087,8 +12154,8 @@ __metadata: linkType: hard "terser@npm:^5.15.0": - version: 5.46.2 - resolution: "terser@npm:5.46.2" + version: 5.48.0 + resolution: "terser@npm:5.48.0" dependencies: "@jridgewell/source-map": ^0.3.3 acorn: ^8.15.0 @@ -12096,7 +12163,7 @@ __metadata: source-map-support: ~0.5.20 bin: terser: bin/terser - checksum: ce43b30bc91e7b552dd7dde95f0cf7c0dfd3b726c13c97ebb9b23ff524069ad171f9e08b0e0542fb0822c43a018f050b35b5ba445733f8ac7693f0c7f061408b + checksum: ae4f3216af2c2abf145de7fbc212c0559ae61c66abc97cc63faec49e5da82ca79d551eb69c05717dfbe87c5a3c8960f47b52d26a73f077edf9f2e38b03fdeeef languageName: node linkType: hard @@ -12119,19 +12186,19 @@ __metadata: linkType: hard "tinyexec@npm:^1.0.4": - version: 1.1.2 - resolution: "tinyexec@npm:1.1.2" - checksum: be2cb2b60c415bf9ef2006f86b566774445ea59249b62edc293996299d0b235a14b3ec41bc11e942914287306e5d2c390a1f47cbf781cf3e96833312f0dca6bf + version: 1.2.4 + resolution: "tinyexec@npm:1.2.4" + checksum: 3004b0f784c17d35a87251059dd6d81685848472a21a8c258f7b6c0433a25e37552f3955b0844f422895711fae24f44a8b1165597b8b0fe3fe8d0a25264fe9d9 languageName: node linkType: hard "tinyglobby@npm:^0.2.12, tinyglobby@npm:^0.2.15": - version: 0.2.16 - resolution: "tinyglobby@npm:0.2.16" + version: 0.2.17 + resolution: "tinyglobby@npm:0.2.17" dependencies: fdir: ^6.5.0 picomatch: ^4.0.4 - checksum: db9d22ce1deb1095720a683c492cd5e80da0f71fed21ed697e2752f6f298edd8a1249dab197c86a26f001c180594a81bf532400fe519791ed2a2cb57b03bc337 + checksum: 041e73eae568152c376551b21b8a27909d474166a8f405cdb0345991c50cf6afd0f878d7a387645d9c05d8ea2c9a55cc2fd2cfe6c5d5a5264770972b1adcad86 languageName: node linkType: hard @@ -12272,31 +12339,31 @@ __metadata: linkType: hard "typed-array-length@npm:^1.0.7": - version: 1.0.7 - resolution: "typed-array-length@npm:1.0.7" + version: 1.0.8 + resolution: "typed-array-length@npm:1.0.8" dependencies: - call-bind: ^1.0.7 - for-each: ^0.3.3 - gopd: ^1.0.1 - is-typed-array: ^1.1.13 - possible-typed-array-names: ^1.0.0 - reflect.getprototypeof: ^1.0.6 - checksum: deb1a4ffdb27cd930b02c7030cb3e8e0993084c643208e52696e18ea6dd3953dfc37b939df06ff78170423d353dc8b10d5bae5796f3711c1b3abe52872b3774c + call-bind: ^1.0.9 + for-each: ^0.3.5 + gopd: ^1.2.0 + is-typed-array: ^1.1.15 + possible-typed-array-names: ^1.1.0 + reflect.getprototypeof: ^1.0.10 + checksum: 612a90b6c86fed3aa8de7be20e05fd1fcdf4a5a0f2ce7a04da664b8f5b1ff2fb74a50f91d67dea9dbf2e526fbaac4046093fc0314af4e28533a7d1052d37fbd6 languageName: node linkType: hard "typescript-eslint@npm:^8.58.1": - version: 8.59.2 - resolution: "typescript-eslint@npm:8.59.2" + version: 8.62.1 + resolution: "typescript-eslint@npm:8.62.1" dependencies: - "@typescript-eslint/eslint-plugin": 8.59.2 - "@typescript-eslint/parser": 8.59.2 - "@typescript-eslint/typescript-estree": 8.59.2 - "@typescript-eslint/utils": 8.59.2 + "@typescript-eslint/eslint-plugin": 8.62.1 + "@typescript-eslint/parser": 8.62.1 + "@typescript-eslint/typescript-estree": 8.62.1 + "@typescript-eslint/utils": 8.62.1 peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: ">=4.8.4 <6.1.0" - checksum: 5e51f8d6049dfce4379ad02b19a79b782d947a357c180192b872fd9c8b153dd73161a43e6b74bef4cbad4c142f1630e945585d63689b8d1bc923048689bd078a + checksum: 4cd1349fcf738bfd5012bcfebc4624d736e0e43813a0947263dfc29d42983b97ce19b32f24ba987b8108e67b6bb9e144c9587d50b088d997fa87bf8f66fa7d16 languageName: node linkType: hard @@ -12341,17 +12408,17 @@ __metadata: languageName: node linkType: hard -"undici-types@npm:~7.19.0": - version: 7.19.2 - resolution: "undici-types@npm:7.19.2" - checksum: f721026160e1f068a982401d0272b872819c335a2f64783c235ddd37a65ccd94327ec24489cee4556d57c77c14bd68ced60efa5def11cf11e3991f5ebf5e0e72 +"undici-types@npm:~8.3.0": + version: 8.3.0 + resolution: "undici-types@npm:8.3.0" + checksum: bdc1d54d8b48f3f2a801f7707a2ad53b76711039dd9a276382d4bdbbded79355574309425096127a42313731bde7a76cc299855fc06872797edd0acbfcef5bc0 languageName: node linkType: hard -"undici@npm:^6.25.0": - version: 6.25.0 - resolution: "undici@npm:6.25.0" - checksum: aed372e1b0f16045696c878e46b03e97dfd1c6dd650fb2355d48adeecc730c990ab15ab2de5a5855dbfe04c9af403a3d4f702234d3e25e72c475d1fb3a72fcfe +"undici@npm:^8.4.1": + version: 8.6.0 + resolution: "undici@npm:8.6.0" + checksum: 2f18f803e1c4aff66ba31f8b6deb0917db2a7321aef4e2244a5d6093dfc349c10c300ac57ecb30f6d7e927437dfd8a3ed6d63221ffbb01d1e9bb56b31bf7a75e languageName: node linkType: hard @@ -12615,10 +12682,10 @@ __metadata: languageName: node linkType: hard -"whatwg-url-minimum@npm:^0.1.1": - version: 0.1.1 - resolution: "whatwg-url-minimum@npm:0.1.1" - checksum: 0f6629c5ea0d4518f3f3f9dff4441d59bce5655e30291dcedc68b1ffd2e1c8fe8e21e5a83609d197560e75bdbf626b1b020be24b95874418dd0e7ec98ada9e06 +"whatwg-url-minimum@npm:^0.1.2": + version: 0.1.2 + resolution: "whatwg-url-minimum@npm:0.1.2" + checksum: 31a92ddf3c20b77ca85a253cd54e1bf85e0ddf8f01cccc031c38164415ab383b95000f89d1edc338316dc73cab2a758f1cf39fe41f2b0a0b79d3301badd59e8e languageName: node linkType: hard @@ -12679,17 +12746,17 @@ __metadata: linkType: hard "which-typed-array@npm:^1.1.16, which-typed-array@npm:^1.1.19": - version: 1.1.20 - resolution: "which-typed-array@npm:1.1.20" + version: 1.1.22 + resolution: "which-typed-array@npm:1.1.22" dependencies: available-typed-arrays: ^1.0.7 - call-bind: ^1.0.8 + call-bind: ^1.0.9 call-bound: ^1.0.4 for-each: ^0.3.5 get-proto: ^1.0.1 gopd: ^1.2.0 has-tostringtag: ^1.0.2 - checksum: 82527027127c3a6f7b278b5c0059605b968bec780d1ddd7c0ce3c2172ae4b9d2217486123107e31d229ff57ed8cc2bc76d751f290f392ee6d3aa27b26d2ffc12 + checksum: b81581da1a730f9149948f98034af956c2ee68d757b44c2b026d521922a1d73191bde874db6a6519c98e263e4255e24f2b77dadb6a2aa9c0c03174e6063a9f37 languageName: node linkType: hard @@ -12704,14 +12771,14 @@ __metadata: languageName: node linkType: hard -"which@npm:^6.0.0": - version: 6.0.1 - resolution: "which@npm:6.0.1" +"which@npm:^7.0.0": + version: 7.0.0 + resolution: "which@npm:7.0.0" dependencies: isexe: ^4.0.0 bin: node-which: bin/which.js - checksum: dbea77c7d3058bf6c78bf9659d2dce4d2b57d39a15b826b2af6ac2e5a219b99dc8a831b79fdbc453c0598adb4f3f84cf9c2491fd52beb9f5d2dececcad117f68 + checksum: 913a43ac10df37602ba9795a004dd7ab12ba7dd592aca1f08ec333be1fdd6a49bbf119a88c3f8d0ea70eeb6251726e77069251424d73000299a0a840ed000732 languageName: node linkType: hard @@ -12773,8 +12840,8 @@ __metadata: linkType: hard "ws@npm:^7, ws@npm:^7.5.10": - version: 7.5.10 - resolution: "ws@npm:7.5.10" + version: 7.5.11 + resolution: "ws@npm:7.5.11" peerDependencies: bufferutil: ^4.0.1 utf-8-validate: ^5.0.2 @@ -12783,13 +12850,13 @@ __metadata: optional: true utf-8-validate: optional: true - checksum: f9bb062abf54cc8f02d94ca86dcd349c3945d63851f5d07a3a61c2fcb755b15a88e943a63cf580cbdb5b74436d67ef6b67f745b8f7c0814e411379138e1863cb + checksum: 3f32457a6019e6875cc0c2a037d4b4789da22a55b7be278e78845ec7614d28eaab9b36994cfdf9a18a48fe02f11c496168354b96b6ce202f65fe414cdf96f08c languageName: node linkType: hard "ws@npm:^8.11.0, ws@npm:^8.12.1": - version: 8.20.0 - resolution: "ws@npm:8.20.0" + version: 8.21.0 + resolution: "ws@npm:8.21.0" peerDependencies: bufferutil: ^4.0.1 utf-8-validate: ">=5.0.2" @@ -12798,7 +12865,7 @@ __metadata: optional: true utf-8-validate: optional: true - checksum: 2b31d24a53690770564a033c21ea48390f84d23fbc5abc14b2bbec4e112846f2f3ca66caee769a73fb8bc89ba16b452a6911a553e9742bbc75bccb79e203953e + checksum: 83ff89ae011bc5c3c5605a45a0d50e12589143c7500ca4de83a8d43b3cd26e71f422cb3206fd1a9e6d541d666eeb66255c30d095d62d413b3c7afe5d2c5cb928 languageName: node linkType: hard @@ -12872,11 +12939,11 @@ __metadata: linkType: hard "yaml@npm:^2.6.1, yaml@npm:^2.8.2": - version: 2.8.4 - resolution: "yaml@npm:2.8.4" + version: 2.9.0 + resolution: "yaml@npm:2.9.0" bin: yaml: bin.mjs - checksum: 04658713380cecf0b2c13fce255a5ea39c694e4b38a3e8416834e1dc84b70e4cebe0f3d8cbaa5f14901f581b904739b977b0fb6d04aa2e0e0ac95857e2ac37f0 + checksum: f0f74b349c126bb9acf649be1e7efaec5869b1567bdc047e43b176bcdd7730a9bc7ab8fc9c6b1c358e611fab0fe0a0d8933393f1af383a7dc0b4755a1d5f31f9 languageName: node linkType: hard @@ -12888,8 +12955,8 @@ __metadata: linkType: hard "yargs@npm:^17.3.1, yargs@npm:^17.6.2": - version: 17.7.2 - resolution: "yargs@npm:17.7.2" + version: 17.7.3 + resolution: "yargs@npm:17.7.3" dependencies: cliui: ^8.0.1 escalade: ^3.1.1 @@ -12898,7 +12965,7 @@ __metadata: string-width: ^4.2.3 y18n: ^5.0.5 yargs-parser: ^21.1.1 - checksum: 73b572e863aa4a8cbef323dd911d79d193b772defd5a51aab0aca2d446655216f5002c42c5306033968193bdbf892a7a4c110b0d77954a7fdf563e653967b56a + checksum: 16fb2d4866f04aaf74f206d3df843e7a80b58a26fda47af29be51b27564c19966b1674c3acf679a26fdfa8a7e4b1b442a63cdab5db8cd3c57db7912f4473e343 languageName: node linkType: hard @@ -12942,8 +13009,8 @@ __metadata: linkType: hard "zustand@npm:^5.0.4": - version: 5.0.13 - resolution: "zustand@npm:5.0.13" + version: 5.0.14 + resolution: "zustand@npm:5.0.14" peerDependencies: "@types/react": ">=18.0.0" immer: ">=9.0.6" @@ -12958,6 +13025,6 @@ __metadata: optional: true use-sync-external-store: optional: true - checksum: 37f9c1eb888fc6c570f80a182a54844752ffe05dca75356273038effad642892df3a839f16733636a2dd4a49f68c02be27a37b9ba767af03515ef1f8d97468de + checksum: 9b660a4cd510f1c0512a71cecbe6a03ce0c353da93391c83dbc4d4904d7d1b45ecf44791e235bea514450ee50ff771cabf45a9a49edeec44a5886a5cdf1a7d3d languageName: node linkType: hard From a21ea230af712b5f9ecba26a71892865fbbf5f13 Mon Sep 17 00:00:00 2001 From: Krzysztof Faracik Date: Wed, 8 Jul 2026 13:58:56 +0200 Subject: [PATCH 10/42] feat(rag): tighten final chunk selection --- __tests__/hybridRetrieval.test.ts | 202 ++++++++++++++++++++++++++++++ __tests__/rankFusion.test.ts | 57 +++++++++ constants/retrieval.ts | 9 ++ utils/hybridRetrieval.ts | 108 ++++++++++++++-- utils/rankFusion.ts | 55 +++++++- 5 files changed, 413 insertions(+), 18 deletions(-) diff --git a/__tests__/hybridRetrieval.test.ts b/__tests__/hybridRetrieval.test.ts index 02db1e3a..6642663f 100644 --- a/__tests__/hybridRetrieval.test.ts +++ b/__tests__/hybridRetrieval.test.ts @@ -227,6 +227,208 @@ describe('hybridRetrieve', () => { expect(result.map((c) => c.metadata?.name)).toContain('FAQ'); }); + it('caps one document so a second enabled source is not fully evicted', async () => { + const vectorResults = [ + { + id: '1:0', + document: 'doc a passage one', + embedding: [1, 0, 0, 0, 0], + similarity: 0.9, + metadata: { documentId: 1, name: 'DocA' }, + }, + { + id: '1:1', + document: 'doc a passage two', + embedding: [0, 1, 0, 0, 0], + similarity: 0.88, + metadata: { documentId: 1, name: 'DocA' }, + }, + { + id: '1:2', + document: 'doc a passage three', + embedding: [0, 0, 1, 0, 0], + similarity: 0.86, + metadata: { documentId: 1, name: 'DocA' }, + }, + { + id: '1:3', + document: 'doc a passage four', + embedding: [0, 0, 0, 1, 0], + similarity: 0.84, + metadata: { documentId: 1, name: 'DocA' }, + }, + { + id: '1:4', + document: 'doc a passage five', + embedding: [0, 0, 0, 0, 1], + similarity: 0.82, + metadata: { documentId: 1, name: 'DocA' }, + }, + { + id: '2:0', + document: 'doc b passage', + embedding: [1, 1, 0, 0, 0], + similarity: 0.6, + metadata: { documentId: 2, name: 'DocB' }, + }, + ]; + mockKeywordSearch.mockResolvedValue([]); + + const result = await hybridRetrieve({ + prompt: 'zzz', + enabledSourceIds: [1, 2], + vectorStore: makeVectorStore(vectorResults, {}), + sourceNamesById: new Map(), + embeddings: null, + }); + + expect(result.map((c) => c.metadata?.name)).toContain('DocB'); + }); + + it('adaptive-k drops a weak non-adjacent chunk after a large relevance gap', async () => { + const vectorResults = [ + { + id: '1:0', + document: 'the exact code e4021 is here', + embedding: [1, 0], + similarity: 0.9, + metadata: { documentId: 1, name: 'DocA' }, + }, + { + id: '1:5', + document: 'unrelated filler paragraph', + embedding: [0, 1], + similarity: 0.58, + metadata: { documentId: 1, name: 'DocA' }, + }, + ]; + mockKeywordSearch.mockResolvedValue([ + { chunkId: '1:0', documentId: 1, score: -1 }, + ]); + + const result = await hybridRetrieve({ + prompt: 'e4021', + enabledSourceIds: [1], + vectorStore: makeVectorStore(vectorResults, {}), + sourceNamesById: new Map(), + embeddings: null, + }); + + const docs = result.map((c) => c.document); + expect(docs).toContain('the exact code e4021 is here'); + expect(docs).not.toContain('unrelated filler paragraph'); + }); + + it('orders a more-relevant later chunk ahead of a less-relevant earlier one', async () => { + const vectorResults = [ + { + id: '1:2', + document: 'table of contents item 14 principal accountant fees', + embedding: [1, 0], + similarity: 0.5, + metadata: { documentId: 1, name: 'AppleK' }, + }, + { + id: '1:20', + document: 'ben borders will assume the role of principal accounting officer', + embedding: [0, 1], + similarity: 0.9, + metadata: { documentId: 1, name: 'AppleK' }, + }, + ]; + mockKeywordSearch.mockResolvedValue([ + { chunkId: '1:20', documentId: 1, score: -1 }, + { chunkId: '1:2', documentId: 1, score: -1.1 }, + ]); + + const result = await hybridRetrieve({ + prompt: 'who becomes principal accounting officer', + enabledSourceIds: [1], + vectorStore: makeVectorStore(vectorResults, {}), + sourceNamesById: new Map(), + embeddings: null, + }); + + const docs = result.map((c) => c.document); + expect(docs).toContain( + 'ben borders will assume the role of principal accounting officer' + ); + expect(docs[0]).toContain('ben borders'); + expect(docs.indexOf('ben borders will assume the role of principal accounting officer')).toBeLessThan( + docs.indexOf('table of contents item 14 principal accountant fees') + ); + }); + + it('leads with the best seed window even when it sits late in the document (with neighbors)', async () => { + const vectorResults = [ + { + id: '1:2', + document: 'toc item 14 principal accountant fees and services', + embedding: [1, 0], + similarity: 0.44, + metadata: { documentId: 1, name: 'AppleK' }, + }, + { + id: '1:20', + document: 'item 9b ben borders will assume principal accounting officer', + embedding: [0, 1], + similarity: 0.5, + metadata: { documentId: 1, name: 'AppleK' }, + }, + ]; + const vectorsById = { + '1:1': { + id: '1:1', + document: 'toc neighbor before', + embedding: [1, 0], + metadata: JSON.stringify({ documentId: 1, name: 'AppleK' }), + }, + '1:3': { + id: '1:3', + document: 'toc neighbor after', + embedding: [1, 0], + metadata: JSON.stringify({ documentId: 1, name: 'AppleK' }), + }, + '1:19': { + id: '1:19', + document: 'item 9b neighbor before', + embedding: [0, 1], + metadata: JSON.stringify({ documentId: 1, name: 'AppleK' }), + }, + '1:21': { + id: '1:21', + document: 'item 9b neighbor after', + embedding: [0, 1], + metadata: JSON.stringify({ documentId: 1, name: 'AppleK' }), + }, + }; + mockKeywordSearch.mockResolvedValue([ + { chunkId: '1:20', documentId: 1, score: -1 }, + { chunkId: '1:2', documentId: 1, score: -1.1 }, + ]); + + const result = await hybridRetrieve({ + prompt: 'who becomes principal accounting officer', + enabledSourceIds: [1], + vectorStore: makeVectorStore(vectorResults, vectorsById), + sourceNamesById: new Map(), + embeddings: null, + }); + + const docs = result.map((c) => c.document); + const lastNine = Math.max( + docs.indexOf('item 9b neighbor before'), + docs.indexOf('item 9b ben borders will assume principal accounting officer'), + docs.indexOf('item 9b neighbor after') + ); + const firstToc = Math.min( + docs.indexOf('toc neighbor before'), + docs.indexOf('toc item 14 principal accountant fees and services'), + docs.indexOf('toc neighbor after') + ); + expect(lastNine).toBeLessThan(firstToc); + }); + it('expands a selected chunk with its same-document neighbors, in order', async () => { const vectorResults = [ { diff --git a/__tests__/rankFusion.test.ts b/__tests__/rankFusion.test.ts index ae2c85d5..de6d539d 100644 --- a/__tests__/rankFusion.test.ts +++ b/__tests__/rankFusion.test.ts @@ -3,6 +3,7 @@ import { cosineSimilarity, termCoverage, maximalMarginalRelevance, + adaptiveKeepCount, } from '../utils/rankFusion'; describe('reciprocalRankFusion', () => { @@ -122,4 +123,60 @@ describe('maximalMarginalRelevance', () => { expect(selected).toHaveLength(1); }); + + it('caps selections per group, leaving slots for other groups', () => { + const selected = maximalMarginalRelevance( + [ + { id: 'a1', relevance: 1.0, embedding: [1, 0] }, + { id: 'a2', relevance: 0.9, embedding: [0, 1] }, + { id: 'a3', relevance: 0.8, embedding: [1, 1] }, + { id: 'b1', relevance: 0.3, embedding: [1, 0.5] }, + ], + 4, + 0.9, + { groupOf: (c) => c.id[0], maxPerGroup: 2 } + ); + + const ids = selected.map((s) => s.id); + expect(ids.filter((id) => id.startsWith('a'))).toHaveLength(2); + expect(ids).toContain('b1'); + }); + + it('stops instead of overfilling when every remaining item is in a full group', () => { + const selected = maximalMarginalRelevance( + [ + { id: 'a1', relevance: 1.0, embedding: [1, 0] }, + { id: 'a2', relevance: 0.9, embedding: [0, 1] }, + { id: 'a3', relevance: 0.8, embedding: [1, 1] }, + ], + 3, + 0.9, + { groupOf: () => 'a', maxPerGroup: 2 } + ); + + expect(selected).toHaveLength(2); + }); +}); + +describe('adaptiveKeepCount', () => { + it('keeps everything when scores decay gently', () => { + expect(adaptiveKeepCount([1.0, 0.8, 0.6], 1, 0.45)).toBe(3); + }); + + it('cuts at the first large relative drop', () => { + expect(adaptiveKeepCount([0.9, 0.85, 0.2], 1, 0.45)).toBe(2); + }); + + it('cuts to a single strong chunk when the rest fall off a cliff', () => { + expect(adaptiveKeepCount([0.9, 0.1, 0.05], 1, 0.45)).toBe(1); + }); + + it('never trims below minKeep', () => { + expect(adaptiveKeepCount([0.9, 0.1], 2, 0.45)).toBe(2); + }); + + it('never returns fewer than minKeep or more than the list', () => { + expect(adaptiveKeepCount([0.9], 1, 0.45)).toBe(1); + expect(adaptiveKeepCount([], 1, 0.45)).toBe(0); + }); }); diff --git a/constants/retrieval.ts b/constants/retrieval.ts index 8ad8ff47..e8a8af4d 100644 --- a/constants/retrieval.ts +++ b/constants/retrieval.ts @@ -20,6 +20,15 @@ export const COVERAGE_ALPHA = 0.5; /** Bonus that floats a freshly-attached chunk past the gate to the pool's front. */ export const ATTACHMENT_RELEVANCE_BONUS = 10; +/** Max chunks kept from one document in the final selection, applied only when ≥2 documents qualify. */ +export const MAX_CHUNKS_PER_FILE = 3; + +/** Adaptive-k: after MMR, drop trailing chunks once relevance falls below this fraction of the previous. */ +export const ADAPTIVE_K_DROP_RATIO = 0.45; + +/** Adaptive-k never trims below this many non-attachment chunks. */ +export const ADAPTIVE_K_MIN_KEEP = 1; + /** Cosine floor to qualify on semantics alone; above LFM2.5's ~0.35–0.45 noise floor. */ export const STRONG_SEMANTIC_THRESHOLD = 0.55; diff --git a/utils/hybridRetrieval.ts b/utils/hybridRetrieval.ts index a73f8901..c019486e 100644 --- a/utils/hybridRetrieval.ts +++ b/utils/hybridRetrieval.ts @@ -5,6 +5,7 @@ import { extractQueryTerms, stemPrefix } from './queryTerms'; import { keywordSearch } from '../database/keywordIndex'; import { type ContextChunk, sourceKey } from './contextUtils'; import { + adaptiveKeepCount, cosineSimilarity, maximalMarginalRelevance, reciprocalRankFusion, @@ -12,11 +13,13 @@ import { type MMRCandidate, } from './rankFusion'; import { + ADAPTIVE_K_MIN_KEEP, ATTACHMENT_RELEVANCE_BONUS, CANDIDATE_POOL, COVERAGE_ALPHA, KEYWORD_WEIGHT, LEXICAL_MATCH_MIN_SIMILARITY, + MAX_CHUNKS_PER_FILE, MAX_RELEVANT_CHUNKS, STRONG_SEMANTIC_THRESHOLD, VECTOR_WEIGHT, @@ -165,10 +168,36 @@ const expandSelectedWithNeighbors = async ( const result: ContextChunk[] = []; for (const key of groupOrder) { const group = groups.get(key)!; - const orderedIds = [...group.indices.entries()] - .filter(([id]) => chunkById.get(id)?.document) - .sort((a, b) => a[1] - b[1]) - .map(([id]) => id); + + // Order chunks as relevance-ranked windows: seeds most- to least-relevant, + // each emitting its [seed-1, seed, seed+1] run in document order (deduped), + // so the matched chunk leads and a later budget truncation trims the tail. + const seeds = [...group.indices.entries()] + .filter(([id]) => selectedSimilarity.has(id)) + .sort( + (a, b) => + (selectedSimilarity.get(b[0]) ?? 0) - + (selectedSimilarity.get(a[0]) ?? 0) || a[1] - b[1] + ); + + const emitted = new Set(); + const orderedIds: string[] = []; + for (const [seedId, seedIndex] of seeds) { + const parsed = parseChunkId(seedId); + const documentId = parsed?.documentId ?? group.documentId; + const windowIds = ( + parsed + ? [seedIndex - 1, seedIndex, seedIndex + 1].map( + (idx) => `${documentId}:${idx}` + ) + : [seedId] + ).filter((id) => group.indices.has(id)); + for (const id of windowIds) { + if (emitted.has(id) || !chunkById.get(id)?.document) continue; + emitted.add(id); + orderedIds.push(id); + } + } for (const id of orderedIds) { const info = chunkById.get(id)!; @@ -219,12 +248,22 @@ export const hybridRetrieve = async ({ const terms = extractQueryTerms(prompt); const coverageTerms = new Set([...terms].map(stemPrefix)); + // Isolate the vector query: without a usable embedding the store re-embeds and + // rejects, so catch here to degrade to keyword-only instead of returning nothing. const [vectorResults, keywordHits] = await Promise.all([ - vectorStore.query({ - ...(queryEmbedding ? { queryEmbedding } : { queryText: prompt }), - predicate: (r) => enabledSet.has(r.metadata?.documentId), - nResults: CANDIDATE_POOL, - }), + vectorStore + .query({ + ...(queryEmbedding ? { queryEmbedding } : { queryText: prompt }), + predicate: (r) => enabledSet.has(r.metadata?.documentId), + nResults: CANDIDATE_POOL, + }) + .catch((error) => { + console.warn( + 'Vector query failed; degrading to keyword-only retrieval', + error + ); + return [] as Awaited>; + }), keywordSearch(vectorStore.db, [...terms], enabledSourceIds, CANDIDATE_POOL), ]); const keywordIds = new Set(keywordHits.map((hit) => hit.chunkId)); @@ -297,27 +336,70 @@ export const hybridRetrieve = async ({ Number.EPSILON ); + const baseRelevanceById = new Map(); const mmrCandidates: MMRCandidate[] = qualified.map((candidate) => { const base = (fused.get(candidate.id) ?? 0) / maxFused; const coverage = coverageOf(candidate); + const baseRelevance = base * (1 + COVERAGE_ALPHA * coverage); + baseRelevanceById.set(candidate.id, baseRelevance); return { id: candidate.id, relevance: - base * (1 + COVERAGE_ALPHA * coverage) + + baseRelevance + (isAttachment(candidate.documentId) ? ATTACHMENT_RELEVANCE_BONUS : 0), embedding: candidate.embedding, }; }); - const selected = maximalMarginalRelevance(mmrCandidates, MAX_RELEVANT_CHUNKS); + // Cap chunks per document only when the pool spans several documents, so one + // long or freshly-attached file can't evict every other enabled source. + const distinctDocs = new Set(qualified.map((c) => c.documentId)).size; + const selected = maximalMarginalRelevance( + mmrCandidates, + MAX_RELEVANT_CHUNKS, + undefined, + distinctDocs > 1 + ? { + groupOf: (candidate) => { + const documentId = byId.get(candidate.id)?.documentId; + return typeof documentId === 'number' + ? String(documentId) + : undefined; + }, + maxPerGroup: MAX_CHUNKS_PER_FILE, + } + : undefined + ); + + // Adaptive-k on the non-attachment tail: drop chunks past the first large + // relevance gap so a weak distractor never reaches the small reader. + // Attachment chunks are always kept — they are the explicit subject of the turn. + const nonAttachmentByRelevance = selected + .filter((item) => !isAttachment(byId.get(item.id)?.documentId)) + .sort( + (a, b) => + (baseRelevanceById.get(b.id) ?? 0) - (baseRelevanceById.get(a.id) ?? 0) + ); + const keepCount = adaptiveKeepCount( + nonAttachmentByRelevance.map((item) => baseRelevanceById.get(item.id) ?? 0), + ADAPTIVE_K_MIN_KEEP + ); + const keptNonAttachmentIds = new Set( + nonAttachmentByRelevance.slice(0, keepCount).map((item) => item.id) + ); + const kept = selected.filter( + (item) => + isAttachment(byId.get(item.id)?.documentId) || + keptNonAttachmentIds.has(item.id) + ); const ordered = attachmentSet.size - ? [...selected].sort( + ? [...kept].sort( (a, b) => Number(isAttachment(byId.get(b.id)?.documentId)) - Number(isAttachment(byId.get(a.id)?.documentId)) ) - : selected; + : kept; return expandSelectedWithNeighbors( ordered.map((item) => item.id), diff --git a/utils/rankFusion.ts b/utils/rankFusion.ts index 1d11f2e7..e74137a6 100644 --- a/utils/rankFusion.ts +++ b/utils/rankFusion.ts @@ -1,4 +1,8 @@ -import { RRF_K, MMR_LAMBDA } from '../constants/retrieval'; +import { + RRF_K, + MMR_LAMBDA, + ADAPTIVE_K_DROP_RATIO, +} from '../constants/retrieval'; // Pure scoring primitives for hybrid retrieval — plain arithmetic, no model/IO. @@ -65,24 +69,40 @@ export type MMRCandidate = { embedding: number[]; }; +export type MMROptions = { + groupOf?: (candidate: MMRCandidate) => string | undefined; + maxPerGroup?: number; +}; + // Maximal Marginal Relevance: greedily picks `count` candidates, each maximising // `λ·relevance − (1−λ)·maxSimilarityToPicked` — relevant but non-duplicate. // `relevance` may be any scale (only order matters); `lambda` trades relevance -// vs diversity. O(count·pool·dim), no cross-encoder. +// vs diversity. An optional per-group cap keeps one document (or any group) from +// filling every slot. O(count·pool·dim), no cross-encoder. export const maximalMarginalRelevance = ( candidates: MMRCandidate[], count: number, - lambda = MMR_LAMBDA + lambda = MMR_LAMBDA, + { groupOf, maxPerGroup }: MMROptions = {} ): MMRCandidate[] => { const remaining = [...candidates]; const selected: MMRCandidate[] = []; + const groupCounts = new Map(); + + const isGroupFull = (candidate: MMRCandidate): boolean => { + if (!groupOf || !maxPerGroup) return false; + const group = groupOf(candidate); + if (group === undefined) return false; + return (groupCounts.get(group) ?? 0) >= maxPerGroup; + }; while (selected.length < count && remaining.length > 0) { - let bestIndex = 0; + let bestIndex = -1; let bestScore = -Infinity; for (let i = 0; i < remaining.length; i++) { const candidate = remaining[i]!; + if (isGroupFull(candidate)) continue; let maxSimilarity = 0; for (const picked of selected) { @@ -100,8 +120,33 @@ export const maximalMarginalRelevance = ( } } - selected.push(remaining.splice(bestIndex, 1)[0]!); + if (bestIndex === -1) break; + + const picked = remaining.splice(bestIndex, 1)[0]!; + selected.push(picked); + const group = groupOf?.(picked); + if (group !== undefined) { + groupCounts.set(group, (groupCounts.get(group) ?? 0) + 1); + } } return selected; }; + +// Keep leading items until relevance drops below `dropRatio × previous`; at least `minKeep`. +export const adaptiveKeepCount = ( + sortedScoresDesc: number[], + minKeep = 1, + dropRatio = ADAPTIVE_K_DROP_RATIO +): number => { + const total = sortedScoresDesc.length; + if (total <= minKeep) return total; + + for (let i = Math.max(1, minKeep); i < total; i++) { + const prev = sortedScoresDesc[i - 1]!; + const curr = sortedScoresDesc[i]!; + if (prev > 0 && curr < dropRatio * prev) return i; + } + + return total; +}; From 3219505e2496e37fdd1456786ddbc55185705fd9 Mon Sep 17 00:00:00 2001 From: Krzysztof Faracik Date: Wed, 8 Jul 2026 14:18:45 +0200 Subject: [PATCH 11/42] feat(rag): dedup overlap when stitching passages --- __tests__/prepareContext.test.ts | 33 ++++++++++++++++++++++++++++++++ constants/retrieval.ts | 3 +++ utils/contextUtils.ts | 29 ++++++++++++++++++++++++---- 3 files changed, 61 insertions(+), 4 deletions(-) diff --git a/__tests__/prepareContext.test.ts b/__tests__/prepareContext.test.ts index b42eedde..84099a4c 100644 --- a/__tests__/prepareContext.test.ts +++ b/__tests__/prepareContext.test.ts @@ -28,6 +28,39 @@ describe('formatContextChunks / getSourceDocumentsFromChunks', () => { expect(result[0]).not.toMatch(/%|Relevance/); }); + it('stitches adjacent passages without re-printing their shared overlap', () => { + const chunks = [ + makeChunk( + 'The quarterly revenue report shows a total of 1200 units sold in Q3.', + 0.9, + 1, + 'report.pdf' + ), + makeChunk( + 'a total of 1200 units sold in Q3. The following section covers Q4 projections.', + 0.85, + 1, + 'report.pdf' + ), + ]; + const [source] = getSourceDocumentsFromChunks(chunks); + const passage = source.passage!; + + expect(passage.split('a total of 1200 units sold in Q3')).toHaveLength(2); + expect(passage).toContain('The following section covers Q4 projections'); + }); + + it('leaves non-overlapping passages fully intact', () => { + const chunks = [ + makeChunk('completely distinct first passage', 0.9, 1, 'doc.pdf'), + makeChunk('an entirely separate second passage', 0.8, 1, 'doc.pdf'), + ]; + const [source] = getSourceDocumentsFromChunks(chunks); + + expect(source.passage).toContain('completely distinct first passage'); + expect(source.passage).toContain('an entirely separate second passage'); + }); + it('groups chunks of one document into a single source, preserving input order', () => { const chunks = [ makeChunk('a-1', 0.9, 1, 'doc-a.pdf'), diff --git a/constants/retrieval.ts b/constants/retrieval.ts index e8a8af4d..c6391026 100644 --- a/constants/retrieval.ts +++ b/constants/retrieval.ts @@ -37,3 +37,6 @@ export const LEXICAL_MATCH_MIN_SIMILARITY = 0.1; export const TEXT_SPLITTER_CHUNK_SIZE = 1000; export const TEXT_SPLITTER_CHUNK_OVERLAP = 200; + +/** Min matched run to treat as overlap when stitching passages — below the splitter overlap, above coincidental repetition. */ +export const MIN_STITCH_OVERLAP = 24; diff --git a/utils/contextUtils.ts b/utils/contextUtils.ts index 861cc9cd..45536d47 100644 --- a/utils/contextUtils.ts +++ b/utils/contextUtils.ts @@ -1,3 +1,5 @@ +import { MIN_STITCH_OVERLAP } from '../constants/retrieval'; + export type ContextChunk = { document?: string; similarity: number; @@ -64,11 +66,30 @@ const groupChunksByDocument = (chunks: ContextChunk[]): DocumentGroup[] => { return order.map((key) => groups.get(key)!); }; -const joinGroupPassages = (group: DocumentGroup): string => - group.chunks +// Drop the leading part of `next` that repeats the tail of `prev`, longest match first. +const stripLeadingOverlap = (prev: string, next: string): string => { + const max = Math.min(prev.length, next.length); + for (let len = max; len >= MIN_STITCH_OVERLAP; len--) { + if (prev.slice(prev.length - len) === next.slice(0, len)) { + return next.slice(len); + } + } + return next; +}; + +const joinGroupPassages = (group: DocumentGroup): string => { + const passages = group.chunks .map((chunk) => chunk.document?.trim() ?? '') - .filter(Boolean) - .join('\n\n'); + .filter(Boolean); + if (passages.length === 0) return ''; + + let stitched = passages[0]!; + for (let i = 1; i < passages.length; i++) { + const deduped = stripLeadingOverlap(stitched, passages[i]!).trimStart(); + if (deduped) stitched += `\n\n${deduped}`; + } + return stitched; +}; export const formatContextChunks = (chunks: ContextChunk[]): string[] => groupChunksByDocument(chunks).map( From ee71db01046fe799d2c87851bbca067e416e67cc Mon Sep 17 00:00:00 2001 From: Krzysztof Faracik Date: Wed, 8 Jul 2026 14:44:35 +0200 Subject: [PATCH 12/42] fix(rag): pack prompt to budget without dropping the answer --- __tests__/promptUtils.test.ts | 26 ++++++++++++++++++++++++-- utils/promptUtils.ts | 32 ++++++++++++++++++-------------- 2 files changed, 42 insertions(+), 16 deletions(-) diff --git a/__tests__/promptUtils.test.ts b/__tests__/promptUtils.test.ts index 4c267099..3d751bc8 100644 --- a/__tests__/promptUtils.test.ts +++ b/__tests__/promptUtils.test.ts @@ -1,6 +1,7 @@ import { prepareMessagesForLLM } from '../utils/promptUtils'; import { Message, ChatSettings } from '../database/chatRepository'; import { Model } from '../database/modelRepository'; +import { getPromptCharBudget } from '../constants/context-window'; const baseSettings = { systemPrompt: 'You are a helpful assistant.', @@ -303,7 +304,7 @@ describe('prepareMessagesForLLM', () => { id, chatId: 1, role, - content: 'x'.repeat(2000), + content: 'x'.repeat(getPromptCharBudget(baseModel)), timestamp: 0, }); @@ -364,7 +365,7 @@ describe('prepareMessagesForLLM', () => { { id: 1, chatId: 1, role: 'user', content: 'question', timestamp: 0 }, { id: 2, chatId: 1, role: 'assistant', content: '', timestamp: 0 }, ]; - const hugeContext = 'y'.repeat(20000); + const hugeContext = 'y'.repeat(getPromptCharBudget(baseModel) * 2 + 10000); const result = prepareMessagesForLLM( messages, @@ -379,6 +380,27 @@ describe('prepareMessagesForLLM', () => { expect(last.content.length).toBeLessThan(hugeContext.length); }); + it('cuts an over-budget context at a chunk boundary, keeping the leading section', () => { + const messages: Message[] = [ + { id: 1, chatId: 1, role: 'user', content: 'question', timestamp: 0 }, + { id: 2, chatId: 1, role: 'assistant', content: '', timestamp: 0 }, + ]; + const answer = 'NEEDLE_ANSWER_XYZ is the answer.'; + const filler = 'FILLERBLOCK'.repeat(3000); + const context = [`${answer}\n\n${filler}`]; + + const result = prepareMessagesForLLM( + messages, + context, + baseSettings, + baseModel + ); + + const last = result[result.length - 1]; + expect(last.content).toContain('NEEDLE_ANSWER_XYZ'); + expect(last.content).not.toContain('FILLERBLOCK'); + }); + it('does not trim when everything comfortably fits', () => { const messages = makeMessages(6); const result = prepareMessagesForLLM( diff --git a/utils/promptUtils.ts b/utils/promptUtils.ts index 19643805..a92fd816 100644 --- a/utils/promptUtils.ts +++ b/utils/promptUtils.ts @@ -8,16 +8,11 @@ import { type Message as ExecutorchMessage } from 'react-native-executorch'; import { getPromptCharBudget } from '../constants/context-window'; const CONTEXT_INSTRUCTION = ` -IMPORTANT CONTEXT INFORMATION: -You have access to relevant excerpts from the user's document sources. Use this context to provide accurate, well-informed responses. Always prioritize information from the provided context when it's relevant to the user's question. -Instructions for using context: -- The context is delimited by and tags -- Retrieved passages are labeled "Source N: "; a freshly attached document's overview is labeled "Current Attachment Source: (Overview)" -- The block is the ONLY authoritative source for the current question. Answer strictly from the excerpts inside it. -- Do NOT describe, summarize, or answer about any document that is not present in the current block, even if it was discussed or attached in an earlier turn of this conversation. Earlier turns are for conversational continuity only, not a source of document facts. -- If information from context conflicts with your general knowledge, prioritize the context -- If the context doesn't contain relevant information say "I don't know" or "The provided context does not contain the information"`; +IMPORTANT CONTEXT INFORMATION: +The block below holds excerpts from the user's documents ("Source N: ", or "(Overview)" for a freshly attached file). It is the ONLY authoritative source for this question — answer strictly from it and prefer it over your own knowledge. +Do not answer about any document that is not in the current block, even if it appeared earlier in the chat. +If the block does not contain the answer, say "I don't know".`; const getPreferredSourceInstruction = (sources?: SourceDocument[]) => { if (!sources?.length) return ''; @@ -26,10 +21,7 @@ const getPreferredSourceInstruction = (sources?: SourceDocument[]) => { return ` CURRENT ATTACHMENT PRIORITY: -The user just attached these document sources to the current message: ${sourceNames}. -They are the primary subject of the latest question. When the user says "this file", "the document", "the file", "it" or asks about a format, they mean these current attachment sources — never a document that only appeared earlier in the conversation. -Base your answer on the documents present in the block below. Only bring in another source when these attachment sources do not contain the answer, or the user's question explicitly asks about a different document. -You may still use earlier conversation for continuity when it does not conflict with the current attachment sources.`; +The user just attached: ${sourceNames}. Treat these as the subject of the question — "this file", "the document", "it" refer to them. Base the answer on them; bring in another source only if they lack the answer. You may still use earlier conversation for continuity.`; }; export const prepareMessagesForLLM = ( @@ -96,7 +88,19 @@ export const prepareMessagesForLLM = ( ${userText} `; - lastMessage.content = wrap(safeContext); + const availableForLast = Math.max(0, budgetChars - systemChars); + let finalContext = safeContext; + if (wrap(finalContext).length > availableForLast) { + const overhead = wrap('').length; + const room = Math.max(0, availableForLast - overhead); + const hardSlice = safeContext.slice(0, room); + const boundary = Math.max( + hardSlice.lastIndexOf('\n\n'), + hardSlice.lastIndexOf('\n ---') + ); + finalContext = boundary > 0 ? hardSlice.slice(0, boundary) : hardSlice; + } + lastMessage.content = wrap(finalContext); } const mandatoryChars = systemChars + lastMessage.content.length; From 0927606409ba912d8998ccc1b71ed45a6c6e7585 Mon Sep 17 00:00:00 2001 From: Krzysztof Faracik Date: Wed, 8 Jul 2026 16:24:22 +0200 Subject: [PATCH 13/42] fix(chat): show legacy-document notice as a reactive toast --- __tests__/legacyChat.test.ts | 38 ++++++++++++----------- components/chat-screen/ChatScreen.tsx | 18 ++--------- constants/chat.ts | 1 + hooks/useLegacyChatNotice.ts | 29 ++++++++++++++++++ utils/legacyChat.ts | 43 +++++++++++++++------------ 5 files changed, 78 insertions(+), 51 deletions(-) create mode 100644 constants/chat.ts create mode 100644 hooks/useLegacyChatNotice.ts diff --git a/__tests__/legacyChat.test.ts b/__tests__/legacyChat.test.ts index 4ce5b7b4..73cd868a 100644 --- a/__tests__/legacyChat.test.ts +++ b/__tests__/legacyChat.test.ts @@ -1,12 +1,8 @@ -import { - chatPredatesSourceLinking, - buildLegacyChatWarningMessage, - LEGACY_CHAT_WARNING_MESSAGE_ID, -} from '../utils/legacyChat'; +import { chatPredatesSourceLinking } from '../utils/legacyChat'; import { setSourceLinkingBoundary } from '../utils/sourceLinkingBoundary'; import { Message } from '../database/chatRepository'; -const BOUNDARY = 100; +const BOUNDARY = 200; const message = (overrides: Partial): Message => ({ id: 1, @@ -59,6 +55,25 @@ describe('chatPredatesSourceLinking', () => { ).toBe(false); }); + it('keeps flagging a legacy chat after a new-era turn retrieves a source', () => { + expect( + chatPredatesSourceLinking( + [ + message({ id: 10, role: 'user', documentName: 'report.pdf' }), + message({ id: 11, role: 'assistant', content: 'summary' }), + message({ id: 204, role: 'user', documentName: 'other.pdf' }), + message({ + id: 205, + role: 'assistant', + content: 'answer [1]', + sourceDocuments: [{ name: 'other.pdf', documentId: 9 }], + }), + ], + BOUNDARY + ) + ).toBe(true); + }); + it('does NOT flag a new-era chat whose upload was interrupted before sourceDocuments', () => { expect( chatPredatesSourceLinking( @@ -107,14 +122,3 @@ describe('chatPredatesSourceLinking', () => { ).toBe(false); }); }); - -describe('buildLegacyChatWarningMessage', () => { - it('builds a transient event message carrying the chat id', () => { - const warning = buildLegacyChatWarningMessage(42); - expect(warning.id).toBe(LEGACY_CHAT_WARNING_MESSAGE_ID); - expect(warning.id).toBeLessThan(0); - expect(warning.chatId).toBe(42); - expect(warning.role).toBe('event'); - expect(warning.content.length).toBeGreaterThan(0); - }); -}); diff --git a/components/chat-screen/ChatScreen.tsx b/components/chat-screen/ChatScreen.tsx index 7d81bd33..ed473ea1 100644 --- a/components/chat-screen/ChatScreen.tsx +++ b/components/chat-screen/ChatScreen.tsx @@ -37,10 +37,7 @@ import { useSQLiteContext } from 'expo-sqlite'; import { useVectorStore } from '../../context/VectorStoreContext'; import { Attachment } from '../../hooks/useAttachment'; import { buildMessageSources } from '../../utils/messageSources'; -import { - chatPredatesSourceLinking, - buildLegacyChatWarningMessage, -} from '../../utils/legacyChat'; +import { useLegacyChatNotice } from '../../hooks/useLegacyChatNotice'; import { useSourceStore } from '../../store/sourceStore'; import useChatSettings from '../../hooks/useChatSettings'; import Toast from 'react-native-toast-message'; @@ -309,16 +306,7 @@ export default function ChatScreen({ const isEmpty = !isLoading && messageHistory.length === 0; - // Conversations created before documents were linked to messages get a - // transient (unsaved) notice at the top explaining the missing source. The - // real history is untouched — this only affects what is rendered. - const displayedHistory = useMemo( - () => - chatPredatesSourceLinking(messageHistory) - ? [buildLegacyChatWarningMessage(chatId), ...messageHistory] - : messageHistory, - [messageHistory, chatId] - ); + useLegacyChatNotice(messageHistory); const { height: windowHeight } = useWindowDimensions(); const gradientProgress = useSharedValue(isEmpty ? 1 : 0); @@ -343,7 +331,7 @@ export default function ChatScreen({ { + const { theme } = useTheme(); + const isDrawerOpen = useDrawerStatus() === 'open'; + const isLegacy = useMemo( + () => diagnoseLegacyChat(messageHistory).isLegacy, + [messageHistory] + ); + const show = isLegacy && !isDrawerOpen; + + useEffect(() => { + if (!show) return; + Toast.show({ + type: 'defaultToast', + text1: + 'Note: this conversation predates document linking, so its attached document is no longer available here. Attach it again in a new chat to use it as a source.', + autoHide: false, + topOffset: theme.insets.top + LEGACY_CHAT_NOTICE_TOP_OFFSET, + }); + return () => Toast.hide(); + }, [show, theme.insets.top]); +}; diff --git a/utils/legacyChat.ts b/utils/legacyChat.ts index bbe4d27e..4a25f6b4 100644 --- a/utils/legacyChat.ts +++ b/utils/legacyChat.ts @@ -1,31 +1,36 @@ import { Message } from '../database/chatRepository'; import { getSourceLinkingBoundary } from './sourceLinkingBoundary'; -// Negative synthetic id, distinct from real ids and the -1 stream placeholder. -export const LEGACY_CHAT_WARNING_MESSAGE_ID = -100; +export type LegacyChatDiagnosis = { + hasLegacyDocument: boolean; + hasSourceLinking: boolean; + isLegacy: boolean; +}; -// True when a legacy document (attached before the boundary) is present but no -// message carries sourceDocuments. The boundary excludes new interrupted uploads. -export const chatPredatesSourceLinking = ( +// Both checks are scoped to the pre-boundary era (id <= boundary) on purpose: a +// new turn that retrieves a source (id > boundary) must not flip a legacy chat to +// "linked" and drop the notice mid-conversation. +export const diagnoseLegacyChat = ( messages: Message[], boundaryMessageId: number = getSourceLinkingBoundary() -): boolean => { - const usedLegacyDocument = messages.some( +): LegacyChatDiagnosis => { + const hasLegacyDocument = messages.some( (message) => !!message.documentName && message.id <= boundaryMessageId ); - if (!usedLegacyDocument) return false; - const hasSourceLinking = messages.some( - (message) => !!message.sourceDocuments && message.sourceDocuments.length > 0 + (message) => + message.id <= boundaryMessageId && + !!message.sourceDocuments && + message.sourceDocuments.length > 0 ); - return !hasSourceLinking; + return { + hasLegacyDocument, + hasSourceLinking, + isLegacy: hasLegacyDocument && !hasSourceLinking, + }; }; -export const buildLegacyChatWarningMessage = (chatId: number): Message => ({ - id: LEGACY_CHAT_WARNING_MESSAGE_ID, - chatId, - role: 'event', - content: - 'Note: this conversation predates document linking, so its attached document is no longer available here. Attach it again in a new chat to use it as a source.', - timestamp: 0, -}); +export const chatPredatesSourceLinking = ( + messages: Message[], + boundaryMessageId: number = getSourceLinkingBoundary() +): boolean => diagnoseLegacyChat(messages, boundaryMessageId).isLegacy; From d956ad32a79dac1db95d406f5ae77253c76f1230 Mon Sep 17 00:00:00 2001 From: Krzysztof Faracik Date: Thu, 9 Jul 2026 11:18:38 +0200 Subject: [PATCH 14/42] perf(chat): render the sent message instantly, defer retrieval --- __tests__/llmStore.test.ts | 35 +++++---- components/chat-screen/ChatScreen.tsx | 106 +++++++++++++------------- store/llmStore.ts | 79 +++++++++++-------- 3 files changed, 125 insertions(+), 95 deletions(-) diff --git a/__tests__/llmStore.test.ts b/__tests__/llmStore.test.ts index 8c87c6ed..420209b3 100644 --- a/__tests__/llmStore.test.ts +++ b/__tests__/llmStore.test.ts @@ -23,6 +23,12 @@ const mockLLMModule = LLMModule as jest.Mocked; const mockPersistMessage = chatRepository.persistMessage as jest.Mock; const mockGetChatMessages = chatRepository.getChatMessages as jest.Mock; +const noSources = async () => ({ + context: [] as string[], + sourceDocuments: [], + preferredSourceDocuments: [], +}); + const mockDb = {} as any; const baseModel = { @@ -310,13 +316,13 @@ describe('sendChatMessage', () => { it('returns early when db is not set', async () => { useLLMStore.setState({ db: null }); - await useLLMStore.getState().sendChatMessage('hi', 1, [], settings); + await useLLMStore.getState().sendChatMessage('hi', 1, noSources, settings); expect(mockPersistMessage).not.toHaveBeenCalled(); }); it('returns early when model is not loaded', async () => { useLLMStore.setState({ model: null }); - await useLLMStore.getState().sendChatMessage('hi', 1, [], settings); + await useLLMStore.getState().sendChatMessage('hi', 1, noSources, settings); expect(mockPersistMessage).not.toHaveBeenCalled(); }); @@ -327,7 +333,7 @@ describe('sendChatMessage', () => { activeChatMessages: [], }); - await useLLMStore.getState().sendChatMessage('hello', 1, [], settings); + await useLLMStore.getState().sendChatMessage('hello', 1, noSources, settings); expect(mockPersistMessage).toHaveBeenCalledTimes(2); expect(mockPersistMessage).toHaveBeenCalledWith( @@ -350,7 +356,7 @@ describe('sendChatMessage', () => { activeChatMessages: [], }); - await useLLMStore.getState().sendChatMessage('hello', 1, [], settings); + await useLLMStore.getState().sendChatMessage('hello', 1, noSources, settings); expect(useLLMStore.getState().isProcessingPrompt).toBe(false); expect(useLLMStore.getState().isGenerating).toBe(false); @@ -368,7 +374,7 @@ describe('sendChatMessage', () => { activeChatMessages: [], }); - await useLLMStore.getState().sendChatMessage('ping', 1, [], settings); + await useLLMStore.getState().sendChatMessage('ping', 1, noSources, settings); expect(messagesBeforeGenerate).toHaveLength(2); expect(messagesBeforeGenerate[0].role).toBe('user'); @@ -384,7 +390,7 @@ describe('sendChatMessage', () => { activeChatMessages: [], }); - await useLLMStore.getState().sendChatMessage('hello', 1, [], settings); + await useLLMStore.getState().sendChatMessage('hello', 1, noSources, settings); expect(useLLMStore.getState().isGenerating).toBe(false); expect(useLLMStore.getState().isProcessingPrompt).toBe(false); @@ -400,21 +406,24 @@ describe('sendChatMessage', () => { activeChatMessages: [], }); - await useLLMStore.getState().sendChatMessage('hello', 1, [], settings); + await useLLMStore.getState().sendChatMessage('hello', 1, noSources, settings); expect(useLLMStore.getState().isGenerating).toBe(false); expect(useLLMStore.getState().isProcessingPrompt).toBe(false); }); it('does not update performance metrics on last message when user navigated away', async () => { - mockInstance.generate.mockResolvedValue('response'); + mockInstance.generate.mockImplementation(async () => { + useLLMStore.setState({ activeChatId: 99 }); + return 'response'; + }); useLLMStore.setState({ model: baseModel, - activeChatId: 99, // different from chatId=1 + activeChatId: 1, activeChatMessages: [], }); - await useLLMStore.getState().sendChatMessage('hello', 1, [], settings); + await useLLMStore.getState().sendChatMessage('hello', 1, noSources, settings); // complete called without perf data — last message should not have timeToFirstToken const messages = useLLMStore.getState().activeChatMessages; @@ -524,7 +533,7 @@ describe('sendChatMessage imagePath', () => { it('passes imagePath to persistMessage for user message when provided', async () => { await useLLMStore .getState() - .sendChatMessage('What is this?', 1, [], settings, '/local/image.jpg'); + .sendChatMessage('What is this?', 1, noSources, settings, '/local/image.jpg'); expect(mockPersistMessage).toHaveBeenCalledWith( expect.anything(), @@ -533,7 +542,7 @@ describe('sendChatMessage imagePath', () => { }); it('passes undefined imagePath to persistMessage when not provided', async () => { - await useLLMStore.getState().sendChatMessage('Hello', 1, [], settings); + await useLLMStore.getState().sendChatMessage('Hello', 1, noSources, settings); expect(mockPersistMessage).toHaveBeenCalledWith( expect.anything(), @@ -552,7 +561,7 @@ describe('sendChatMessage imagePath', () => { await useLLMStore .getState() - .sendChatMessage('What is this?', 1, [], settings, '/local/image.jpg'); + .sendChatMessage('What is this?', 1, noSources, settings, '/local/image.jpg'); expect(mockInstance.generate).toHaveBeenCalledTimes(1); const calledMessages = mockInstance.generate.mock.calls[0][0]; diff --git a/components/chat-screen/ChatScreen.tsx b/components/chat-screen/ChatScreen.tsx index ed473ea1..1855451f 100644 --- a/components/chat-screen/ChatScreen.tsx +++ b/components/chat-screen/ChatScreen.tsx @@ -76,7 +76,6 @@ export default function ChatScreen({ isGenerating, sendChatMessage, loadModel, - setActiveChatId, model: loadedModel, } = useLLMStore(); const { getModelById } = useModelStore(); @@ -138,7 +137,8 @@ export default function ChatScreen({ return; let targetChatId = chatId!; - if (!(await checkIfChatExists(db, targetChatId))) { + const isNewChat = !(await checkIfChatExists(db, targetChatId)); + if (isNewChat) { const docName = attachments?.find((a) => a.type === 'document')?.name; const titleSource = userInput.trim() || docName || 'New chat'; const newChatTitle = @@ -147,10 +147,7 @@ export default function ChatScreen({ : titleSource; const newChatId = await addChat(newChatTitle, model!.id); if (!newChatId) return; - targetChatId = newChatId; - await setActiveChatId(targetChatId); - router.replace(`/chat/${targetChatId}`); } let persistedImagePath: string | undefined = imagePath; @@ -177,51 +174,10 @@ export default function ChatScreen({ // https://vercel.com/blog/how-we-built-the-v0-ios-app messagesRef.current?.onMessageSent(); - // Resolve which attachment sources actually exist, then build the RAG - // context + citations for this turn (see utils/messageSources). - const allSources = useSourceStore.getState().sources; - const existingSourceIds = new Set(allSources.map((source) => source.id)); - const attachmentSourceIds = (attachments || []) - .filter((a) => a.type === 'document' && a.sourceId) - .map((a) => a.sourceId!) - .filter((sourceId) => { - const exists = existingSourceIds.has(sourceId); - if (!exists) { - console.warn('Skipping missing attachment source before send', { - chatId: targetChatId, - sourceId, - }); - } - return exists; - }); - - let context: string[] = []; - let sourceDocuments: SourceDocument[] = []; - let preferredSourceDocuments: SourceDocument[] = []; - if (vectorStore) { - ({ context, sourceDocuments, preferredSourceDocuments } = - await buildMessageSources({ - userInput, - attachmentSourceIds, - enabledSources, - sources: allSources, - vectorStore, - embeddings, - })); - } - - // Enable new sources for this chat (persists for future messages) - for (const sourceId of attachmentSourceIds) { - if (!enabledSources.includes(sourceId)) { - await enableSource(targetChatId, sourceId); - } - } - const settings: ChatSettings = { systemPrompt: chatSettings.systemPrompt, thinkingEnabled: chatSettings.thinkingEnabled, }; - const docAttachments = attachments?.filter((a) => a.type === 'document') || []; const docName = @@ -229,16 +185,64 @@ export default function ChatScreen({ .map((a) => a.name) .filter(Boolean) .join(', ') || undefined; - await sendChatMessage( + + // Deferred so retrieval runs only after the optimistic message is on screen. + const buildSources = async () => { + const allSources = useSourceStore.getState().sources; + const existingSourceIds = new Set(allSources.map((source) => source.id)); + const attachmentSourceIds = (attachments || []) + .filter((a) => a.type === 'document' && a.sourceId) + .map((a) => a.sourceId!) + .filter((sourceId) => { + const exists = existingSourceIds.has(sourceId); + if (!exists) { + console.warn('Skipping missing attachment source before send', { + chatId: targetChatId, + sourceId, + }); + } + return exists; + }); + + let context: string[] = []; + let sourceDocuments: SourceDocument[] = []; + let preferredSourceDocuments: SourceDocument[] = []; + if (vectorStore) { + ({ context, sourceDocuments, preferredSourceDocuments } = + await buildMessageSources({ + userInput, + attachmentSourceIds, + enabledSources, + sources: allSources, + vectorStore, + embeddings, + })); + } + + // Enable new sources for this chat (persists for future messages) + for (const sourceId of attachmentSourceIds) { + if (!enabledSources.includes(sourceId)) { + await enableSource(targetChatId, sourceId); + } + } + + return { context, sourceDocuments, preferredSourceDocuments }; + }; + + const generation = sendChatMessage( userInput, targetChatId, - context, + buildSources, settings, persistedImagePath, - docName, - sourceDocuments, - preferredSourceDocuments + docName ); + + if (isNewChat) { + router.replace(`/chat/${targetChatId}`); + } + + await generation; }; const handleSelectModel = async (selectedModel: Model) => { diff --git a/store/llmStore.ts b/store/llmStore.ts index 95136e69..d5b5befb 100644 --- a/store/llmStore.ts +++ b/store/llmStore.ts @@ -18,7 +18,7 @@ import { Feedback } from '../utils/Feedback'; import { prepareMessagesForLLM } from '../utils/promptUtils'; import { getGenerationConfigForModel } from '../constants/default-models'; -interface LLMStore { +export interface LLMStore { isLoading: boolean; isGenerating: boolean; isProcessingPrompt: boolean; @@ -39,12 +39,14 @@ interface LLMStore { sendChatMessage: ( newMessage: string, chatId: number, - context: string[], + buildSources: () => Promise<{ + context: string[]; + sourceDocuments?: SourceDocument[]; + preferredSourceDocuments?: SourceDocument[]; + }>, settings: ChatSettings, imagePath?: string, - documentName?: string, - sourceDocuments?: SourceDocument[], - preferredSourceDocuments?: SourceDocument[] + documentName?: string ) => Promise; runBenchmark: () => Promise; interrupt: () => void; @@ -126,6 +128,7 @@ const updateChatStateForGeneration = ( set({ isProcessingPrompt: true, generatingForChatId: data?.chatId, + ...(data?.chatId !== undefined ? { activeChatId: data.chatId } : {}), activeChatMessages: data?.activeChatMessages, }); break; @@ -352,12 +355,10 @@ export const useLLMStore = create((set, get) => ({ sendChatMessage: async ( newMessage, chatId, - context, + buildSources, settings, imagePath, - documentName, - sourceDocuments, - preferredSourceDocuments + documentName ) => { const { db, model: currentModel, activeChatMessages } = get(); if (!db || !currentModel) { @@ -365,35 +366,50 @@ export const useLLMStore = create((set, get) => ({ return; } + const tempUserId = -Date.now(); + const userMessage: Message = { + id: tempUserId, + role: 'user', + content: newMessage, + chatId, + timestamp: Date.now(), + imagePath, + documentName, + }; + const assistantPlaceholder: Message = { + role: 'assistant', + content: '', + modelName: currentModel.modelName, + chatId: chatId, + timestamp: Date.now(), + id: -1, + }; + + updateChatStateForGeneration(set, 'start', { + chatId, + activeChatMessages: [ + ...activeChatMessages, + userMessage, + assistantPlaceholder, + ], + }); + try { - const userMessage: Omit = { + const userMessageId = await persistMessage(db, { role: 'user', content: newMessage, chatId, - timestamp: Date.now(), imagePath, documentName, - }; - const assistantPlaceholder: Message = { - role: 'assistant', - content: '', - modelName: currentModel.modelName, - chatId: chatId, - timestamp: Date.now(), - id: -1, - sourceDocuments, - }; - const userMessageId = await persistMessage(db, userMessage); - const updatedChatMessages = [ - ...activeChatMessages, - { ...userMessage, id: userMessageId }, - assistantPlaceholder, - ]; - - updateChatStateForGeneration(set, 'start', { - chatId, - activeChatMessages: updatedChatMessages, }); + set((state) => ({ + activeChatMessages: state.activeChatMessages.map((msg) => + msg.id === tempUserId ? { ...msg, id: userMessageId } : msg + ), + })); + + const { context, sourceDocuments, preferredSourceDocuments } = + await buildSources(); const messagesWithSystemPrompt = prepareMessagesForLLM( get().activeChatMessages, @@ -420,6 +436,7 @@ export const useLLMStore = create((set, get) => ({ await persistMessage(db, { ...assistantPlaceholder, content: finalResponse, + sourceDocuments, tokensPerSecond: responsePerformance.tokensPerSecond, timeToFirstToken: responsePerformance.timeToFirstToken, }); From 27374b4cccb588e9906a6af6a8d0f8892bc549df Mon Sep 17 00:00:00 2001 From: Krzysztof Faracik Date: Thu, 9 Jul 2026 13:10:12 +0200 Subject: [PATCH 15/42] feat(citations): attribute replies to the sources they actually used --- __tests__/messageSources.test.ts | 175 ++++++++++++++++++++++++++++++- __tests__/prepareContext.test.ts | 42 ++++++++ constants/retrieval.ts | 5 + store/llmStore.ts | 24 ++++- utils/contextUtils.ts | 12 ++- utils/messageSources.ts | 115 ++++++++++++++++---- 6 files changed, 347 insertions(+), 26 deletions(-) diff --git a/__tests__/messageSources.test.ts b/__tests__/messageSources.test.ts index c3bf9373..062f5789 100644 --- a/__tests__/messageSources.test.ts +++ b/__tests__/messageSources.test.ts @@ -1,5 +1,12 @@ -import { mergeAttachmentFirst } from '../utils/messageSources'; +import { + assembleSourceDocuments, + mergeAttachmentFirst, + pickCitationsByAnswer, + restrictCitationsToContext, + type SourceRow, +} from '../utils/messageSources'; import { SourceDocument } from '../database/chatRepository'; +import { formatContextChunks } from '../utils/contextUtils'; const doc = (documentId: number | undefined, name: string): SourceDocument => ({ documentId, @@ -44,3 +51,169 @@ describe('mergeAttachmentFirst', () => { expect(result.map((d) => d.name)).toEqual(['a.pdf', 'b.pdf']); }); }); + +describe('assembleSourceDocuments', () => { + const source = (id: number, name: string, firstChunk?: string): SourceRow => ({ + id, + name, + firstChunk, + }); + + it('returns the merged citations when retrieval produced sources', () => { + const retrieved = [doc(1, 'old.pdf'), doc(2, 'attachment.txt')]; + const result = assembleSourceDocuments( + retrieved, + [doc(2, 'attachment.txt')], + [2], + [source(1, 'old.pdf'), source(2, 'attachment.txt')], + true + ); + + expect(result.map((d) => d.documentId)).toEqual([2, 1]); + }); + + it('links the active document when context was sent but nothing was cited', () => { + const result = assembleSourceDocuments( + [], + [], + [], + [source(9, 'report.pdf', 'overview text')], + true + ); + + expect(result).toEqual([ + { documentId: 9, name: 'report.pdf', passage: 'overview text' }, + ]); + }); + + it('stays empty when no context reached the model', () => { + const result = assembleSourceDocuments( + [], + [], + [], + [source(9, 'report.pdf', 'overview text')], + false + ); + + expect(result).toEqual([]); + }); +}); + +describe('restrictCitationsToContext', () => { + const cite = (documentId: number, name: string): SourceDocument => ({ + documentId, + name, + }); + const block = (documentId: number, name: string, document: string) => ({ + document, + similarity: 0.8, + metadata: { documentId, name }, + }); + + it('drops documents whose block was truncated out of the prompt', () => { + const cited = [ + cite(21, 'polityka_urlopowa_2026.pdf'), + cite(20, 'sample.htm'), + cite(19, '_10-K-2025-As-Filed.pdf'), + ]; + const prompt = formatContextChunks([ + block(21, 'polityka_urlopowa_2026.pdf', 'vacation'), + ]).join(' '); + + const result = restrictCitationsToContext(cited, prompt, [ + doc(21, 'polityka_urlopowa_2026.pdf'), + ]); + + expect(result.map((d) => d.documentId)).toEqual([21]); + }); + + it('keeps every document whose block survived', () => { + const cited = [cite(1, 'a.pdf'), cite(2, 'b.pdf')]; + const prompt = formatContextChunks([ + block(1, 'a.pdf', 'x'), + block(2, 'b.pdf', 'y'), + ]).join(' '); + + const result = restrictCitationsToContext(cited, prompt, []); + + expect(result.map((d) => d.documentId)).toEqual([1, 2]); + }); + + it('keeps the leading citation when nothing matched the prompt', () => { + const cited = [cite(1, 'a.pdf'), cite(2, 'b.pdf')]; + + const result = restrictCitationsToContext(cited, 'no headers here', []); + + expect(result.map((d) => d.documentId)).toEqual([1]); + }); + + it('passes through a single citation untouched', () => { + const cited = [cite(1, 'a.pdf')]; + + expect(restrictCitationsToContext(cited, '', [])).toEqual(cited); + }); +}); + +describe('pickCitationsByAnswer', () => { + const withPassage = ( + documentId: number, + name: string, + passage: string + ): SourceDocument => ({ documentId, name, passage }); + + it('cites only the source the answer actually echoes', () => { + const cited = [ + withPassage(22, 'sample.html', 'The quarterly revenue report and profit summary.'), + withPassage(24, 'sample.csv', 'employee,vacation,days\nAnna,urlop,26'), + ]; + const answer = 'Anna ma 26 dni urlopu według danych o pracownikach.'; + + const result = pickCitationsByAnswer(cited, answer, []); + + expect(result.map((d) => d.documentId)).toEqual([24]); + }); + + it('keeps both sources when the answer draws on each', () => { + const cited = [ + withPassage(1, 'revenue.txt', 'Total revenue grew to five million dollars.'), + withPassage(2, 'headcount.txt', 'The company hired forty new engineers.'), + ]; + const answer = + 'Revenue grew to five million dollars while the company hired forty engineers.'; + + const result = pickCitationsByAnswer(cited, answer, []); + + expect(result.map((d) => d.documentId).sort()).toEqual([1, 2]); + }); + + it('never drops the freshly-attached source the answer does not echo', () => { + const cited = [ + withPassage(1, 'other.txt', 'Revenue grew to five million dollars.'), + withPassage(2, 'attachment.txt', 'Completely unrelated attached content.'), + ]; + const answer = 'Revenue grew to five million dollars.'; + + const result = pickCitationsByAnswer(cited, answer, [ + doc(2, 'attachment.txt'), + ]); + + expect(result.map((d) => d.documentId).sort()).toEqual([1, 2]); + }); + + it('leaves the list unchanged when the answer echoes no passage', () => { + const cited = [ + withPassage(1, 'a.txt', 'alpha beta gamma'), + withPassage(2, 'b.txt', 'delta epsilon zeta'), + ]; + + const result = pickCitationsByAnswer(cited, "I don't know.", []); + + expect(result.map((d) => d.documentId)).toEqual([1, 2]); + }); + + it('passes through a single citation untouched', () => { + const cited = [withPassage(1, 'a.txt', 'alpha beta gamma')]; + + expect(pickCitationsByAnswer(cited, 'anything at all', [])).toEqual(cited); + }); +}); diff --git a/__tests__/prepareContext.test.ts b/__tests__/prepareContext.test.ts index 84099a4c..304d4549 100644 --- a/__tests__/prepareContext.test.ts +++ b/__tests__/prepareContext.test.ts @@ -2,6 +2,7 @@ import { formatContextChunks, formatFirstChunks, getSourceDocumentsFromChunks, + sourcesPresentInContext, } from '../utils/contextUtils'; describe('formatContextChunks / getSourceDocumentsFromChunks', () => { @@ -130,3 +131,44 @@ describe('formatFirstChunks', () => { expect(result[0]).toContain('End of Current Attachment Source'); }); }); + +describe('sourcesPresentInContext', () => { + const chunk = (document: string, documentId: number, name: string) => ({ + document, + similarity: 0.9, + metadata: { documentId, name }, + }); + + it('extracts the names from Source blocks it is given', () => { + const context = formatContextChunks([ + chunk('vacation rules', 21, 'polityka_urlopowa_2026.pdf'), + chunk('unrelated', 20, 'sample.htm'), + ]).join(' '); + + expect(sourcesPresentInContext(context)).toEqual( + new Set(['polityka_urlopowa_2026.pdf', 'sample.htm']) + ); + }); + + it('strips the (Overview) marker from attachment headers', () => { + const context = formatFirstChunks( + [{ id: 21, name: 'polityka_urlopowa_2026.pdf', firstChunk: 'intro' }], + 'Current Attachment Source' + ).join(' '); + + expect(sourcesPresentInContext(context)).toEqual( + new Set(['polityka_urlopowa_2026.pdf']) + ); + }); + + it('reports only the blocks that survived truncation', () => { + const blocks = formatContextChunks([ + chunk('kept', 21, 'polityka_urlopowa_2026.pdf'), + chunk('dropped', 20, 'sample.htm'), + ]); + + expect(sourcesPresentInContext(blocks[0])).toEqual( + new Set(['polityka_urlopowa_2026.pdf']) + ); + }); +}); diff --git a/constants/retrieval.ts b/constants/retrieval.ts index c6391026..ba2c3bd1 100644 --- a/constants/retrieval.ts +++ b/constants/retrieval.ts @@ -35,8 +35,13 @@ export const STRONG_SEMANTIC_THRESHOLD = 0.55; /** Min cosine to qualify via lexical overlap (paired with non-zero term coverage). */ export const LEXICAL_MATCH_MIN_SIMILARITY = 0.1; +/** After generation, cite a non-attachment document only if its answer↔passage term overlap is at least this fraction of the strongest cited document's — attributes the reply to the source(s) it was actually based on. */ +export const ANSWER_CITATION_OVERLAP_RATIO = 0.5; + export const TEXT_SPLITTER_CHUNK_SIZE = 1000; export const TEXT_SPLITTER_CHUNK_OVERLAP = 200; /** Min matched run to treat as overlap when stitching passages — below the splitter overlap, above coincidental repetition. */ export const MIN_STITCH_OVERLAP = 24; + +export const SOURCE_HEADER = /--- [^:]+: (.+?) ---/g; diff --git a/store/llmStore.ts b/store/llmStore.ts index d5b5befb..eeba2d12 100644 --- a/store/llmStore.ts +++ b/store/llmStore.ts @@ -16,6 +16,10 @@ import { type Message as ExecutorchMessage } from 'react-native-executorch'; import { Platform } from 'react-native'; import { Feedback } from '../utils/Feedback'; import { prepareMessagesForLLM } from '../utils/promptUtils'; +import { + pickCitationsByAnswer, + restrictCitationsToContext, +} from '../utils/messageSources'; import { getGenerationConfigForModel } from '../constants/default-models'; export interface LLMStore { @@ -418,6 +422,17 @@ export const useLLMStore = create((set, get) => ({ currentModel, preferredSourceDocuments ); + const lastPreparedMessage = messagesWithSystemPrompt.at(-1); + const lastPreparedContent = + typeof lastPreparedMessage?.content === 'string' + ? lastPreparedMessage.content + : JSON.stringify(lastPreparedMessage?.content ?? ''); + + const seenSourceDocuments = restrictCitationsToContext( + sourceDocuments ?? [], + lastPreparedContent, + preferredSourceDocuments ?? [] + ); await waitForModelLoad(get); @@ -433,10 +448,15 @@ export const useLLMStore = create((set, get) => ({ await generateLLMResponse(messagesWithSystemPrompt, get); // Handle successful response if (finalResponse) { + const citedSourceDocuments = pickCitationsByAnswer( + seenSourceDocuments, + finalResponse, + preferredSourceDocuments ?? [] + ); await persistMessage(db, { ...assistantPlaceholder, content: finalResponse, - sourceDocuments, + sourceDocuments: citedSourceDocuments, tokensPerSecond: responsePerformance.tokensPerSecond, timeToFirstToken: responsePerformance.timeToFirstToken, }); @@ -447,7 +467,7 @@ export const useLLMStore = create((set, get) => ({ tokensPerSecond: responsePerformance.tokensPerSecond, finalAssistantMessage: { content: finalResponse, - sourceDocuments, + sourceDocuments: citedSourceDocuments, }, }); } else { diff --git a/utils/contextUtils.ts b/utils/contextUtils.ts index 45536d47..8a043b56 100644 --- a/utils/contextUtils.ts +++ b/utils/contextUtils.ts @@ -1,4 +1,4 @@ -import { MIN_STITCH_OVERLAP } from '../constants/retrieval'; +import { MIN_STITCH_OVERLAP, SOURCE_HEADER } from '../constants/retrieval'; export type ContextChunk = { document?: string; @@ -111,6 +111,16 @@ export const getSourceDocumentsFromChunks = ( similarity: group.maxSimilarity, })); +export const sourcesPresentInContext = ( + contextContent: string +): Set => { + const names = new Set(); + for (const match of contextContent.matchAll(SOURCE_HEADER)) { + names.add(match[1]!.replace(/ \(Overview\)$/, '').trim()); + } + return names; +}; + export const formatFirstChunks = ( sources: FirstChunkSource[], label = 'Source' diff --git a/utils/messageSources.ts b/utils/messageSources.ts index 5059e11e..17f90a50 100644 --- a/utils/messageSources.ts +++ b/utils/messageSources.ts @@ -6,20 +6,11 @@ import { formatFirstChunks, getSourceDocumentsFromChunks, sourceKey, + sourcesPresentInContext, } from './contextUtils'; import { hybridRetrieve } from './hybridRetrieval'; - -// Builds one LLM turn's source data from the message's attachments + the chat's -// enabled sources: `context` (the "Source N" / overview blocks for the model), -// `sourceDocuments` (citations for the reply) and `preferredSourceDocuments` -// (freshly attached sources to prioritise). State-free, so it's unit-testable. - -const DEBUG_PREVIEW_LENGTH = 1200; - -const previewText = (value?: string) => - value && value.length > DEBUG_PREVIEW_LENGTH - ? `${value.slice(0, DEBUG_PREVIEW_LENGTH)}...` - : value; +import { extractQueryTerms, stemPrefix } from './queryTerms'; +import { ANSWER_CITATION_OVERLAP_RATIO } from '../constants/retrieval'; export interface SourceRow { id: number; @@ -40,8 +31,6 @@ const getAttachmentSourceDocuments = ( passage: source.firstChunk, })); -// Order citations attachment-first: retrieved attachment docs, then attachments -// with no retrieved chunk (cited via overview), then the remaining retrieved docs. export const mergeAttachmentFirst = ( retrieved: SourceDocument[], preferred: SourceDocument[], @@ -64,6 +53,89 @@ export const mergeAttachmentFirst = ( return [...attachmentDocs, ...missingAttachments, ...otherDocs]; }; +export const assembleSourceDocuments = ( + retrieved: SourceDocument[], + preferred: SourceDocument[], + attachmentSourceIds: number[], + activeSources: SourceRow[], + contextPresent: boolean +): SourceDocument[] => { + const merged = mergeAttachmentFirst( + retrieved, + preferred, + attachmentSourceIds + ); + if (merged.length > 0 || !contextPresent) return merged; + + return activeSources.map((source) => ({ + documentId: source.id, + name: source.name, + passage: source.firstChunk, + })); +}; + +export const restrictCitationsToContext = ( + sourceDocuments: SourceDocument[], + promptContext: string, + preferred: SourceDocument[] +): SourceDocument[] => { + if (sourceDocuments.length <= 1) return sourceDocuments; + + const present = sourcesPresentInContext(promptContext); + const preferredNames = new Set(preferred.map((doc) => doc.name)); + + const survived = sourceDocuments.filter( + (doc) => preferredNames.has(doc.name) || present.has(doc.name) + ); + return survived.length > 0 ? survived : sourceDocuments.slice(0, 1); +}; + +const overlapWithAnswer = ( + passage: string, + answerTerms: Set +): number => { + let overlap = 0; + const seen = new Set(); + for (const term of extractQueryTerms(passage)) { + const stem = stemPrefix(term); + if (seen.has(stem)) continue; + seen.add(stem); + if (answerTerms.has(stem)) overlap++; + } + return overlap; +}; + +export const pickCitationsByAnswer = ( + sourceDocuments: SourceDocument[], + answer: string, + preferred: SourceDocument[] +): SourceDocument[] => { + if (sourceDocuments.length <= 1) return sourceDocuments; + + const answerTerms = new Set( + [...extractQueryTerms(answer)].map(stemPrefix) + ); + if (answerTerms.size === 0) return sourceDocuments; + + const preferredNames = new Set(preferred.map((doc) => doc.name)); + const scored = sourceDocuments.map((doc) => ({ + doc, + isPreferred: preferredNames.has(doc.name), + overlap: overlapWithAnswer(`${doc.name} ${doc.passage ?? ''}`, answerTerms), + })); + + const maxOverlap = Math.max(0, ...scored.map((s) => s.overlap)); + if (maxOverlap === 0) return sourceDocuments; + + return scored + .filter( + (s) => + s.isPreferred || + s.overlap >= maxOverlap * ANSWER_CITATION_OVERLAP_RATIO + ) + .map((s) => s.doc); +}; + const retrieveChunks = async ( userInput: string, allSourceIds: number[], @@ -149,14 +221,13 @@ export const buildMessageSources = async ({ context.push(...formatContextChunks(relevantChunks)); const retrieved = getSourceDocumentsFromChunks(relevantChunks); - sourceDocuments = - attachmentSourceIds.length > 0 - ? mergeAttachmentFirst( - retrieved, - preferredSourceDocuments, - attachmentSourceIds - ) - : retrieved; + sourceDocuments = assembleSourceDocuments( + retrieved, + preferredSourceDocuments, + attachmentSourceIds, + activeSources, + context.length > 0 + ); } else if (attachmentSourceIds.length > 0) { sourceDocuments = preferredSourceDocuments; context.push(...attachmentOverview()); From a7f307bd1b273cc39ac37e7f3b23a96f02c12679 Mon Sep 17 00:00:00 2001 From: Krzysztof Faracik Date: Thu, 9 Jul 2026 16:39:14 +0200 Subject: [PATCH 16/42] feat(attachments): show import progress and flag scanned PDFs --- __tests__/hybridRetrieval.test.ts | 16 ++++-- __tests__/llmStore.test.ts | 44 ++++++++++++--- __tests__/messageSources.test.ts | 24 ++++++-- __tests__/promptUtils.test.ts | 4 +- __tests__/sourceStore.test.ts | 16 ++++++ .../chat-screen/AttachmentThumbnail.tsx | 56 ++++++++++++++++++- constants/chat.ts | 2 + hooks/useAttachment.ts | 24 ++++++-- store/sourceStore.ts | 22 ++++++-- utils/documentErrorMessage.ts | 12 ++++ utils/messageSources.ts | 7 +-- 11 files changed, 192 insertions(+), 35 deletions(-) create mode 100644 utils/documentErrorMessage.ts diff --git a/__tests__/hybridRetrieval.test.ts b/__tests__/hybridRetrieval.test.ts index 6642663f..959f358d 100644 --- a/__tests__/hybridRetrieval.test.ts +++ b/__tests__/hybridRetrieval.test.ts @@ -330,7 +330,8 @@ describe('hybridRetrieve', () => { }, { id: '1:20', - document: 'ben borders will assume the role of principal accounting officer', + document: + 'ben borders will assume the role of principal accounting officer', embedding: [0, 1], similarity: 0.9, metadata: { documentId: 1, name: 'AppleK' }, @@ -354,7 +355,11 @@ describe('hybridRetrieve', () => { 'ben borders will assume the role of principal accounting officer' ); expect(docs[0]).toContain('ben borders'); - expect(docs.indexOf('ben borders will assume the role of principal accounting officer')).toBeLessThan( + expect( + docs.indexOf( + 'ben borders will assume the role of principal accounting officer' + ) + ).toBeLessThan( docs.indexOf('table of contents item 14 principal accountant fees') ); }); @@ -370,7 +375,8 @@ describe('hybridRetrieve', () => { }, { id: '1:20', - document: 'item 9b ben borders will assume principal accounting officer', + document: + 'item 9b ben borders will assume principal accounting officer', embedding: [0, 1], similarity: 0.5, metadata: { documentId: 1, name: 'AppleK' }, @@ -418,7 +424,9 @@ describe('hybridRetrieve', () => { const docs = result.map((c) => c.document); const lastNine = Math.max( docs.indexOf('item 9b neighbor before'), - docs.indexOf('item 9b ben borders will assume principal accounting officer'), + docs.indexOf( + 'item 9b ben borders will assume principal accounting officer' + ), docs.indexOf('item 9b neighbor after') ); const firstToc = Math.min( diff --git a/__tests__/llmStore.test.ts b/__tests__/llmStore.test.ts index 420209b3..e2f57486 100644 --- a/__tests__/llmStore.test.ts +++ b/__tests__/llmStore.test.ts @@ -333,7 +333,9 @@ describe('sendChatMessage', () => { activeChatMessages: [], }); - await useLLMStore.getState().sendChatMessage('hello', 1, noSources, settings); + await useLLMStore + .getState() + .sendChatMessage('hello', 1, noSources, settings); expect(mockPersistMessage).toHaveBeenCalledTimes(2); expect(mockPersistMessage).toHaveBeenCalledWith( @@ -356,7 +358,9 @@ describe('sendChatMessage', () => { activeChatMessages: [], }); - await useLLMStore.getState().sendChatMessage('hello', 1, noSources, settings); + await useLLMStore + .getState() + .sendChatMessage('hello', 1, noSources, settings); expect(useLLMStore.getState().isProcessingPrompt).toBe(false); expect(useLLMStore.getState().isGenerating).toBe(false); @@ -374,7 +378,9 @@ describe('sendChatMessage', () => { activeChatMessages: [], }); - await useLLMStore.getState().sendChatMessage('ping', 1, noSources, settings); + await useLLMStore + .getState() + .sendChatMessage('ping', 1, noSources, settings); expect(messagesBeforeGenerate).toHaveLength(2); expect(messagesBeforeGenerate[0].role).toBe('user'); @@ -390,7 +396,9 @@ describe('sendChatMessage', () => { activeChatMessages: [], }); - await useLLMStore.getState().sendChatMessage('hello', 1, noSources, settings); + await useLLMStore + .getState() + .sendChatMessage('hello', 1, noSources, settings); expect(useLLMStore.getState().isGenerating).toBe(false); expect(useLLMStore.getState().isProcessingPrompt).toBe(false); @@ -406,7 +414,9 @@ describe('sendChatMessage', () => { activeChatMessages: [], }); - await useLLMStore.getState().sendChatMessage('hello', 1, noSources, settings); + await useLLMStore + .getState() + .sendChatMessage('hello', 1, noSources, settings); expect(useLLMStore.getState().isGenerating).toBe(false); expect(useLLMStore.getState().isProcessingPrompt).toBe(false); @@ -423,7 +433,9 @@ describe('sendChatMessage', () => { activeChatMessages: [], }); - await useLLMStore.getState().sendChatMessage('hello', 1, noSources, settings); + await useLLMStore + .getState() + .sendChatMessage('hello', 1, noSources, settings); // complete called without perf data — last message should not have timeToFirstToken const messages = useLLMStore.getState().activeChatMessages; @@ -533,7 +545,13 @@ describe('sendChatMessage imagePath', () => { it('passes imagePath to persistMessage for user message when provided', async () => { await useLLMStore .getState() - .sendChatMessage('What is this?', 1, noSources, settings, '/local/image.jpg'); + .sendChatMessage( + 'What is this?', + 1, + noSources, + settings, + '/local/image.jpg' + ); expect(mockPersistMessage).toHaveBeenCalledWith( expect.anything(), @@ -542,7 +560,9 @@ describe('sendChatMessage imagePath', () => { }); it('passes undefined imagePath to persistMessage when not provided', async () => { - await useLLMStore.getState().sendChatMessage('Hello', 1, noSources, settings); + await useLLMStore + .getState() + .sendChatMessage('Hello', 1, noSources, settings); expect(mockPersistMessage).toHaveBeenCalledWith( expect.anything(), @@ -561,7 +581,13 @@ describe('sendChatMessage imagePath', () => { await useLLMStore .getState() - .sendChatMessage('What is this?', 1, noSources, settings, '/local/image.jpg'); + .sendChatMessage( + 'What is this?', + 1, + noSources, + settings, + '/local/image.jpg' + ); expect(mockInstance.generate).toHaveBeenCalledTimes(1); const calledMessages = mockInstance.generate.mock.calls[0][0]; diff --git a/__tests__/messageSources.test.ts b/__tests__/messageSources.test.ts index 062f5789..216ad21e 100644 --- a/__tests__/messageSources.test.ts +++ b/__tests__/messageSources.test.ts @@ -53,7 +53,11 @@ describe('mergeAttachmentFirst', () => { }); describe('assembleSourceDocuments', () => { - const source = (id: number, name: string, firstChunk?: string): SourceRow => ({ + const source = ( + id: number, + name: string, + firstChunk?: string + ): SourceRow => ({ id, name, firstChunk, @@ -163,7 +167,11 @@ describe('pickCitationsByAnswer', () => { it('cites only the source the answer actually echoes', () => { const cited = [ - withPassage(22, 'sample.html', 'The quarterly revenue report and profit summary.'), + withPassage( + 22, + 'sample.html', + 'The quarterly revenue report and profit summary.' + ), withPassage(24, 'sample.csv', 'employee,vacation,days\nAnna,urlop,26'), ]; const answer = 'Anna ma 26 dni urlopu według danych o pracownikach.'; @@ -175,7 +183,11 @@ describe('pickCitationsByAnswer', () => { it('keeps both sources when the answer draws on each', () => { const cited = [ - withPassage(1, 'revenue.txt', 'Total revenue grew to five million dollars.'), + withPassage( + 1, + 'revenue.txt', + 'Total revenue grew to five million dollars.' + ), withPassage(2, 'headcount.txt', 'The company hired forty new engineers.'), ]; const answer = @@ -189,7 +201,11 @@ describe('pickCitationsByAnswer', () => { it('never drops the freshly-attached source the answer does not echo', () => { const cited = [ withPassage(1, 'other.txt', 'Revenue grew to five million dollars.'), - withPassage(2, 'attachment.txt', 'Completely unrelated attached content.'), + withPassage( + 2, + 'attachment.txt', + 'Completely unrelated attached content.' + ), ]; const answer = 'Revenue grew to five million dollars.'; diff --git a/__tests__/promptUtils.test.ts b/__tests__/promptUtils.test.ts index 3d751bc8..5e4af3ed 100644 --- a/__tests__/promptUtils.test.ts +++ b/__tests__/promptUtils.test.ts @@ -365,7 +365,9 @@ describe('prepareMessagesForLLM', () => { { id: 1, chatId: 1, role: 'user', content: 'question', timestamp: 0 }, { id: 2, chatId: 1, role: 'assistant', content: '', timestamp: 0 }, ]; - const hugeContext = 'y'.repeat(getPromptCharBudget(baseModel) * 2 + 10000); + const hugeContext = 'y'.repeat( + getPromptCharBudget(baseModel) * 2 + 10000 + ); const result = prepareMessagesForLLM( messages, diff --git a/__tests__/sourceStore.test.ts b/__tests__/sourceStore.test.ts index 8c058764..b823a8df 100644 --- a/__tests__/sourceStore.test.ts +++ b/__tests__/sourceStore.test.ts @@ -78,6 +78,22 @@ describe('addSource', () => { expect(useSourceStore.getState().isReading).toBe(false); }); + it('flags a PDF with no extractable text as scanned', async () => { + mockReadDocumentText.mockResolvedValue(''); + const result = await useSourceStore + .getState() + .addSource( + { name: 'scan.pdf', type: 'pdf', size: 100 }, + '/path/scan.pdf', + mockVectorStore + ); + expect(result).toEqual({ + success: false, + isEmpty: true, + reason: 'scanned_pdf', + }); + }); + it('sets isReading to true during processing then false on success', async () => { mockReadDocumentText.mockResolvedValue('some content'); mockInsertSource.mockResolvedValue(42); diff --git a/components/chat-screen/AttachmentThumbnail.tsx b/components/chat-screen/AttachmentThumbnail.tsx index 5e1c3001..1948729a 100644 --- a/components/chat-screen/AttachmentThumbnail.tsx +++ b/components/chat-screen/AttachmentThumbnail.tsx @@ -1,10 +1,12 @@ -import React, { useMemo } from 'react'; +import React, { useEffect, useMemo, useRef } from 'react'; import { View, Image, Text, TouchableOpacity, ActivityIndicator, + Animated, + Easing, StyleSheet, } from 'react-native'; import { useTheme } from '../../context/ThemeContext'; @@ -13,6 +15,7 @@ import { fontFamily, fontSizes } from '../../styles/fontStyles'; import CloseIcon from '../../assets/icons/close.svg'; import AttachmentIcon from '../../assets/icons/attachment.svg'; import { Attachment } from '../../hooks/useAttachment'; +import { ATTACHMENT_PROGRESS_TRACK_WIDTH } from '../../constants/chat'; interface Props { attachment: Attachment; @@ -23,11 +26,40 @@ const AttachmentThumbnail = ({ attachment, onRemove }: Props) => { const { theme } = useTheme(); const styles = useMemo(() => createStyles(theme), [theme]); + const fill = useRef(new Animated.Value(0)).current; + useEffect(() => { + if (attachment.progress == null) return; + Animated.timing(fill, { + toValue: attachment.progress * ATTACHMENT_PROGRESS_TRACK_WIDTH, + duration: 250, + easing: Easing.out(Easing.cubic), + useNativeDriver: false, + }).start(); + }, [attachment.progress, fill]); + const renderContent = () => { if (attachment.status === 'loading') { + if (attachment.progress == null) { + return ( + + + + ); + } + return ( - - + + + + {Math.round(attachment.progress * 100)}% + + + + ); } @@ -107,6 +139,24 @@ const createStyles = (theme: Theme) => maxWidth: 60, textAlign: 'center', }, + percentText: { + fontSize: fontSizes.sm, + fontFamily: fontFamily.medium, + color: theme.text.primary, + fontVariant: ['tabular-nums'], + }, + progressTrack: { + width: ATTACHMENT_PROGRESS_TRACK_WIDTH, + height: 4, + borderRadius: 2, + backgroundColor: theme.bg.softSecondary, + overflow: 'hidden', + }, + progressFill: { + height: 4, + borderRadius: 2, + backgroundColor: theme.bg.strongPrimary, + }, dismissButton: { position: 'absolute', top: -6, diff --git a/constants/chat.ts b/constants/chat.ts index e405dc98..b757db66 100644 --- a/constants/chat.ts +++ b/constants/chat.ts @@ -1 +1,3 @@ export const LEGACY_CHAT_NOTICE_TOP_OFFSET = 64; + +export const ATTACHMENT_PROGRESS_TRACK_WIDTH = 52; diff --git a/hooks/useAttachment.ts b/hooks/useAttachment.ts index 115bc566..37cfcf06 100644 --- a/hooks/useAttachment.ts +++ b/hooks/useAttachment.ts @@ -7,6 +7,7 @@ import Toast from 'react-native-toast-message'; import { useSourceStore } from '../store/sourceStore'; import { useVectorStore } from '../context/VectorStoreContext'; import { useEmbeddingModelStore } from '../store/embeddingModelStore'; +import { documentErrorMessage } from '../utils/documentErrorMessage'; export interface Attachment { id: string; @@ -15,6 +16,7 @@ export interface Attachment { name?: string; status: 'loading' | 'ready'; sourceId?: number; + progress?: number; } interface ClearAllOptions { @@ -158,11 +160,27 @@ export const useAttachment = () => { size: asset.size || null, }; const { addSource } = useSourceStore.getState(); + let lastPercent = -1; + const handleProgress = (progress: number) => { + const percent = Math.round(progress * 100); + if (percent === lastPercent) return; + lastPercent = percent; + if ( + attachmentRequestRef.current !== requestId || + currentDocumentAttachmentIdRef.current !== attachmentId + ) { + return; + } + setAttachments((prev) => + prev.map((a) => (a.id === attachmentId ? { ...a, progress } : a)) + ); + }; const result = await addSource( newSource, asset.uri, vectorStore!, - embeddings + embeddings, + handleProgress ); const isCurrentDocumentRequest = attachmentRequestRef.current === requestId && @@ -191,9 +209,7 @@ export const useAttachment = () => { setAttachments((prev) => prev.filter((a) => a.id !== attachmentId)); Toast.show({ type: 'defaultToast', - text1: result.isEmpty - ? 'Document appears to be empty.' - : 'Failed to process document.', + text1: documentErrorMessage(result), }); } } catch (error) { diff --git a/store/sourceStore.ts b/store/sourceStore.ts index 9825f970..5f61635e 100644 --- a/store/sourceStore.ts +++ b/store/sourceStore.ts @@ -33,8 +33,14 @@ interface SourceStore { source: Omit, sourceUri: string, vectorStore: OPSQLiteVectorStore, - embeddings?: LFMEmbeddings | null - ) => Promise<{ success: boolean; isEmpty?: boolean; sourceId?: number }>; + embeddings?: LFMEmbeddings | null, + onProgress?: (progress: number) => void + ) => Promise<{ + success: boolean; + isEmpty?: boolean; + reason?: 'scanned_pdf'; + sourceId?: number; + }>; setSourceProcessing: (id: number, isProcessing: boolean) => void; deleteSource: (source: Source) => Promise; renameSource: (id: number, newName: string) => Promise; @@ -61,7 +67,7 @@ export const useSourceStore = create((set, get) => ({ } }, - addSource: async (source, sourceUri, vectorStore, embeddings) => { + addSource: async (source, sourceUri, vectorStore, embeddings, onProgress) => { const db = get().db; if (!db) return { success: false }; @@ -72,7 +78,12 @@ export const useSourceStore = create((set, get) => ({ const sourceTextContent = await readDocumentText(sourceUri, source.type); if (!sourceTextContent || sourceTextContent.trim().length === 0) { - return { success: false, isEmpty: true }; + const isScannedPdf = source.type.toLowerCase() === 'pdf'; + return { + success: false, + isEmpty: true, + ...(isScannedPdf ? { reason: 'scanned_pdf' as const } : {}), + }; } const tempSource: Source = { ...source, id: tempId, isProcessing: true }; @@ -95,6 +106,7 @@ export const useSourceStore = create((set, get) => ({ return { success: false }; } + onProgress?.(0); for (let i = 0; i < chunks.length; i++) { const embedding = embeddings ? await embeddings.embedDocument(chunks[i]!) @@ -119,8 +131,8 @@ export const useSourceStore = create((set, get) => ({ chunks[i]! ); } + onProgress?.((i + 1) / chunks.length); } - set((state) => ({ sources: state.sources.map((s) => s.id === tempId diff --git a/utils/documentErrorMessage.ts b/utils/documentErrorMessage.ts new file mode 100644 index 00000000..744c311b --- /dev/null +++ b/utils/documentErrorMessage.ts @@ -0,0 +1,12 @@ +export const documentErrorMessage = (result: { + reason?: 'scanned_pdf'; + isEmpty?: boolean; +}): string => { + if (result.reason === 'scanned_pdf') { + return 'This PDF has no selectable text — it looks scanned, so it can’t be read yet. Try a text-based PDF.'; + } + if (result.isEmpty) { + return 'Document appears to be empty.'; + } + return 'Failed to process document.'; +}; diff --git a/utils/messageSources.ts b/utils/messageSources.ts index 17f90a50..04914888 100644 --- a/utils/messageSources.ts +++ b/utils/messageSources.ts @@ -112,9 +112,7 @@ export const pickCitationsByAnswer = ( ): SourceDocument[] => { if (sourceDocuments.length <= 1) return sourceDocuments; - const answerTerms = new Set( - [...extractQueryTerms(answer)].map(stemPrefix) - ); + const answerTerms = new Set([...extractQueryTerms(answer)].map(stemPrefix)); if (answerTerms.size === 0) return sourceDocuments; const preferredNames = new Set(preferred.map((doc) => doc.name)); @@ -130,8 +128,7 @@ export const pickCitationsByAnswer = ( return scored .filter( (s) => - s.isPreferred || - s.overlap >= maxOverlap * ANSWER_CITATION_OVERLAP_RATIO + s.isPreferred || s.overlap >= maxOverlap * ANSWER_CITATION_OVERLAP_RATIO ) .map((s) => s.doc); }; From e3115b8d7c1913b416f3c23e2beeb0557798be62 Mon Sep 17 00:00:00 2001 From: Krzysztof Faracik Date: Thu, 9 Jul 2026 18:39:51 +0200 Subject: [PATCH 17/42] feat(citations): add citation constants and refine source attribution --- __tests__/messageSources.test.ts | 127 +++++++++++++++++++++++++++++-- constants/citations.ts | 34 +++++++++ utils/messageSources.ts | 56 ++++++++++++-- 3 files changed, 207 insertions(+), 10 deletions(-) diff --git a/__tests__/messageSources.test.ts b/__tests__/messageSources.test.ts index 216ad21e..2e575d05 100644 --- a/__tests__/messageSources.test.ts +++ b/__tests__/messageSources.test.ts @@ -1,8 +1,10 @@ import { assembleSourceDocuments, + looksLikeNoAnswer, mergeAttachmentFirst, pickCitationsByAnswer, restrictCitationsToContext, + visibleAnswer, type SourceRow, } from '../utils/messageSources'; import { SourceDocument } from '../database/chatRepository'; @@ -216,15 +218,34 @@ describe('pickCitationsByAnswer', () => { expect(result.map((d) => d.documentId).sort()).toEqual([1, 2]); }); - it('leaves the list unchanged when the answer echoes no passage', () => { + it('drops all citations when the answer echoes no passage (refusal)', () => { const cited = [ - withPassage(1, 'a.txt', 'alpha beta gamma'), - withPassage(2, 'b.txt', 'delta epsilon zeta'), + withPassage(1, 'sample.pdf', 'alpha beta gamma'), + withPassage(2, 'misja_ares_trzy.pdf', 'delta epsilon zeta'), ]; - const result = pickCitationsByAnswer(cited, "I don't know.", []); + const result = pickCitationsByAnswer( + cited, + 'W dokumentach nie ma informacji o L4.', + [] + ); - expect(result.map((d) => d.documentId)).toEqual([1, 2]); + expect(result).toEqual([]); + }); + + it('keeps only the fresh attachment when a refusal echoes no passage', () => { + const cited = [ + withPassage(1, 'library.pdf', 'alpha beta gamma'), + withPassage(2, 'attachment.pdf', 'delta epsilon zeta'), + ]; + + const result = pickCitationsByAnswer( + cited, + 'There is no information about L4 here.', + [doc(2, 'attachment.pdf')] + ); + + expect(result.map((d) => d.documentId)).toEqual([2]); }); it('passes through a single citation untouched', () => { @@ -232,4 +253,100 @@ describe('pickCitationsByAnswer', () => { expect(pickCitationsByAnswer(cited, 'anything at all', [])).toEqual(cited); }); + + it('ignores the block and attributes only the visible reply', () => { + const cited = [ + withPassage(1, 'sample.pdf', 'alpha beta gamma'), + withPassage(2, 'misja_ares_trzy.pdf', 'delta epsilon zeta'), + ]; + const answer = + 'The alpha beta gamma file and the delta epsilon zeta file both ' + + 'need checking for L4.W dokumentach nie ma informacji o L4.'; + + const result = pickCitationsByAnswer(cited, answer, []); + + expect(result).toEqual([]); + }); + + it('cites nothing when a verbose refusal still overlaps the passages', () => { + const cited = [ + withPassage( + 1, + 'sample.pdf', + 'The report covers revenue and profit figures.' + ), + withPassage( + 2, + 'misja_ares_trzy.pdf', + 'The mission Ares III briefing and crew roster.' + ), + ]; + const answer = + 'Przeanalizowałem dokumenty: sample.pdf opisuje revenue i profit, a misja ' + + 'Ares III to briefing i crew roster. W żadnym nie ma informacji o L4.'; + + const result = pickCitationsByAnswer(cited, answer, []); + + expect(result).toEqual([]); + }); + + it('attributes to the source the visible reply echoes, not the reasoning', () => { + const cited = [ + withPassage(1, 'sample.pdf', 'alpha beta gamma'), + withPassage(2, 'misja_ares_trzy.pdf', 'delta epsilon zeta'), + ]; + const answer = + 'Compare alpha beta gamma against delta epsilon zeta.' + + 'The mission file covers delta, epsilon and zeta in detail.'; + + const result = pickCitationsByAnswer(cited, answer, []); + + expect(result.map((d) => d.documentId)).toEqual([2]); + }); +}); + +describe('visibleAnswer', () => { + it('drops a complete think block, keeping text before and after', () => { + expect(visibleAnswer('beforehidden reasoningafter')).toBe( + 'before after' + ); + }); + + it('drops an unterminated think block (streaming) entirely', () => { + expect(visibleAnswer('visiblestill reasoning')).toBe('visible '); + }); + + it('returns the text unchanged when there is no think block', () => { + expect(visibleAnswer('plain answer')).toBe('plain answer'); + }); +}); + +describe('looksLikeNoAnswer', () => { + it.each([ + 'W dokumentach nie ma informacji o L4.', + 'Brak informacji na ten temat w załączonych plikach.', + 'Dokument nie zawiera danych o urlopie.', + 'Nie wiem, o tym nie ma mowy.', + 'Nie ma dokumentu z tematem "L4" w kontekście dostanych materiałów. Informacje zamieszczone w źródłach obejmują tylko raport testowy.', + 'There is no information about L4 in the documents.', + 'There is no mention of sick leave anywhere.', + 'Sick leave is not mentioned in the provided documents.', + 'The file does not contain any information about L4.', + "I don't know — the context does not cover this.", + 'That detail is not found in the provided sources.', + ])('flags the refusal: %s', (reply) => { + expect(looksLikeNoAnswer(reply)).toBe(true); + }); + + it.each([ + 'The company has no debt and reported a five million profit.', + 'Firma nie ma zadłużenia, a zysk wyniósł pięć milionów.', + 'Nie ma limitu urlopu — polityka pozwala na 30 dni w roku.', + 'Polityka nie zawiera kar umownych za zwłokę.', + 'Document A covers revenue; it does not mention costs, which are in B.', + 'Anna ma 26 dni urlopu zgodnie z regulaminem.', + 'The mission launches on Tuesday with a crew of three.', + ])('does not flag a real answer: %s', (reply) => { + expect(looksLikeNoAnswer(reply)).toBe(false); + }); }); diff --git a/constants/citations.ts b/constants/citations.ts index f0c85b6f..27600a3b 100644 --- a/constants/citations.ts +++ b/constants/citations.ts @@ -5,3 +5,37 @@ export const CITATION_ALPHA_TERM_PATTERN = /^[a-ząćęłńóśźż]+$/; export const CITATION_MIN_MATCH_SCORE = 2; export const CITATION_EXCERPT_MAX_CHARS = 300; export const CITATION_DOCUMENT_NAME_TOKEN_PATTERN = /[^a-z0-9ąćęłńóśźż]+/i; +export const THINK_OPEN = ''; +export const THINK_CLOSE = ''; + +// Coverage nouns (does a source address the topic); a refusal negates one, a negative-fact answer does not. +const NO_ANSWER_META_EN = + 'information|info|mention|reference|data|details?|indication|records?'; +const NO_ANSWER_META_PL = + 'informacj\\w*|info|wzmian\\w*|danych|dane|mowy|odniesie\\w*|dokument\\w*|plik\\w*|tematu|tekst\\w*|materia\\w*|źród\\w*|nic|niczego'; + +// English "no information" refusal patterns; each negation is tied to a coverage noun. +export const NO_ANSWER_PATTERNS_EN: RegExp[] = [ + new RegExp(`\\bthere (is|are|'s) no (${NO_ANSWER_META_EN})\\b`, 'i'), + new RegExp( + `\\b(does|do|did|could|can) ?n['o]?t (contain|mention|include|provide|specify|cover|have|state|say)( any| any relevant)? (${NO_ANSWER_META_EN})\\b`, + 'i' + ), + new RegExp( + `\\b(${NO_ANSWER_META_EN}) (is|are|'s|was|were)?\\s?(not|n['o]?t) (mentioned|found|provided|specified|stated|included|present|available|given)\\b`, + 'i' + ), + /\bnot (found|available|mentioned|provided|present|specified|stated) in (the|these|any|this|your|provided|given)\b/i, + /\bi (do ?n['o]?t|cannot|can ?not|can['o]?t) (know|find|see|answer|tell|determine|locate)\b/i, + /\bunable to (find|answer|determine|locate|provide)\b/i, +]; + +// Polish "brak informacji" refusal patterns; each negation is tied to a coverage noun. +export const NO_ANSWER_PATTERNS_PL: RegExp[] = [ + new RegExp( + `\\b(nie ma|brak|nie zawiera\\w*|nie znaleziono|nie podano|nie wymienia\\w*) (żadn\\w* )?(${NO_ANSWER_META_PL})\\b`, + 'i' + ), + /\bnie wiem\b/i, + /\bnie mog\w* (znaleźć|odpowiedzieć|okre\w*)\b/i, +]; diff --git a/utils/messageSources.ts b/utils/messageSources.ts index 04914888..60c9a032 100644 --- a/utils/messageSources.ts +++ b/utils/messageSources.ts @@ -11,6 +11,12 @@ import { import { hybridRetrieve } from './hybridRetrieval'; import { extractQueryTerms, stemPrefix } from './queryTerms'; import { ANSWER_CITATION_OVERLAP_RATIO } from '../constants/retrieval'; +import { + NO_ANSWER_PATTERNS_EN, + NO_ANSWER_PATTERNS_PL, + THINK_CLOSE, + THINK_OPEN, +} from '../constants/citations'; export interface SourceRow { id: number; @@ -105,6 +111,36 @@ const overlapWithAnswer = ( return overlap; }; +// Attribute against the visible reply only; the block surveys every source and inflates overlap. +export const visibleAnswer = (answer: string): string => { + const open = answer.indexOf(THINK_OPEN); + if (open === -1) return answer; + const close = answer.indexOf(THINK_CLOSE); + const after = close === -1 ? '' : answer.slice(close + THINK_CLOSE.length); + return `${answer.slice(0, open)} ${after}`; +}; + +const answerTermsOf = (answer: string): Set => + new Set([...extractQueryTerms(visibleAnswer(answer))].map(stemPrefix)); + +// True when the visible reply is an EN/PL "no information" refusal (negation tied to a coverage noun). +export const looksLikeNoAnswer = (visibleReply: string): boolean => + [...NO_ANSWER_PATTERNS_EN, ...NO_ANSWER_PATTERNS_PL].some((pattern) => + pattern.test(visibleReply) + ); + +// Compact per-document overlap for logs: `name:overlap` per candidate, so a surprising citation set is diagnosable. +export const answerCitationOverlaps = ( + sourceDocuments: SourceDocument[], + answer: string +): string[] => { + const answerTerms = answerTermsOf(answer); + return sourceDocuments.map( + (doc) => + `${doc.name}:${overlapWithAnswer(`${doc.name} ${doc.passage ?? ''}`, answerTerms)}` + ); +}; + export const pickCitationsByAnswer = ( sourceDocuments: SourceDocument[], answer: string, @@ -112,18 +148,28 @@ export const pickCitationsByAnswer = ( ): SourceDocument[] => { if (sourceDocuments.length <= 1) return sourceDocuments; - const answerTerms = new Set([...extractQueryTerms(answer)].map(stemPrefix)); - if (answerTerms.size === 0) return sourceDocuments; - const preferredNames = new Set(preferred.map((doc) => doc.name)); + + // A refusal cites nothing but a freshly-attached subject, even when it describes the sources. + if (looksLikeNoAnswer(visibleAnswer(answer))) { + return sourceDocuments.filter((doc) => preferredNames.has(doc.name)); + } + + const answerTerms = answerTermsOf(answer); const scored = sourceDocuments.map((doc) => ({ doc, isPreferred: preferredNames.has(doc.name), - overlap: overlapWithAnswer(`${doc.name} ${doc.passage ?? ''}`, answerTerms), + overlap: answerTerms.size + ? overlapWithAnswer(`${doc.name} ${doc.passage ?? ''}`, answerTerms) + : 0, })); const maxOverlap = Math.max(0, ...scored.map((s) => s.overlap)); - if (maxOverlap === 0) return sourceDocuments; + + // Reply echoes no candidate → not grounded in any; keep only a freshly-attached source, cite nothing else. + if (maxOverlap === 0) { + return scored.filter((s) => s.isPreferred).map((s) => s.doc); + } return scored .filter( From 35ed96ee5442e2805e969b6aa238c31183522d51 Mon Sep 17 00:00:00 2001 From: Krzysztof Faracik Date: Mon, 13 Jul 2026 14:59:57 +0200 Subject: [PATCH 18/42] refactor(sources-sheet): extract subcomponents and hoist constants --- __mocks__/@gorhom/bottom-sheet.tsx | 59 +++- __mocks__/react-native-reanimated.ts | 1 + __tests__/MessageItem.test.tsx | 2 + components/bottomSheets/SheetBackdrop.tsx | 42 +++ components/chat-screen/RowChevron.tsx | 33 +++ components/chat-screen/SourcesSheet.tsx | 317 +++++++++++----------- constants/bottom-sheet.ts | 1 + constants/documents.ts | 1 + constants/sources-sheet.ts | 29 ++ utils/documentType.ts | 10 + 10 files changed, 334 insertions(+), 161 deletions(-) create mode 100644 components/bottomSheets/SheetBackdrop.tsx create mode 100644 components/chat-screen/RowChevron.tsx create mode 100644 constants/bottom-sheet.ts create mode 100644 constants/documents.ts create mode 100644 constants/sources-sheet.ts create mode 100644 utils/documentType.ts diff --git a/__mocks__/@gorhom/bottom-sheet.tsx b/__mocks__/@gorhom/bottom-sheet.tsx index fad1e0e1..cf6e0062 100644 --- a/__mocks__/@gorhom/bottom-sheet.tsx +++ b/__mocks__/@gorhom/bottom-sheet.tsx @@ -1,14 +1,53 @@ -import React from 'react'; -import { View, FlatList } from 'react-native'; +import React, { forwardRef, type PropsWithChildren } from 'react'; +import { + View, + FlatList, + type FlatListProps, + type ScrollViewProps, + type ViewProps, +} from 'react-native'; -export const BottomSheetModal = React.forwardRef( - ({ children }: any, _ref: any) => <>{children} -); -export const BottomSheetView = ({ children, style }: any) => ( +export interface BottomSheetModalRef { + present: () => void; + dismiss: () => void; +} + +export type SheetAnimationConfig = Record; + +export const BottomSheetModal = forwardRef< + BottomSheetModalRef, + PropsWithChildren +>(({ children }, _ref) => <>{children}); +BottomSheetModal.displayName = 'BottomSheetModal'; + +export const BottomSheetView = ({ children, style }: ViewProps) => ( {children} ); -export const BottomSheetFlatList = (props: any) => ; + +export const BottomSheetScrollView = ({ + children, + contentContainerStyle, + testID, +}: ScrollViewProps) => ( + + {children} + +); + +export const BottomSheetFlatList = (props: FlatListProps) => ( + +); + export const BottomSheetBackdrop = () => null; -export const BottomSheetModalProvider = ({ children }: any) => <>{children}; -export const useBottomSheetTimingConfigs = (_config: any) => ({}); -export const useBottomSheetSpringConfigs = (_config: any) => ({}); + +export const BottomSheetModalProvider = ({ children }: PropsWithChildren) => ( + <>{children} +); + +export const useBottomSheet = () => ({ close: () => {} }); + +export const useBottomSheetTimingConfigs = (config: SheetAnimationConfig) => + config; + +export const useBottomSheetSpringConfigs = (config: SheetAnimationConfig) => + config; diff --git a/__mocks__/react-native-reanimated.ts b/__mocks__/react-native-reanimated.ts index 3d23bf99..2ef96431 100644 --- a/__mocks__/react-native-reanimated.ts +++ b/__mocks__/react-native-reanimated.ts @@ -35,6 +35,7 @@ module.exports = { inOut: (fn: any) => fn, out: (fn: any) => fn, in: (fn: any) => fn, + bezier: () => (t: any) => t, }, interpolate: (val: any, inputRange: any, outputRange: any) => { if (val <= inputRange[0]) return outputRange[0]; diff --git a/__tests__/MessageItem.test.tsx b/__tests__/MessageItem.test.tsx index b16f617a..31e3de1d 100644 --- a/__tests__/MessageItem.test.tsx +++ b/__tests__/MessageItem.test.tsx @@ -79,6 +79,8 @@ jest.mock('@gorhom/bottom-sheet', () => { BottomSheetModal, BottomSheetView: View, BottomSheetScrollView: View, + useBottomSheet: () => ({ close: jest.fn() }), + useBottomSheetSpringConfigs: (config: unknown) => config, }; }); diff --git a/components/bottomSheets/SheetBackdrop.tsx b/components/bottomSheets/SheetBackdrop.tsx new file mode 100644 index 00000000..d67a11bb --- /dev/null +++ b/components/bottomSheets/SheetBackdrop.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { Pressable, useWindowDimensions } from 'react-native'; +import { + useBottomSheet, + type BottomSheetBackdropProps, +} from '@gorhom/bottom-sheet'; +import Animated, { + Extrapolation, + interpolate, + useAnimatedStyle, +} from 'react-native-reanimated'; +import { useTheme } from '../../context/ThemeContext'; +import { BACKDROP_CLOSE_FADE } from '../../constants/bottom-sheet'; + +const AnimatedPressable = Animated.createAnimatedComponent(Pressable); + +const SheetBackdrop = ({ + animatedPosition, + style, +}: BottomSheetBackdropProps) => { + const { close } = useBottomSheet(); + const { theme } = useTheme(); + const { height: screenHeight } = useWindowDimensions(); + + const animatedStyle = useAnimatedStyle(() => ({ + opacity: interpolate( + animatedPosition.value, + [screenHeight - BACKDROP_CLOSE_FADE, screenHeight], + [1, 0], + Extrapolation.CLAMP + ), + })); + + return ( + close()} + style={[style, { backgroundColor: theme.bg.overlay }, animatedStyle]} + /> + ); +}; + +export default SheetBackdrop; diff --git a/components/chat-screen/RowChevron.tsx b/components/chat-screen/RowChevron.tsx new file mode 100644 index 00000000..ec3cc7fd --- /dev/null +++ b/components/chat-screen/RowChevron.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import Animated, { + useAnimatedStyle, + useDerivedValue, + withTiming, +} from 'react-native-reanimated'; +import ChevronDownIcon from '../../assets/icons/chevron-down.svg'; +import { space } from '../../constants/design-system'; + +interface RowChevronProps { + expanded: boolean; + color: string; +} + +const RowChevron = ({ expanded, color }: RowChevronProps) => { + const progress = useDerivedValue(() => withTiming(expanded ? 1 : 0)); + + const animatedStyle = useAnimatedStyle(() => ({ + transform: [{ rotate: `${progress.value * 180}deg` }], + })); + + return ( + + + + ); +}; + +export default RowChevron; diff --git a/components/chat-screen/SourcesSheet.tsx b/components/chat-screen/SourcesSheet.tsx index dbbd8436..3b2f93e2 100644 --- a/components/chat-screen/SourcesSheet.tsx +++ b/components/chat-screen/SourcesSheet.tsx @@ -1,6 +1,7 @@ import React, { forwardRef, useCallback, + useEffect, useImperativeHandle, useMemo, useRef, @@ -11,25 +12,38 @@ import { StyleSheet, Text, Pressable, - LayoutAnimation, - Platform, - UIManager, - Dimensions, + useWindowDimensions, + type LayoutChangeEvent, } from 'react-native'; import { - BottomSheetBackdrop, BottomSheetModal, BottomSheetScrollView, + useBottomSheetSpringConfigs, type BottomSheetBackdropProps, type BottomSheetScrollViewMethods, } from '@gorhom/bottom-sheet'; import { useTheme } from '../../context/ThemeContext'; import { Theme } from '../../styles/colors'; -import { radius, space, textStyles } from '../../constants/design-system'; +import { + radius, + space, + stroke, + textStyles, +} from '../../constants/design-system'; +import { + EST_ROW_GAP, + EST_ROW_HEIGHT, + EST_SHEET_CHROME, + MAX_SHEET_HEIGHT_RATIO, + ROW_EXPAND_SCROLL_DELAY, + SHEET_HANDLE_HEIGHT, + SHEET_SPRING_CONFIG, +} from '../../constants/sources-sheet'; +import SheetBackdrop from '../bottomSheets/SheetBackdrop'; +import RowChevron from './RowChevron'; import SourceIcon from '../../assets/icons/source.svg'; -import ChevronDownIcon from '../../assets/icons/chevron-down.svg'; -import ChevronUpIcon from '../../assets/icons/chevron-up.svg'; import { type SourceDocument } from '../../database/chatRepository'; +import { getDocumentType, isSpreadsheetType } from '../../utils/documentType'; import { findCitedSpan, buildCitationExcerpt, @@ -37,45 +51,9 @@ import { type CitationExcerpt, } from '../../utils/citationHighlight'; -if ( - Platform.OS === 'android' && - UIManager.setLayoutAnimationEnabledExperimental -) { - UIManager.setLayoutAnimationEnabledExperimental(true); -} - -const SCREEN_HEIGHT = Dimensions.get('window').height; -const SOURCE_ROW_HEIGHT = space.twelve; -const SOURCE_ROW_GAP = space.one; -const SHEET_CHROME_HEIGHT = space.ten + space.eight; - -const getSourcesSnapPoints = ( - count: number, - bottomInset: number -): (string | number)[] => { - const contentHeight = - SHEET_CHROME_HEIGHT + - count * SOURCE_ROW_HEIGHT + - Math.max(0, count - 1) * SOURCE_ROW_GAP + - bottomInset + - space.eight; - const fraction = Math.min(0.9, Math.max(0.32, contentHeight / SCREEN_HEIGHT)); - const first = `${Math.round(fraction * 100)}%`; - return fraction >= 0.85 ? ['90%'] : [first, '90%']; -}; - -const SPREADSHEET_DOC_TYPES = new Set(['XLSX', 'XLS', 'XLSM', 'CSV']); +type SheetStyles = ReturnType; -const getDocumentType = (name: string): string => { - const lastDot = name.lastIndexOf('.'); - if (lastDot <= 0 || lastDot === name.length - 1) return ''; - return name.slice(lastDot + 1).toUpperCase(); -}; - -const renderPassage = ( - excerpt: CitationExcerpt, - styles: ReturnType -) => { +const renderPassage = (excerpt: CitationExcerpt, styles: SheetStyles) => { const { text, span } = excerpt; if ( @@ -98,6 +76,65 @@ const renderPassage = ( ); }; +interface SourceRowProps { + source: SourceDocument; + isHighlighted: boolean; + isExpanded: boolean; + excerpt: CitationExcerpt | null; + chevronColor: string; + styles: SheetStyles; + onToggle: () => void; + onLayout: (event: LayoutChangeEvent) => void; +} + +const SourceRow = ({ + source, + isHighlighted, + isExpanded, + excerpt, + chevronColor, + styles, + onToggle, + onLayout, +}: SourceRowProps) => { + const docType = getDocumentType(source.name); + const hasPassage = !!source.passage && !isSpreadsheetType(docType); + + return ( + + + + + {docType ? {docType} : null} + + + {source.name} + + {hasPassage ? ( + + ) : null} + + {hasPassage && isExpanded && excerpt ? ( + + {renderPassage(excerpt, styles)} + + ) : null} + + ); +}; + export interface SourcesSheetHandle { present: (highlightIndex?: number | null) => void; } @@ -111,20 +148,33 @@ const SourcesSheet = forwardRef( ({ sources, userQuestion }, ref) => { const { theme } = useTheme(); const styles = useMemo(() => createStyles(theme), [theme]); + const { height: screenHeight } = useWindowDimensions(); const sheetRef = useRef(null); + const isOpenRef = useRef(false); const scrollRef = useRef(null); const rowYRef = useRef>({}); const listYRef = useRef(0); + const scrollTimerRef = useRef | null>(null); const [highlightedIndex, setHighlightedIndex] = useState( null ); const [expandedIndex, setExpandedIndex] = useState(null); + const [contentHeight, setContentHeight] = useState(0); + + const clearScrollTimer = useCallback(() => { + if (scrollTimerRef.current) clearTimeout(scrollTimerRef.current); + scrollTimerRef.current = null; + }, []); + + useEffect(() => clearScrollTimer, [clearScrollTimer]); useImperativeHandle( ref, () => ({ present: (highlightIndex: number | null = null) => { + if (isOpenRef.current) return; + isOpenRef.current = true; setHighlightedIndex(highlightIndex); setExpandedIndex(highlightIndex); sheetRef.current?.present(); @@ -133,38 +183,27 @@ const SourcesSheet = forwardRef( [] ); - const snapPoints = useMemo( - () => getSourcesSnapPoints(sources.length, theme.insets.bottom), - [sources.length, theme.insets.bottom] - ); - const renderBackdrop = useCallback( - (props: BottomSheetBackdropProps) => ( - - ), + (props: BottomSheetBackdropProps) => , [] ); const toggleExpanded = useCallback( (index: number) => { - LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); const willExpand = expandedIndex !== index; setExpandedIndex(willExpand ? index : null); - if (willExpand) { - setTimeout(() => { - const y = listYRef.current + (rowYRef.current[index] ?? 0); - scrollRef.current?.scrollTo({ - y: Math.max(space.none, y - space.two), - animated: true, - }); - }, 260); - } + clearScrollTimer(); + if (!willExpand) return; + + scrollTimerRef.current = setTimeout(() => { + const y = listYRef.current + (rowYRef.current[index] ?? 0); + scrollRef.current?.scrollTo({ + y: Math.max(space.none, y - space.two), + animated: true, + }); + }, ROW_EXPAND_SCROLL_DELAY); }, - [expandedIndex] + [expandedIndex, clearScrollTimer] ); const anyNamedSource = useMemo( @@ -188,16 +227,38 @@ const SourcesSheet = forwardRef( return buildCitationExcerpt(passage, span); }, [expandedIndex, sources, userQuestion, anyNamedSource]); + const onContentSizeChange = useCallback((width: number, height: number) => { + const next = Math.round(height); + setContentHeight((prev) => (Math.abs(prev - next) > 1 ? next : prev)); + }, []); + + const snapPoints = useMemo(() => { + const seed = + EST_SHEET_CHROME + + theme.insets.bottom + + sources.length * EST_ROW_HEIGHT + + Math.max(0, sources.length - 1) * EST_ROW_GAP; + const target = contentHeight ? contentHeight + SHEET_HANDLE_HEIGHT : seed; + return [Math.min(target, screenHeight * MAX_SHEET_HEIGHT_RATIO)]; + }, [contentHeight, sources.length, theme.insets.bottom, screenHeight]); + + const animationConfigs = useBottomSheetSpringConfigs(SHEET_SPRING_CONFIG); + return ( { + isOpenRef.current = index >= 0; + }} onDismiss={() => { + isOpenRef.current = false; + clearScrollTimer(); setHighlightedIndex(null); setExpandedIndex(null); }} @@ -205,6 +266,7 @@ const SourcesSheet = forwardRef( Sources @@ -214,69 +276,21 @@ const SourcesSheet = forwardRef( listYRef.current = e.nativeEvent.layout.y; }} > - {sources.map((source, index) => { - const docType = getDocumentType(source.name); - const isSpreadsheet = SPREADSHEET_DOC_TYPES.has(docType); - const hasPassage = !!source.passage && !isSpreadsheet; - const isExpanded = expandedIndex === index; - - return ( - toggleExpanded(index) : undefined} - onLayout={(e) => { - rowYRef.current[index] = e.nativeEvent.layout.y; - }} - disabled={!hasPassage} - accessibilityRole="button" - accessibilityState={{ expanded: isExpanded }} - testID="source-item" - > - - - - - {docType ? ( - {docType} - ) : null} - - {source.name} - - {hasPassage ? ( - isExpanded ? ( - - ) : ( - - ) - ) : null} - - {hasPassage && isExpanded && expandedExcerpt ? ( - - {renderPassage(expandedExcerpt, styles)} - - ) : null} - - ); - })} + {sources.map((source, index) => ( + toggleExpanded(index)} + onLayout={(e) => { + rowYRef.current[index] = e.nativeEvent.layout.y; + }} + /> + ))} @@ -308,14 +322,16 @@ const createStyles = (theme: Theme) => color: theme.text.primary, }, sourcesList: { - gap: space.one, + gap: space.two, }, sourceRow: { flexDirection: 'column', gap: space.two, - paddingVertical: space.twoHalf, - paddingHorizontal: space.two, + paddingVertical: space.three, + paddingHorizontal: space.three, borderRadius: radius.twelve, + borderWidth: stroke.soft, + borderColor: theme.border.soft, }, sourceRowHighlighted: { backgroundColor: theme.bg.softSecondary, @@ -325,29 +341,28 @@ const createStyles = (theme: Theme) => alignItems: 'center', gap: space.two, }, - sourceIconWrapper: { - width: space.six + space.one, - height: space.six + space.one, - borderRadius: radius.six, - backgroundColor: theme.bg.softSecondary, - justifyContent: 'center', + typeBadge: { + flexDirection: 'row', alignItems: 'center', + gap: space.half, + paddingVertical: space.one, + paddingHorizontal: space.two, + borderWidth: stroke.soft, + borderColor: theme.border.soft, + borderRadius: radius.full, }, - sourceRowIcon: { - color: theme.text.primary, + typeBadgeIcon: { + color: theme.text.defaultSecondary, }, - sourceRowType: { - ...textStyles.bodyQuaternaryMedium, - color: theme.text.defaultTertiary, + typeBadgeText: { + ...textStyles.bodyTertiaryRegular, + color: theme.text.defaultSecondary, }, sourceRowName: { flex: 1, ...textStyles.bodySecondaryMedium, color: theme.text.primary, }, - sourceRowChevron: { - color: theme.text.defaultTertiary, - }, sourcePassageText: { ...textStyles.bodyTertiaryRegular, color: theme.text.defaultTertiary, diff --git a/constants/bottom-sheet.ts b/constants/bottom-sheet.ts new file mode 100644 index 00000000..34410904 --- /dev/null +++ b/constants/bottom-sheet.ts @@ -0,0 +1 @@ +export const BACKDROP_CLOSE_FADE = 120; diff --git a/constants/documents.ts b/constants/documents.ts new file mode 100644 index 00000000..3ff6ae4d --- /dev/null +++ b/constants/documents.ts @@ -0,0 +1 @@ +export const SPREADSHEET_DOC_TYPES = new Set(['XLSX', 'XLS', 'XLSM', 'CSV']); diff --git a/constants/sources-sheet.ts b/constants/sources-sheet.ts new file mode 100644 index 00000000..d76fb86f --- /dev/null +++ b/constants/sources-sheet.ts @@ -0,0 +1,29 @@ +import { space, textStyles } from './design-system'; + +export const MAX_SHEET_HEIGHT_RATIO = 0.9; +export const SHEET_HANDLE_HEIGHT = 24; + +const SHEET_CONTENT_PADDING_TOP = space.two; +const SHEET_CONTENT_PADDING_BOTTOM = space.eight; +const SHEET_TITLE_HEIGHT = textStyles.titleH3.lineHeight; +const SHEET_TITLE_GAP = space.three; + +export const EST_ROW_HEIGHT = 50; +export const EST_ROW_GAP = space.two; +export const EST_SHEET_CHROME = + SHEET_HANDLE_HEIGHT + + SHEET_CONTENT_PADDING_TOP + + SHEET_TITLE_HEIGHT + + SHEET_TITLE_GAP + + SHEET_CONTENT_PADDING_BOTTOM; + +export const ROW_EXPAND_SCROLL_DELAY = 260; + +export const SHEET_SPRING_CONFIG = { + damping: 50, + stiffness: 300, + mass: 1, + overshootClamping: true, + restDisplacementThreshold: 0.5, + restSpeedThreshold: 2, +}; diff --git a/utils/documentType.ts b/utils/documentType.ts new file mode 100644 index 00000000..36b7f298 --- /dev/null +++ b/utils/documentType.ts @@ -0,0 +1,10 @@ +import { SPREADSHEET_DOC_TYPES } from '../constants/documents'; + +export const getDocumentType = (name: string): string => { + const lastDot = name.lastIndexOf('.'); + if (lastDot <= 0 || lastDot === name.length - 1) return ''; + return name.slice(lastDot + 1).toUpperCase(); +}; + +export const isSpreadsheetType = (docType: string): boolean => + SPREADSHEET_DOC_TYPES.has(docType); From de344c62da952ac52dcd021c23dd937be4ec21ea Mon Sep 17 00:00:00 2001 From: Krzysztof Faracik Date: Mon, 13 Jul 2026 15:52:15 +0200 Subject: [PATCH 19/42] feat(citations): widen no-answer detection for PL/EN refusals --- constants/citations.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/constants/citations.ts b/constants/citations.ts index 27600a3b..fbd93e53 100644 --- a/constants/citations.ts +++ b/constants/citations.ts @@ -16,6 +16,7 @@ const NO_ANSWER_META_PL = // English "no information" refusal patterns; each negation is tied to a coverage noun. export const NO_ANSWER_PATTERNS_EN: RegExp[] = [ + new RegExp(`\\bno (${NO_ANSWER_META_EN})\\b`, 'i'), new RegExp(`\\bthere (is|are|'s) no (${NO_ANSWER_META_EN})\\b`, 'i'), new RegExp( `\\b(does|do|did|could|can) ?n['o]?t (contain|mention|include|provide|specify|cover|have|state|say)( any| any relevant)? (${NO_ANSWER_META_EN})\\b`, @@ -36,6 +37,8 @@ export const NO_ANSWER_PATTERNS_PL: RegExp[] = [ `\\b(nie ma|brak|nie zawiera\\w*|nie znaleziono|nie podano|nie wymienia\\w*) (żadn\\w* )?(${NO_ANSWER_META_PL})\\b`, 'i' ), + /\bnie wspomina\w*\b/i, + /\bnie odnosi si\w*\b/i, /\bnie wiem\b/i, /\bnie mog\w* (znaleźć|odpowiedzieć|okre\w*)\b/i, ]; From a11468ccf894a3a3ad5b59e1b7931c5063176f13 Mon Sep 17 00:00:00 2001 From: Krzysztof Faracik Date: Tue, 14 Jul 2026 13:51:54 +0200 Subject: [PATCH 20/42] fix(attachments): abort embedding on cancel and cap oversized document --- __tests__/ChatBar.test.tsx | 52 ++++++++++++++++++++++------ __tests__/hybridRetrieval.test.ts | 7 ++-- __tests__/llmStore.test.ts | 17 +++++---- __tests__/sourceStore.test.ts | 57 ++++++++++++++++++++++++++++--- constants/retrieval.ts | 3 ++ hooks/useAttachment.ts | 18 +++++++++- store/chatStore.ts | 1 - store/embeddingModelStore.ts | 6 +++- store/llmStore.ts | 4 +-- store/sourceStore.ts | 41 +++++++++++++++++++--- 10 files changed, 172 insertions(+), 34 deletions(-) diff --git a/__tests__/ChatBar.test.tsx b/__tests__/ChatBar.test.tsx index b3e890f0..3e8a29d9 100644 --- a/__tests__/ChatBar.test.tsx +++ b/__tests__/ChatBar.test.tsx @@ -1,6 +1,8 @@ import React from 'react'; import { render, screen, fireEvent, act } from '@testing-library/react-native'; import type { LLMStore } from '../store/llmStore'; +import type { Attachment } from '../hooks/useAttachment'; +import type { PermissionStatus } from 'react-native-audio-api'; // ── mocks ───────────────────────────────────────────────────────────────────── @@ -27,7 +29,7 @@ jest.mock('../store/llmStore', () => ({ })); const mockUseAttachment = { - attachments: [] as any[], + attachments: [] as Attachment[], sheetRef: { current: null }, pickFromLibrary: jest.fn(), pickFromCamera: jest.fn(), @@ -49,7 +51,12 @@ jest.mock('../components/bottomSheets/AttachmentSheet', () => { onPickFromCamera, onPickDocument, isVisionModel, - }: any) => ( + }: { + onPickFromLibrary: () => void; + onPickFromCamera: () => void; + onPickDocument: () => void; + isVisionModel: boolean; + }) => ( {isVisionModel && ( <> @@ -73,7 +80,13 @@ jest.mock('../components/bottomSheets/AttachmentSheet', () => { jest.mock('../components/chat-screen/AttachmentThumbnail', () => { const { View, TouchableOpacity, Text } = require('react-native'); - return ({ attachment, onRemove }: any) => ( + return ({ + attachment, + onRemove, + }: { + attachment: Attachment; + onRemove: () => void; + }) => ( {attachment.name || attachment.uri} { }); jest.mock('../components/chat-screen/ChatSpeechInput', () => { - const { View, TouchableOpacity, Text } = require('react-native'); - return ({ onSubmit, onCancel }: any) => ( + const { View, TouchableOpacity } = require('react-native'); + return ({ + onSubmit, + onCancel, + }: { + onSubmit: (transcript: string) => void; + onCancel: () => void; + }) => ( { jest.mock('../components/chat-screen/PromptSuggestions', () => { const { TouchableOpacity, Text } = require('react-native'); - return ({ onSelectPrompt }: any) => ( + return ({ onSelectPrompt }: { onSelectPrompt: (prompt: string) => void }) => ( onSelectPrompt('Suggested prompt')} @@ -124,7 +143,18 @@ jest.mock('../components/chat-screen/ChatBarActions', () => { onThinkingToggle, thinkingEnabled, onAttach, - }: any) => ( + }: { + userInput: string; + hasAttachments: boolean; + onSend: () => void; + isGenerating: boolean; + isProcessingPrompt: boolean; + onInterrupt: () => void; + onSpeechInput: () => void; + onThinkingToggle: () => void; + thinkingEnabled: boolean; + onAttach: () => void; + }) => ( + @@ -185,7 +215,7 @@ const defaultProps = { onSelectModel: jest.fn(), onSelectPrompt: jest.fn(), model: downloadedModel, - scrollRef: { current: null } as any, + scrollRef: { current: null }, isAtBottom: true, isVisionModel: false, thinkingEnabled: false, @@ -218,7 +248,7 @@ beforeEach(() => { jest.spyOn(console, 'warn').mockImplementation(() => {}); // Default: permission granted mockAudioManager.requestRecordingPermissions.mockResolvedValue( - 'Granted' as any + 'Granted' as PermissionStatus ); }); @@ -357,7 +387,7 @@ describe('speech input', () => { it('shows toast and stays in text mode when microphone permission is denied', async () => { mockAudioManager.requestRecordingPermissions.mockResolvedValue( - 'Denied' as any + 'Denied' as PermissionStatus ); renderBar(); await act(async () => { @@ -430,7 +460,7 @@ describe('speech input', () => { // Override speech mock to submit empty string jest.mock('../components/chat-screen/ChatSpeechInput', () => { const { View, TouchableOpacity } = require('react-native'); - return ({ onSubmit }: any) => ( + return ({ onSubmit }: { onSubmit: (transcript: string) => void }) => ( ({ keywordSearch: jest.fn(), @@ -8,8 +9,8 @@ jest.mock('../database/keywordIndex', () => ({ const mockKeywordSearch = keywordIndex.keywordSearch as jest.Mock; const makeVectorStore = ( - queryResults: any[], - vectorsById: Record + queryResults: unknown[], + vectorsById: Record ) => ({ query: jest.fn().mockResolvedValue(queryResults), @@ -20,7 +21,7 @@ const makeVectorStore = ( rows: ids.map((id) => vectorsById[id]).filter(Boolean), })), }, - }) as any; + }) as unknown as OPSQLiteVectorStore; describe('hybridRetrieve', () => { beforeEach(() => { diff --git a/__tests__/llmStore.test.ts b/__tests__/llmStore.test.ts index e2f57486..f4d76857 100644 --- a/__tests__/llmStore.test.ts +++ b/__tests__/llmStore.test.ts @@ -1,6 +1,9 @@ import { useLLMStore } from '../store/llmStore'; import { LLMModule } from 'react-native-executorch'; import * as chatRepository from '../database/chatRepository'; +import type { Message } from '../database/chatRepository'; +import type { Model } from '../database/modelRepository'; +import type { SQLiteDatabase } from 'expo-sqlite'; import * as Feedback from '../utils/Feedback'; import { prepareMessagesForLLM } from '../utils/promptUtils'; @@ -29,7 +32,7 @@ const noSources = async () => ({ preferredSourceDocuments: [], }); -const mockDb = {} as any; +const mockDb = {} as unknown as SQLiteDatabase; const baseModel = { id: 1, @@ -64,7 +67,7 @@ beforeEach(() => { mockLLMModule.fromModelName.mockImplementation( async (_namedSources, _onProgress, onToken) => { capturedTokenCallback = onToken; - return mockInstance as any; + return mockInstance as unknown as LLMModule; } ); @@ -89,7 +92,7 @@ beforeEach(() => { mockLLMModule.fromModelName.mockImplementation( async (_namedSources, _onProgress, onToken) => { capturedTokenCallback = onToken; - return mockInstance as any; + return mockInstance as unknown as LLMModule; } ); }); @@ -112,7 +115,7 @@ describe('loadModel', () => { mockLLMModule.fromModelName.mockImplementation(async (...args) => { wasLoading = useLLMStore.getState().isLoading; capturedTokenCallback = args[4]; - return mockInstance as any; + return mockInstance as unknown as LLMModule; }); await useLLMStore.getState().loadModel(baseModel); @@ -142,7 +145,7 @@ describe('loadModel', () => { mockInstance = makeMockInstance(); mockLLMModule.fromModelName.mockImplementation(async (...args) => { capturedTokenCallback = args[4]; - return mockInstance as any; + return mockInstance as unknown as LLMModule; }); await useLLMStore.getState().loadModel({ ...baseModel, id: 2 }); @@ -367,7 +370,7 @@ describe('sendChatMessage', () => { }); it('adds user message and assistant placeholder to activeChatMessages before generating', async () => { - let messagesBeforeGenerate: any[] = []; + let messagesBeforeGenerate: Message[] = []; mockInstance.generate.mockImplementation(async () => { messagesBeforeGenerate = useLLMStore.getState().activeChatMessages; return 'response'; @@ -536,7 +539,7 @@ describe('sendChatMessage imagePath', () => { modelName: 'LFM VL', vision: true, featured: true, - } as any, + } as Model, activeChatId: 1, activeChatMessages: [], }); diff --git a/__tests__/sourceStore.test.ts b/__tests__/sourceStore.test.ts index b823a8df..315ff534 100644 --- a/__tests__/sourceStore.test.ts +++ b/__tests__/sourceStore.test.ts @@ -6,6 +6,7 @@ import { useLLMStore } from '../store/llmStore'; import type { SQLiteDatabase } from 'expo-sqlite'; import type { OPSQLiteVectorStore } from '@react-native-rag/op-sqlite'; import type { LFMEmbeddings } from '../utils/lfmEmbeddings'; +import { MAX_SOURCE_CHUNKS } from '../constants/retrieval'; jest.mock('../database/sourcesRepository'); jest.mock('../utils/fileReaders'); @@ -25,8 +26,10 @@ jest.mock('@react-native-rag/op-sqlite', () => ({})); const mockDb = {} as Partial as SQLiteDatabase; const vectorStoreAdd = jest.fn(); +const vectorStoreDelete = jest.fn(); const mockVectorStore = { add: vectorStoreAdd, + delete: vectorStoreDelete, } as Partial as OPSQLiteVectorStore; const mockReadDocumentText = fileReaders.readDocumentText as jest.Mock; @@ -141,12 +144,58 @@ describe('addSource', () => { .addSource(baseSource, '/path/doc.txt', mockVectorStore); const sources = useSourceStore.getState().sources; - expect(result).toEqual({ success: true, sourceId: 99 }); + expect(result).toEqual({ success: true, sourceId: 99, truncated: false }); expect(sources).toHaveLength(1); expect(sources[0].id).toBe(99); expect(sources[0].isProcessing).toBe(false); }); + it('caps embedded chunks at MAX_SOURCE_CHUNKS and flags the result truncated', async () => { + mockReadDocumentText.mockResolvedValue('content'); + mockInsertSource.mockResolvedValue(99); + const manyChunks = Array.from( + { length: MAX_SOURCE_CHUNKS + 1 }, + (_, i) => `chunk-${i}` + ); + MockSplitter.mockImplementation(() => ({ + splitText: jest.fn().mockResolvedValue(manyChunks), + })); + + const result = await useSourceStore + .getState() + .addSource(baseSource, '/path/doc.txt', mockVectorStore); + + expect(result).toEqual({ success: true, sourceId: 99, truncated: true }); + expect(vectorStoreAdd).toHaveBeenCalledTimes(MAX_SOURCE_CHUNKS); + }); + + it('aborts embedding and rolls back the partial source when the signal is aborted', async () => { + mockReadDocumentText.mockResolvedValue('content'); + mockInsertSource.mockResolvedValue(99); + MockSplitter.mockImplementation(() => ({ + splitText: jest.fn().mockResolvedValue(['chunk-a', 'chunk-b']), + })); + const controller = new AbortController(); + controller.abort(); + + const result = await useSourceStore + .getState() + .addSource( + baseSource, + '/path/doc.txt', + mockVectorStore, + undefined, + undefined, + controller.signal + ); + + expect(result).toEqual({ success: false, cancelled: true }); + expect(vectorStoreAdd).not.toHaveBeenCalled(); + expect(vectorStoreDelete).toHaveBeenCalledTimes(1); + expect(mockDeleteSource).toHaveBeenCalledWith(mockDb, 99); + expect(useSourceStore.getState().sources).toHaveLength(0); + }); + it('passes firstChunk to insertSource', async () => { mockReadDocumentText.mockResolvedValue('content'); mockInsertSource.mockResolvedValue(1); @@ -316,10 +365,10 @@ describe('cleanupOrphanedSources', () => { mockGetOrphanedSources.mockResolvedValue(orphaned); mockDeleteSource.mockResolvedValue(undefined); - const vectorStoreDelete = jest.fn(); + const orphanVectorStoreDelete = jest.fn(); const mockVectorStoreWithDelete = { add: vectorStoreAdd, - delete: vectorStoreDelete, + delete: orphanVectorStoreDelete, } as Partial as OPSQLiteVectorStore; await useSourceStore @@ -328,7 +377,7 @@ describe('cleanupOrphanedSources', () => { expect(mockGetOrphanedSources).toHaveBeenCalledWith(mockDb); expect(mockDeleteSource).toHaveBeenCalledWith(mockDb, 5); - expect(vectorStoreDelete).toHaveBeenCalledWith({ + expect(orphanVectorStoreDelete).toHaveBeenCalledWith({ predicate: expect.any(Function), }); }); diff --git a/constants/retrieval.ts b/constants/retrieval.ts index ba2c3bd1..81737a17 100644 --- a/constants/retrieval.ts +++ b/constants/retrieval.ts @@ -41,6 +41,9 @@ export const ANSWER_CITATION_OVERLAP_RATIO = 0.5; export const TEXT_SPLITTER_CHUNK_SIZE = 1000; export const TEXT_SPLITTER_CHUNK_OVERLAP = 200; +/** Safety backstop on chunks embedded per source; high enough that large real documents (~1.6 MB of text) index in full, low enough to stop a pathological multi-MB file from embedding for tens of minutes. */ +export const MAX_SOURCE_CHUNKS = 2000; + /** Min matched run to treat as overlap when stitching passages — below the splitter overlap, above coincidental repetition. */ export const MIN_STITCH_OVERLAP = 24; diff --git a/hooks/useAttachment.ts b/hooks/useAttachment.ts index 37cfcf06..24326da2 100644 --- a/hooks/useAttachment.ts +++ b/hooks/useAttachment.ts @@ -62,6 +62,7 @@ export const useAttachment = () => { attachmentsRef.current = attachments; const attachmentRequestRef = useRef(0); const currentDocumentAttachmentIdRef = useRef(null); + const documentAbortRef = useRef(null); const sheetRef = useRef(null); const embeddingDownloadSheetRef = useRef(null); const embeddingDownloadSheetOpenRef = useRef(false); @@ -143,6 +144,10 @@ export const useAttachment = () => { attachmentRequestRef.current = requestId; currentDocumentAttachmentIdRef.current = attachmentId; + documentAbortRef.current?.abort(); + const abortController = new AbortController(); + documentAbortRef.current = abortController; + setAttachments([ { id: attachmentId, @@ -180,8 +185,10 @@ export const useAttachment = () => { asset.uri, vectorStore!, embeddings, - handleProgress + handleProgress, + abortController.signal ); + if (result.cancelled) return; const isCurrentDocumentRequest = attachmentRequestRef.current === requestId && currentDocumentAttachmentIdRef.current === attachmentId; @@ -203,6 +210,13 @@ export const useAttachment = () => { : a ) ); + if (result.truncated) { + Toast.show({ + type: 'defaultToast', + text1: + 'This document is large — only the first part was indexed for search.', + }); + } } else { if (!isCurrentDocumentRequest) return; @@ -262,6 +276,7 @@ export const useAttachment = () => { const removeAttachment = useCallback((id: string) => { if (currentDocumentAttachmentIdRef.current === id) { currentDocumentAttachmentIdRef.current = null; + documentAbortRef.current?.abort(); } setAttachments((prev) => prev.filter((a) => a.id !== id)); }, []); @@ -271,6 +286,7 @@ export const useAttachment = () => { const cleanupSources = options.cleanupSources ?? false; const hadDocuments = attachmentsRef.current.some((a) => a.sourceId); currentDocumentAttachmentIdRef.current = null; + documentAbortRef.current?.abort(); setAttachments([]); if (cleanupSources && hadDocuments && vectorStore) { useSourceStore.getState().cleanupOrphanedSources(vectorStore); diff --git a/store/chatStore.ts b/store/chatStore.ts index 81cc7c04..dcf14b18 100644 --- a/store/chatStore.ts +++ b/store/chatStore.ts @@ -11,7 +11,6 @@ import { setChatModel, ChatSettings, getChatSettings, - getNextChatId, setChatSettings, } from '../database/chatRepository'; import { diff --git a/store/embeddingModelStore.ts b/store/embeddingModelStore.ts index b3f5cc09..2adc692f 100644 --- a/store/embeddingModelStore.ts +++ b/store/embeddingModelStore.ts @@ -2,7 +2,11 @@ import { create } from 'zustand'; import { OPSQLiteVectorStore } from '@react-native-rag/op-sqlite'; export type EmbeddingModelStatus = - 'unknown' | 'not_downloaded' | 'downloading' | 'ready' | 'error'; + | 'unknown' + | 'not_downloaded' + | 'downloading' + | 'ready' + | 'error'; type EmbeddingModelStore = { status: EmbeddingModelStatus; diff --git a/store/llmStore.ts b/store/llmStore.ts index eeba2d12..5bbb67b4 100644 --- a/store/llmStore.ts +++ b/store/llmStore.ts @@ -214,7 +214,7 @@ const generateLLMResponse = async ( content: [ { type: 'image' }, { type: 'text', text: msg.content as string }, - ] as any, + ] as unknown as string, } : msg ); @@ -549,7 +549,7 @@ export const useLLMStore = create((set, get) => ({ tokensGenerated: llmInstance.getGeneratedTokenCount(), peakMemory: runPeakMemory, }; - } catch (e) { + } catch { memoryTracker.stop(); } finally { set({ isGenerating: false, isBenchmarking: false }); diff --git a/store/sourceStore.ts b/store/sourceStore.ts index 5f61635e..45ac3750 100644 --- a/store/sourceStore.ts +++ b/store/sourceStore.ts @@ -19,6 +19,7 @@ import { removeDocumentFromKeywordIndex, } from '../database/keywordIndex'; import { + MAX_SOURCE_CHUNKS, TEXT_SPLITTER_CHUNK_OVERLAP, TEXT_SPLITTER_CHUNK_SIZE, } from '../constants/retrieval'; @@ -34,12 +35,15 @@ interface SourceStore { sourceUri: string, vectorStore: OPSQLiteVectorStore, embeddings?: LFMEmbeddings | null, - onProgress?: (progress: number) => void + onProgress?: (progress: number) => void, + signal?: AbortSignal ) => Promise<{ success: boolean; isEmpty?: boolean; reason?: 'scanned_pdf'; sourceId?: number; + cancelled?: boolean; + truncated?: boolean; }>; setSourceProcessing: (id: number, isProcessing: boolean) => void; deleteSource: (source: Source) => Promise; @@ -67,7 +71,14 @@ export const useSourceStore = create((set, get) => ({ } }, - addSource: async (source, sourceUri, vectorStore, embeddings, onProgress) => { + addSource: async ( + source, + sourceUri, + vectorStore, + embeddings, + onProgress, + signal + ) => { const db = get().db; if (!db) return { success: false }; @@ -93,7 +104,11 @@ export const useSourceStore = create((set, get) => ({ chunkSize: TEXT_SPLITTER_CHUNK_SIZE, chunkOverlap: TEXT_SPLITTER_CHUNK_OVERLAP, }); - const chunks = await textSplitter.splitText(sourceTextContent); + const allChunks = await textSplitter.splitText(sourceTextContent); + const truncated = allChunks.length > MAX_SOURCE_CHUNKS; + const chunks = truncated + ? allChunks.slice(0, MAX_SOURCE_CHUNKS) + : allChunks; const sourceId = await insertSource(db, { ...source, @@ -106,8 +121,25 @@ export const useSourceStore = create((set, get) => ({ return { success: false }; } + const rollbackPartialSource = async () => { + if (vectorStore) { + await vectorStore.delete({ + predicate: (value) => value.metadata?.documentId === sourceId, + }); + await removeDocumentFromKeywordIndex(vectorStore.db, sourceId); + } + await deleteSource(db, sourceId); + set((state) => ({ + sources: state.sources.filter((s) => s.id !== tempId), + })); + }; + onProgress?.(0); for (let i = 0; i < chunks.length; i++) { + if (signal?.aborted) { + await rollbackPartialSource(); + return { success: false, cancelled: true }; + } const embedding = embeddings ? await embeddings.embedDocument(chunks[i]!) : undefined; @@ -133,6 +165,7 @@ export const useSourceStore = create((set, get) => ({ } onProgress?.((i + 1) / chunks.length); } + set((state) => ({ sources: state.sources.map((s) => s.id === tempId @@ -140,7 +173,7 @@ export const useSourceStore = create((set, get) => ({ : s ), })); - return { success: true, sourceId }; + return { success: true, sourceId, truncated }; } catch (e) { console.error(e); set((state) => ({ From eeb6e367492efbdb14a4d8b671ebeefd17ebb35d Mon Sep 17 00:00:00 2001 From: Krzysztof Faracik Date: Thu, 16 Jul 2026 10:28:01 +0200 Subject: [PATCH 21/42] =?UTF-8?q?refactor(rag):=20add=20HybridRetriever=20?= =?UTF-8?q?wrapper=20as=20app=E2=86=94lib=20boundary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wraps hybridRetrieve in a HybridRetriever class that binds the vector store and embeddings, exposing a single retrieve(query, options) call as the explicit app↔react-native-rag boundary. hybridRetrieve is unchanged and still the production path (messageSources.ts untouched); the class is the boundary/test seam. No generic and no interface — one impl, one caller; extract an interface only if a second retriever appears. Deliberately not `implements VectorStore`: the hybrid is read-only and its ContextChunk output drops id/embedding, so coercing to QueryResult would change retrieval results. Test proves 1:1 forwarding with load-bearing attachmentSourceIds and sourceNamesById, so a spread that dropped either would fail. Co-Authored-By: Claude Opus 4.8 (1M context) --- __tests__/hybridRetrieval.test.ts | 59 ++++++++++++++++++++++++++++++- utils/hybridRetrieval.ts | 31 ++++++++++++++++ 2 files changed, 89 insertions(+), 1 deletion(-) diff --git a/__tests__/hybridRetrieval.test.ts b/__tests__/hybridRetrieval.test.ts index b880d41d..b089e956 100644 --- a/__tests__/hybridRetrieval.test.ts +++ b/__tests__/hybridRetrieval.test.ts @@ -1,4 +1,4 @@ -import { hybridRetrieve } from '../utils/hybridRetrieval'; +import { hybridRetrieve, HybridRetriever } from '../utils/hybridRetrieval'; import * as keywordIndex from '../database/keywordIndex'; import type { OPSQLiteVectorStore } from '@react-native-rag/op-sqlite'; @@ -483,3 +483,60 @@ describe('hybridRetrieve', () => { expect(result.map((c) => c.similarity)).toEqual([0, 0.9, 0]); }); }); + +describe('HybridRetriever', () => { + beforeEach(() => { + mockKeywordSearch.mockReset(); + }); + + // The cases above already exercise the hybrid logic; this proves the wrapper + // forwards 1:1 — query→prompt, store/embeddings from the constructor, and + // every option spread through. Inputs are chosen so the two options a naive + // spread could silently drop are load-bearing: sourceNamesById resolves doc + // 1's missing name, and attachmentSourceIds keeps doc 2's otherwise-gated + // low-similarity chunk and orders it first. A wrapper that dropped either + // would diverge from the raw call and fail the toEqual. + it('forwards to hybridRetrieve 1:1, including attachmentSourceIds and sourceNamesById', async () => { + const vectorResults = [ + { + id: '1:0', + document: 'a semantic passage about felines', + embedding: [1, 0], + similarity: 0.8, + metadata: { documentId: 1 }, // no name → resolved via sourceNamesById + }, + { + id: '2:0', + document: 'freshly attached, low semantic overlap', + embedding: [0, 1], + similarity: 0.05, // gated out unless treated as an attachment + metadata: { documentId: 2, name: 'Attachment' }, + }, + ]; + mockKeywordSearch.mockResolvedValue([]); + + const store = makeVectorStore(vectorResults, {}); + const options = { + enabledSourceIds: [1, 2], + sourceNamesById: new Map([[1, 'ResolvedName']]), + attachmentSourceIds: [2], + }; + + const viaWrapper = await new HybridRetriever(store, null).retrieve( + 'felines', + options + ); + const viaFunction = await hybridRetrieve({ + prompt: 'felines', + vectorStore: store, + embeddings: null, + ...options, + }); + + const names = viaWrapper.map((c) => c.metadata?.name); + expect(names).toContain('ResolvedName'); // sourceNamesById forwarded + expect(names).toContain('Attachment'); // attachmentSourceIds forwarded + expect(viaWrapper[0]?.metadata?.name).toBe('Attachment'); // attachment ordered first + expect(viaWrapper).toEqual(viaFunction); // and identical to the raw call + }); +}); diff --git a/utils/hybridRetrieval.ts b/utils/hybridRetrieval.ts index c019486e..2acd84d5 100644 --- a/utils/hybridRetrieval.ts +++ b/utils/hybridRetrieval.ts @@ -408,3 +408,34 @@ export const hybridRetrieve = async ({ sourceNamesById ); }; + +// Thin app↔library boundary. Binds the store + embeddings so retrieval is a +// single retrieve(query, options) call, making the hybrid testable and portable +// in isolation. It forwards to hybridRetrieve unchanged — no interface, because +// there is one implementation and one caller; extract a Retriever interface only +// if a second retriever ever appears. Deliberately NOT `implements VectorStore`: +// the hybrid is read-only and its ContextChunk output drops id/embedding, so +// coercing to QueryResult would change retrieval results. +export type HybridRetrieveOptions = Omit< + HybridRetrieveParams, + 'prompt' | 'vectorStore' | 'embeddings' +>; + +export class HybridRetriever { + constructor( + private vectorStore: OPSQLiteVectorStore, + private embeddings?: LFMEmbeddings | null + ) {} + + retrieve( + query: string, + options: HybridRetrieveOptions + ): Promise { + return hybridRetrieve({ + prompt: query, + vectorStore: this.vectorStore, + embeddings: this.embeddings, + ...options, + }); + } +} From 16f41c7cfcf91f84f71daa87d5fec9214e78ff1b Mon Sep 17 00:00:00 2001 From: Krzysztof Faracik Date: Thu, 16 Jul 2026 13:11:38 +0200 Subject: [PATCH 22/42] fix(rag): make vector store teardown idempotent and abort init on unmount Single unload path guarded by an idempotency flag prevents the double unload when an effect run is torn down mid-init, and cancel checkpoints before the destructive migration and model load stop work for a superseded run. Adds characterization tests for the init/teardown chain. Co-Authored-By: Claude Opus 4.8 --- __tests__/VectorStoreContext.test.tsx | 158 ++++++++++++++++++++++++++ context/VectorStoreContext.tsx | 36 +++--- 2 files changed, 176 insertions(+), 18 deletions(-) create mode 100644 __tests__/VectorStoreContext.test.tsx diff --git a/__tests__/VectorStoreContext.test.tsx b/__tests__/VectorStoreContext.test.tsx new file mode 100644 index 00000000..76f13f5c --- /dev/null +++ b/__tests__/VectorStoreContext.test.tsx @@ -0,0 +1,158 @@ +import React from 'react'; +import { render, act } from '@testing-library/react-native'; + +const mockStoreInstances: Array<{ + name: string; + db: unknown; + load: jest.Mock; + unload: jest.Mock; +}> = []; + +jest.mock('@react-native-rag/op-sqlite', () => ({ + OPSQLiteVectorStore: jest + .fn() + .mockImplementation((opts: { name: string }) => { + const instance = { + name: opts.name, + db: { __fakeDb: true }, + load: jest.fn().mockResolvedValue(undefined), + unload: jest.fn().mockResolvedValue(undefined), + }; + mockStoreInstances.push(instance); + return instance; + }), +})); + +jest.mock('expo-sqlite', () => { + const db = { __db: true }; + return { useSQLiteContext: jest.fn(() => db) }; +}); + +jest.mock('../utils/embeddingModelMigration', () => ({ + migrateEmbeddingModelIfNeeded: jest.fn().mockResolvedValue(undefined), +})); + +jest.mock('../database/keywordIndex', () => ({ + ensureKeywordIndex: jest.fn().mockResolvedValue(undefined), +})); + +jest.mock('../utils/embeddingModel', () => ({ + isEmbeddingModelDownloaded: jest.fn().mockResolvedValue(true), +})); + +jest.mock('../utils/lfmEmbeddings', () => ({ + LFMEmbeddings: jest.fn().mockImplementation(() => ({})), +})); + +jest.mock('../store/embeddingModelStore', () => ({ + useEmbeddingModelStore: { + getState: jest.fn(() => ({ + setProgress: jest.fn(), + markReady: jest.fn(), + setStatus: jest.fn(), + })), + }, +})); + +import { VectorStoreProvider } from '../context/VectorStoreContext'; +import { migrateEmbeddingModelIfNeeded } from '../utils/embeddingModelMigration'; + +const mockMigrate = migrateEmbeddingModelIfNeeded as jest.Mock; + +const createDeferred = () => { + let resolve!: (value: T) => void; + const promise = new Promise((promiseResolve) => { + resolve = promiseResolve; + }); + return { promise, resolve }; +}; + +const flush = async () => { + await act(async () => { + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); + }); +}; + +const renderProvider = () => + render({null}); + +beforeEach(() => { + jest.clearAllMocks(); + mockStoreInstances.length = 0; + mockMigrate.mockResolvedValue(undefined); +}); + +afterEach(async () => { + await flush(); +}); + +describe('VectorStoreProvider init/teardown chain', () => { + it('mount → unmount → mount does a single unload per store', async () => { + const first = renderProvider(); + await flush(); + expect(mockStoreInstances).toHaveLength(1); + expect(mockStoreInstances[0].unload).not.toHaveBeenCalled(); + + first.unmount(); + await flush(); + expect(mockStoreInstances[0].unload).toHaveBeenCalledTimes(1); + + renderProvider(); + await flush(); + expect(mockStoreInstances).toHaveLength(2); + expect(mockStoreInstances[0].unload).toHaveBeenCalledTimes(1); + expect(mockStoreInstances[1].unload).not.toHaveBeenCalled(); + }); + + it('unmount during init unloads the store exactly once', async () => { + const deferred = createDeferred(); + mockMigrate.mockReturnValueOnce(deferred.promise); + + const { unmount } = renderProvider(); + await flush(); + expect(mockStoreInstances).toHaveLength(1); + const store = mockStoreInstances[0]; + expect(store.unload).not.toHaveBeenCalled(); + + unmount(); + await flush(); + + await act(async () => { + deferred.resolve(); + }); + await flush(); + + expect(store.unload).toHaveBeenCalledTimes(1); + }); + + it('cancel during migrate skips load and does not publish the store', async () => { + const deferred = createDeferred(); + mockMigrate.mockReturnValueOnce(deferred.promise); + + const { unmount } = renderProvider(); + await flush(); + const store = mockStoreInstances[0]; + + unmount(); + await flush(); + + await act(async () => { + deferred.resolve(); + }); + await flush(); + + expect(mockMigrate).toHaveBeenCalledTimes(1); + expect(store.load).not.toHaveBeenCalled(); + expect(store.unload).toHaveBeenCalledTimes(1); + }); + + it('cancel before init reaches the store creates and unloads nothing', async () => { + const { unmount } = renderProvider(); + unmount(); + await flush(); + + expect(mockStoreInstances).toHaveLength(0); + expect(mockMigrate).not.toHaveBeenCalled(); + }); +}); diff --git a/context/VectorStoreContext.tsx b/context/VectorStoreContext.tsx index a90e0db1..272b69db 100644 --- a/context/VectorStoreContext.tsx +++ b/context/VectorStoreContext.tsx @@ -19,7 +19,6 @@ const VectorStoreContext = createContext<{ embeddings: null, }); -// Serializes init/teardown so overlapping effect runs don't race the shared DB. let vectorStoreInitChain: Promise = Promise.resolve(); export const VectorStoreProvider = ({ @@ -36,6 +35,17 @@ export const VectorStoreProvider = ({ useEffect(() => { let cancelled = false; let localStore: OPSQLiteVectorStore | null = null; + let unloaded = false; + + const unloadStore = async () => { + if (unloaded || !localStore) return; + unloaded = true; + try { + await localStore.unload(); + } catch (error) { + console.error('Failed to unload vector store:', error); + } + }; const initialize = async () => { if (cancelled) return; @@ -53,27 +63,25 @@ export const VectorStoreProvider = ({ }); localStore = store; + if (cancelled) return; await migrateEmbeddingModelIfNeeded( store, db, LFM_2_5_EMBEDDING_MODEL_ID ); + if (cancelled) return; await ensureKeywordIndex(store.db); + if (cancelled) return; const downloaded = await isEmbeddingModelDownloaded(); + + if (cancelled) return; if (downloaded) { await store.load(); } - if (cancelled) { - await store - .unload() - .catch((error) => - console.error('Failed to unload superseded vector store:', error) - ); - return; - } + if (cancelled) return; setVectorStore(store); setEmbeddings(lfmEmbeddings); @@ -96,15 +104,7 @@ export const VectorStoreProvider = ({ setVectorStore(null); setEmbeddings(null); useEmbeddingModelStore.getState().setStatus('unknown'); - vectorStoreInitChain = vectorStoreInitChain.then(() => - localStore - ? localStore - .unload() - .catch((error) => - console.error('Failed to unload vector store:', error) - ) - : undefined - ); + vectorStoreInitChain = vectorStoreInitChain.then(unloadStore); }; }, [db]); From 08f66da494212e651a05587b19c1a0ce3db029d7 Mon Sep 17 00:00:00 2001 From: Krzysztof Faracik Date: Thu, 16 Jul 2026 13:21:24 +0200 Subject: [PATCH 23/42] style: format embedding model status union for prettier 3.9.4 The refreshed lockfile bumps prettier to 3.9.4, which collapses this union onto one line; reformat so `yarn lint` / `format:check` pass in CI. Co-Authored-By: Claude Opus 4.8 --- store/embeddingModelStore.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/store/embeddingModelStore.ts b/store/embeddingModelStore.ts index 2adc692f..b3f5cc09 100644 --- a/store/embeddingModelStore.ts +++ b/store/embeddingModelStore.ts @@ -2,11 +2,7 @@ import { create } from 'zustand'; import { OPSQLiteVectorStore } from '@react-native-rag/op-sqlite'; export type EmbeddingModelStatus = - | 'unknown' - | 'not_downloaded' - | 'downloading' - | 'ready' - | 'error'; + 'unknown' | 'not_downloaded' | 'downloading' | 'ready' | 'error'; type EmbeddingModelStore = { status: EmbeddingModelStatus; From 53106ddce18d5a909656b8280cc76fb7d752bf69 Mon Sep 17 00:00:00 2001 From: Krzysztof Faracik Date: Thu, 16 Jul 2026 13:25:20 +0200 Subject: [PATCH 24/42] test(llm): capture token callback from the correct fromModelName arg The mock read args[4] for the token callback, but fromModelName's third parameter (index 2) is tokenCallback; widen the captured type to allow the optional callback's undefined. Fixes the type errors in this file. Co-Authored-By: Claude Opus 4.8 --- __tests__/llmStore.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/__tests__/llmStore.test.ts b/__tests__/llmStore.test.ts index f4d76857..4cc9e139 100644 --- a/__tests__/llmStore.test.ts +++ b/__tests__/llmStore.test.ts @@ -46,7 +46,7 @@ const baseModel = { }; // Captures the token callback registered during loadModel so tests can fire tokens -let capturedTokenCallback: ((token: string) => void) | null = null; +let capturedTokenCallback: ((token: string) => void) | null | undefined = null; const makeMockInstance = () => ({ generate: jest.fn(), @@ -114,7 +114,7 @@ describe('loadModel', () => { let wasLoading = false; mockLLMModule.fromModelName.mockImplementation(async (...args) => { wasLoading = useLLMStore.getState().isLoading; - capturedTokenCallback = args[4]; + capturedTokenCallback = args[2]; return mockInstance as unknown as LLMModule; }); @@ -144,7 +144,7 @@ describe('loadModel', () => { // Load a different model mockInstance = makeMockInstance(); mockLLMModule.fromModelName.mockImplementation(async (...args) => { - capturedTokenCallback = args[4]; + capturedTokenCallback = args[2]; return mockInstance as unknown as LLMModule; }); await useLLMStore.getState().loadModel({ ...baseModel, id: 2 }); From 6b142ec6ff0d83969d45ec16589b4948174a187c Mon Sep 17 00:00:00 2001 From: Krzysztof Faracik Date: Thu, 16 Jul 2026 16:18:25 +0200 Subject: [PATCH 25/42] test(db): cover runMigrations from a real pre-RAG schema Export runMigrations and drive it through a stateful fake DB that models the schema: verifies every missing column is added onto an old pre-RAG schema, contextWindow is dropped only when present, re-runs are idempotent, a fresh full schema is a no-op, and a mid-migration failure is recovered by a re-run (migrations are non-transactional but idempotent-retry-safe). Co-Authored-By: Claude Opus 4.8 --- __tests__/dbSchemaMigration.test.ts | 187 ++++++++++++++++++++++++++++ database/db.ts | 2 +- 2 files changed, 188 insertions(+), 1 deletion(-) create mode 100644 __tests__/dbSchemaMigration.test.ts diff --git a/__tests__/dbSchemaMigration.test.ts b/__tests__/dbSchemaMigration.test.ts new file mode 100644 index 00000000..49336a6a --- /dev/null +++ b/__tests__/dbSchemaMigration.test.ts @@ -0,0 +1,187 @@ +import type { SQLiteDatabase } from 'expo-sqlite'; + +jest.mock('../store/chatStore', () => ({ + useChatStore: { getState: () => ({}) }, +})); +jest.mock('../store/llmStore', () => ({ + useLLMStore: { getState: () => ({}) }, +})); +jest.mock('../store/modelStore', () => ({ + useModelStore: { getState: () => ({}) }, +})); +jest.mock('../store/sourceStore', () => ({ + useSourceStore: { getState: () => ({}) }, +})); +jest.mock('../database/modelRepository', () => ({ addModel: jest.fn() })); +jest.mock('../utils/sourceLinkingBoundary', () => ({ + initSourceLinkingBoundary: jest.fn(), +})); +jest.mock('../constants/default-models', () => ({ DEFAULT_MODELS: [] })); + +import { runMigrations } from '../database/db'; + +type Schema = Record; + +const oldSchema = (): Schema => ({ + models: ['id', 'name', 'source'], + messages: ['id', 'chatId', 'content', 'role'], + chatSettings: ['id', 'chatId', 'contextWindow'], + sources: ['id', 'name'], + chats: ['id', 'title'], + benchmarks: ['id'], + chatSources: ['chatId', 'sourceId'], +}); + +class FakeDb { + tables: Schema; + alterLog: string[] = []; + failOn: ((sql: string) => boolean) | null = null; + + constructor(schema: Schema) { + this.tables = schema; + } + + getAllAsync = async (sql: string) => { + const match = /PRAGMA table_info\((\w+)\)/.exec(sql); + if (match) return (this.tables[match[1]] ?? []).map((name) => ({ name })); + return []; + }; + + execAsync = async (sql: string) => { + if (this.failOn?.(sql)) throw new Error('simulated migration failure'); + + const add = /ALTER TABLE (\w+) ADD COLUMN (\w+)/.exec(sql); + if (add) { + const [, table, column] = add; + this.alterLog.push(sql); + if ((this.tables[table] ?? []).includes(column)) { + throw new Error(`duplicate column name: ${column}`); + } + this.tables[table] = [...(this.tables[table] ?? []), column]; + return; + } + + const drop = /ALTER TABLE (\w+) DROP COLUMN (\w+)/.exec(sql); + if (drop) { + const [, table, column] = drop; + this.alterLog.push(sql); + this.tables[table] = (this.tables[table] ?? []).filter( + (name) => name !== column + ); + } + }; + + getFirstAsync = async () => null; + runAsync = async () => ({}); + withTransactionAsync = async (fn: () => Promise) => fn(); + + asDb() { + return this as unknown as SQLiteDatabase; + } +} + +const has = (db: FakeDb, table: string, column: string) => + (db.tables[table] ?? []).includes(column); + +describe('runMigrations from a real old (pre-RAG) schema', () => { + it('adds every missing column onto the old schema', async () => { + const db = new FakeDb(oldSchema()); + + await runMigrations(db.asDb()); + + for (const column of [ + 'featured', + 'experimental', + 'family', + 'thinking', + 'labels', + 'vision', + 'systemPrompt', + ]) { + expect(has(db, 'models', column)).toBe(true); + } + expect(has(db, 'messages', 'imagePath')).toBe(true); + expect(has(db, 'messages', 'documentName')).toBe(true); + expect(has(db, 'messages', 'sourceDocuments')).toBe(true); + expect(has(db, 'chatSettings', 'thinkingEnabled')).toBe(true); + expect(has(db, 'sources', 'firstChunk')).toBe(true); + }); + + it('drops contextWindow only when it is present', async () => { + const withColumn = new FakeDb(oldSchema()); + await runMigrations(withColumn.asDb()); + expect(has(withColumn, 'chatSettings', 'contextWindow')).toBe(false); + expect(withColumn.alterLog.some((sql) => sql.includes('DROP COLUMN'))).toBe( + true + ); + + const withoutColumn = new FakeDb({ + ...oldSchema(), + chatSettings: ['id', 'chatId'], + }); + await runMigrations(withoutColumn.asDb()); + expect( + withoutColumn.alterLog.some((sql) => sql.includes('DROP COLUMN')) + ).toBe(false); + }); + + it('is idempotent: a second run alters nothing and does not throw', async () => { + const db = new FakeDb(oldSchema()); + await runMigrations(db.asDb()); + db.alterLog.length = 0; + + await expect(runMigrations(db.asDb())).resolves.toBeUndefined(); + expect(db.alterLog).toEqual([]); + }); + + it('is already-migrated safe: running on a fresh full schema is a no-op', async () => { + const db = new FakeDb({ + models: [ + 'id', + 'name', + 'source', + 'featured', + 'experimental', + 'family', + 'thinking', + 'labels', + 'vision', + 'systemPrompt', + ], + messages: [ + 'id', + 'chatId', + 'content', + 'role', + 'imagePath', + 'documentName', + 'sourceDocuments', + ], + chatSettings: ['id', 'chatId', 'thinkingEnabled'], + sources: ['id', 'name', 'firstChunk'], + }); + + await runMigrations(db.asDb()); + expect(db.alterLog).toEqual([]); + }); + + it('is not atomic but recovers: a mid-migration failure is completed by a re-run', async () => { + const db = new FakeDb(oldSchema()); + db.failOn = (sql) => sql.includes('ADD COLUMN sourceDocuments'); + + await expect(runMigrations(db.asDb())).rejects.toThrow( + 'simulated migration failure' + ); + + expect(has(db, 'models', 'featured')).toBe(true); + expect(has(db, 'messages', 'imagePath')).toBe(true); + expect(has(db, 'messages', 'documentName')).toBe(true); + expect(has(db, 'messages', 'sourceDocuments')).toBe(false); + expect(has(db, 'sources', 'firstChunk')).toBe(false); + + db.failOn = null; + await expect(runMigrations(db.asDb())).resolves.toBeUndefined(); + expect(has(db, 'messages', 'sourceDocuments')).toBe(true); + expect(has(db, 'sources', 'firstChunk')).toBe(true); + }); +}); diff --git a/database/db.ts b/database/db.ts index dd40ae7f..692634a1 100644 --- a/database/db.ts +++ b/database/db.ts @@ -9,7 +9,7 @@ import { useSourceStore } from '../store/sourceStore'; import { initSourceLinkingBoundary } from '../utils/sourceLinkingBoundary'; import { migrateLegacyVectorStore } from './vectorStoreMigration'; -const runMigrations = async (db: SQLiteDatabase) => { +export const runMigrations = async (db: SQLiteDatabase) => { const modelsTableInfo = await db.getAllAsync<{ name: string }>( `PRAGMA table_info(models)` ); From 61d940dc5e32172f56128697526b9af500f15983 Mon Sep 17 00:00:00 2001 From: Krzysztof Faracik Date: Thu, 16 Jul 2026 16:18:25 +0200 Subject: [PATCH 26/42] test(embeddings): cover ensureReady error, retry and single-flight paths Verifies the embedding model download surfaces an error status on failure (no network / no space / interrupted), allows a fresh retry afterwards, single-flights concurrent callers so it downloads only once, and reports downloading/ready status transitions. Co-Authored-By: Claude Opus 4.8 --- __tests__/embeddingModelStore.test.ts | 115 ++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 __tests__/embeddingModelStore.test.ts diff --git a/__tests__/embeddingModelStore.test.ts b/__tests__/embeddingModelStore.test.ts new file mode 100644 index 00000000..70dde28d --- /dev/null +++ b/__tests__/embeddingModelStore.test.ts @@ -0,0 +1,115 @@ +import { useEmbeddingModelStore } from '../store/embeddingModelStore'; +import type { OPSQLiteVectorStore } from '@react-native-rag/op-sqlite'; + +const makeStore = (load: jest.Mock) => + ({ load }) as unknown as OPSQLiteVectorStore; + +const createDeferred = () => { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +}; + +beforeEach(() => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + useEmbeddingModelStore.setState({ status: 'unknown', progress: 0 }); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +describe('embeddingModelStore.ensureReady', () => { + it('returns true immediately without loading when already ready', async () => { + useEmbeddingModelStore.setState({ status: 'ready', progress: 1 }); + const load = jest.fn(); + + const ready = await useEmbeddingModelStore + .getState() + .ensureReady(makeStore(load)); + + expect(ready).toBe(true); + expect(load).not.toHaveBeenCalled(); + }); + + it('downloads then marks the model ready on success', async () => { + const load = jest.fn().mockResolvedValue(undefined); + + const ready = await useEmbeddingModelStore + .getState() + .ensureReady(makeStore(load)); + + expect(ready).toBe(true); + expect(load).toHaveBeenCalledTimes(1); + expect(useEmbeddingModelStore.getState().status).toBe('ready'); + expect(useEmbeddingModelStore.getState().progress).toBe(1); + }); + + it('sets an error status when the download fails (no network / no space / interrupted)', async () => { + const load = jest + .fn() + .mockRejectedValue(new Error('Network request failed')); + + const ready = await useEmbeddingModelStore + .getState() + .ensureReady(makeStore(load)); + + expect(ready).toBe(false); + expect(useEmbeddingModelStore.getState().status).toBe('error'); + expect(useEmbeddingModelStore.getState().progress).toBe(0); + expect(console.error).toHaveBeenCalled(); + }); + + it('reports downloading status while the download is in flight', async () => { + const deferred = createDeferred(); + const load = jest.fn().mockReturnValue(deferred.promise); + + const pending = useEmbeddingModelStore + .getState() + .ensureReady(makeStore(load)); + + expect(useEmbeddingModelStore.getState().status).toBe('downloading'); + expect(useEmbeddingModelStore.getState().progress).toBe(0); + + deferred.resolve(); + await pending; + expect(useEmbeddingModelStore.getState().status).toBe('ready'); + }); + + it('single-flights concurrent calls so the model downloads only once', async () => { + const deferred = createDeferred(); + const load = jest.fn().mockReturnValue(deferred.promise); + const store = makeStore(load); + + const first = useEmbeddingModelStore.getState().ensureReady(store); + const second = useEmbeddingModelStore.getState().ensureReady(store); + + deferred.resolve(); + const [a, b] = await Promise.all([first, second]); + + expect(a).toBe(true); + expect(b).toBe(true); + expect(load).toHaveBeenCalledTimes(1); + }); + + it('allows a fresh retry after a failed download', async () => { + const load = jest + .fn() + .mockRejectedValueOnce(new Error('No space left on device')) + .mockResolvedValueOnce(undefined); + const store = makeStore(load); + + const first = await useEmbeddingModelStore.getState().ensureReady(store); + expect(first).toBe(false); + expect(useEmbeddingModelStore.getState().status).toBe('error'); + + const second = await useEmbeddingModelStore.getState().ensureReady(store); + expect(second).toBe(true); + expect(load).toHaveBeenCalledTimes(2); + expect(useEmbeddingModelStore.getState().status).toBe('ready'); + }); +}); From 661e28977ab59872a669a40ba4615f97cce0cd2d Mon Sep 17 00:00:00 2001 From: Krzysztof Faracik Date: Thu, 16 Jul 2026 16:18:25 +0200 Subject: [PATCH 27/42] test(rag): add buildMessageSources pipeline integration test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exercises the retrieval → context → citation orchestrator end to end through real hybridRetrieve/contextUtils, mocking only the native vector store and FTS5 keyword search. Covers the Source-N ↔ citation lockstep invariant, attachment overview/ordering, citing an attachment with no retrieved chunk, the attachment-only and empty paths, and that no final citation references a block absent from the context sent to the model. Co-Authored-By: Claude Opus 4.8 --- __tests__/ragPipeline.integration.test.ts | 280 ++++++++++++++++++++++ 1 file changed, 280 insertions(+) create mode 100644 __tests__/ragPipeline.integration.test.ts diff --git a/__tests__/ragPipeline.integration.test.ts b/__tests__/ragPipeline.integration.test.ts new file mode 100644 index 00000000..0bb02ee1 --- /dev/null +++ b/__tests__/ragPipeline.integration.test.ts @@ -0,0 +1,280 @@ +import { + buildMessageSources, + pickCitationsByAnswer, + restrictCitationsToContext, + type SourceRow, +} from '../utils/messageSources'; +import { sourcesPresentInContext } from '../utils/contextUtils'; +import * as keywordIndex from '../database/keywordIndex'; +import type { OPSQLiteVectorStore } from '@react-native-rag/op-sqlite'; + +jest.mock('../database/keywordIndex', () => ({ + keywordSearch: jest.fn(), +})); + +const mockKeywordSearch = keywordIndex.keywordSearch as jest.Mock; + +type VectorRow = { + id: string; + document: string; + embedding: number[]; + similarity: number; + metadata: { documentId: number; name?: string }; +}; + +const makeVectorStore = (queryResults: VectorRow[]) => { + const byId = new Map(queryResults.map((r) => [r.id, r])); + return { + query: jest.fn().mockResolvedValue(queryResults), + db: { + execute: jest + .fn() + .mockImplementation(async (_sql: string, ids: string[]) => ({ + rows: ids + .map((id) => byId.get(id)) + .filter(Boolean) + .map((r) => ({ + id: r!.id, + document: r!.document, + embedding: r!.embedding, + metadata: JSON.stringify(r!.metadata), + })), + })), + }, + } as unknown as OPSQLiteVectorStore; +}; + +const source = (id: number, name: string, firstChunk?: string): SourceRow => ({ + id, + name, + firstChunk, +}); + +const presentNames = (context: string[]): Set => + sourcesPresentInContext(context.join('\n')); + +beforeEach(() => { + mockKeywordSearch.mockReset(); + jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, 'warn').mockImplementation(() => {}); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +describe('buildMessageSources — retrieval → context → citation pipeline', () => { + it('keeps context "Source N" headers and the citation set in lockstep across two documents', async () => { + const vectorStore = makeVectorStore([ + { + id: '1:0', + document: 'vacation policy grants twenty six days of paid leave', + embedding: [1, 0], + similarity: 0.85, + metadata: { documentId: 1, name: 'handbook.pdf' }, + }, + { + id: '2:0', + document: 'quarterly revenue reached five million dollars', + embedding: [0, 1], + similarity: 0.82, + metadata: { documentId: 2, name: 'q4_report.pdf' }, + }, + ]); + mockKeywordSearch.mockResolvedValue([ + { chunkId: '1:0', documentId: 1, score: -1 }, + { chunkId: '2:0', documentId: 2, score: -1.1 }, + ]); + + const { context, sourceDocuments, preferredSourceDocuments } = + await buildMessageSources({ + userInput: 'how many vacation days and what was the revenue', + attachmentSourceIds: [], + enabledSources: [1, 2], + sources: [source(1, 'handbook.pdf'), source(2, 'q4_report.pdf')], + vectorStore, + embeddings: null, + }); + + expect(context.join('\n')).toContain('--- Source 1:'); + expect(context.join('\n')).toContain('--- Source 2:'); + + const citedNames = new Set(sourceDocuments.map((d) => d.name)); + expect(citedNames).toEqual(presentNames(context)); + expect(citedNames).toEqual(new Set(['handbook.pdf', 'q4_report.pdf'])); + expect(preferredSourceDocuments).toEqual([]); + }); + + it('prepends the attachment overview and orders the attachment first in the citations', async () => { + const vectorStore = makeVectorStore([ + { + id: '1:0', + document: 'older library document about vacation policy', + embedding: [1, 0], + similarity: 0.8, + metadata: { documentId: 1, name: 'library.pdf' }, + }, + { + id: '2:0', + document: 'freshly attached note with low semantic overlap', + embedding: [0, 1], + similarity: 0.05, + metadata: { documentId: 2, name: 'attachment.txt' }, + }, + ]); + mockKeywordSearch.mockResolvedValue([ + { chunkId: '1:0', documentId: 1, score: -1 }, + ]); + + const { context, sourceDocuments, preferredSourceDocuments } = + await buildMessageSources({ + userInput: 'what does the attachment say', + attachmentSourceIds: [2], + enabledSources: [1], + sources: [ + source(1, 'library.pdf'), + source(2, 'attachment.txt', 'attached overview snippet'), + ], + vectorStore, + embeddings: null, + }); + + expect(context[0]).toContain( + 'Current Attachment Source: attachment.txt (Overview)' + ); + expect(sourceDocuments[0].documentId).toBe(2); + expect(preferredSourceDocuments.map((d) => d.documentId)).toEqual([2]); + expect(new Set(sourceDocuments.map((d) => d.name))).toEqual( + new Set(['attachment.txt', 'library.pdf']) + ); + }); + + it('still cites a freshly attached source that produced no retrieved chunk', async () => { + const vectorStore = makeVectorStore([ + { + id: '1:0', + document: 'the only retrievable content is in the library file', + embedding: [1, 0], + similarity: 0.8, + metadata: { documentId: 1, name: 'library.pdf' }, + }, + ]); + mockKeywordSearch.mockResolvedValue([ + { chunkId: '1:0', documentId: 1, score: -1 }, + ]); + + const { context, sourceDocuments } = await buildMessageSources({ + userInput: 'summarize everything', + attachmentSourceIds: [2], + enabledSources: [1], + sources: [ + source(1, 'library.pdf'), + source(2, 'attachment.pdf', 'attachment overview only'), + ], + vectorStore, + embeddings: null, + }); + + expect(sourceDocuments.map((d) => d.documentId)).toEqual([2, 1]); + expect(context[0]).toContain('attachment.pdf (Overview)'); + }); + + it('takes the attachment-only path when there is no user query', async () => { + const vectorStore = makeVectorStore([]); + mockKeywordSearch.mockResolvedValue([]); + + const { context, sourceDocuments } = await buildMessageSources({ + userInput: ' ', + attachmentSourceIds: [5], + enabledSources: [], + sources: [source(5, 'dropped.pdf', 'just attached, no question yet')], + vectorStore, + embeddings: null, + }); + + expect(vectorStore.query).not.toHaveBeenCalled(); + expect(sourceDocuments).toEqual([ + { + documentId: 5, + name: 'dropped.pdf', + passage: 'just attached, no question yet', + }, + ]); + expect(context[0]).toContain('dropped.pdf (Overview)'); + }); + + it('returns nothing and never touches retrieval when no sources are active', async () => { + const vectorStore = makeVectorStore([]); + mockKeywordSearch.mockResolvedValue([]); + + const result = await buildMessageSources({ + userInput: 'anything', + attachmentSourceIds: [], + enabledSources: [], + sources: [source(1, 'unused.pdf')], + vectorStore, + embeddings: null, + }); + + expect(result).toEqual({ + context: [], + sourceDocuments: [], + preferredSourceDocuments: [], + }); + expect(vectorStore.query).not.toHaveBeenCalled(); + expect(mockKeywordSearch).not.toHaveBeenCalled(); + }); + + it('never emits a citation whose block is absent from the context sent to the model', async () => { + const vectorStore = makeVectorStore([ + { + id: '1:0', + document: 'vacation policy grants twenty six days of paid leave', + embedding: [1, 0], + similarity: 0.85, + metadata: { documentId: 1, name: 'handbook.pdf' }, + }, + { + id: '2:0', + document: 'quarterly revenue reached five million dollars', + embedding: [0, 1], + similarity: 0.82, + metadata: { documentId: 2, name: 'q4_report.pdf' }, + }, + ]); + mockKeywordSearch.mockResolvedValue([ + { chunkId: '1:0', documentId: 1, score: -1 }, + { chunkId: '2:0', documentId: 2, score: -1.1 }, + ]); + + const { context, sourceDocuments, preferredSourceDocuments } = + await buildMessageSources({ + userInput: 'how many vacation days do i get', + attachmentSourceIds: [], + enabledSources: [1, 2], + sources: [source(1, 'handbook.pdf'), source(2, 'q4_report.pdf')], + vectorStore, + embeddings: null, + }); + + const answer = + 'You are granted twenty six days of paid vacation leave each year.'; + + const byAnswer = pickCitationsByAnswer( + sourceDocuments, + answer, + preferredSourceDocuments + ); + const finalCitations = restrictCitationsToContext( + byAnswer, + context.join('\n'), + preferredSourceDocuments + ); + + expect(finalCitations.map((d) => d.documentId)).toEqual([1]); + const present = presentNames(context); + for (const cited of finalCitations) { + expect(present.has(cited.name)).toBe(true); + } + }); +}); From cb476944e6e88a6c73209a6601bb77ced08cb23c Mon Sep 17 00:00:00 2001 From: Krzysztof Faracik Date: Thu, 16 Jul 2026 16:39:49 +0200 Subject: [PATCH 28/42] refactor(rag): reduce retrieval to a vanilla vector baseline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split the PR so this branch carries only what a working RAG needs: on-demand embedding model, source schema + legacy/incompatibility migration, attach → chunk → embed, vector-only retrieval (top-K by cosine + per-file cap + neighbor expansion), context assembly, answer-time citation attribution and the sources UI. Everything that improves retrieval quality moves to a stacked follow-up branch: keyword (FTS5/BM25) indexing, RRF fusion, MMR, adaptive-k, term coverage, and Polish no-answer detection. hybridRetrieval/rankFusion/ keywordIndex and their tests are removed here; messageSources now calls the vanilla retrieve(). Co-Authored-By: Claude Opus 4.8 --- __tests__/VectorStoreContext.test.tsx | 4 - __tests__/hybridRetrieval.test.ts | 542 --------------------- __tests__/keywordIndex.test.ts | 61 --- __tests__/messageSources.test.ts | 27 - __tests__/ragPipeline.integration.test.ts | 25 - __tests__/rankFusion.test.ts | 182 ------- constants/citations.ts | 14 - constants/keyword-index.ts | 4 - constants/retrieval.ts | 24 +- context/VectorStoreContext.tsx | 4 - database/keywordIndex.ts | 144 ------ store/sourceStore.ts | 14 - utils/embeddingModelMigration.ts | 2 - utils/messageSources.ts | 9 +- utils/rankFusion.ts | 152 ------ utils/{hybridRetrieval.ts => retrieval.ts} | 214 ++------ 16 files changed, 49 insertions(+), 1373 deletions(-) delete mode 100644 __tests__/hybridRetrieval.test.ts delete mode 100644 __tests__/keywordIndex.test.ts delete mode 100644 __tests__/rankFusion.test.ts delete mode 100644 constants/keyword-index.ts delete mode 100644 database/keywordIndex.ts delete mode 100644 utils/rankFusion.ts rename utils/{hybridRetrieval.ts => retrieval.ts} (52%) diff --git a/__tests__/VectorStoreContext.test.tsx b/__tests__/VectorStoreContext.test.tsx index 76f13f5c..7e4ca0ce 100644 --- a/__tests__/VectorStoreContext.test.tsx +++ b/__tests__/VectorStoreContext.test.tsx @@ -32,10 +32,6 @@ jest.mock('../utils/embeddingModelMigration', () => ({ migrateEmbeddingModelIfNeeded: jest.fn().mockResolvedValue(undefined), })); -jest.mock('../database/keywordIndex', () => ({ - ensureKeywordIndex: jest.fn().mockResolvedValue(undefined), -})); - jest.mock('../utils/embeddingModel', () => ({ isEmbeddingModelDownloaded: jest.fn().mockResolvedValue(true), })); diff --git a/__tests__/hybridRetrieval.test.ts b/__tests__/hybridRetrieval.test.ts deleted file mode 100644 index b089e956..00000000 --- a/__tests__/hybridRetrieval.test.ts +++ /dev/null @@ -1,542 +0,0 @@ -import { hybridRetrieve, HybridRetriever } from '../utils/hybridRetrieval'; -import * as keywordIndex from '../database/keywordIndex'; -import type { OPSQLiteVectorStore } from '@react-native-rag/op-sqlite'; - -jest.mock('../database/keywordIndex', () => ({ - keywordSearch: jest.fn(), -})); - -const mockKeywordSearch = keywordIndex.keywordSearch as jest.Mock; - -const makeVectorStore = ( - queryResults: unknown[], - vectorsById: Record -) => - ({ - query: jest.fn().mockResolvedValue(queryResults), - db: { - execute: jest - .fn() - .mockImplementation(async (_sql: string, ids: string[]) => ({ - rows: ids.map((id) => vectorsById[id]).filter(Boolean), - })), - }, - }) as unknown as OPSQLiteVectorStore; - -describe('hybridRetrieve', () => { - beforeEach(() => { - mockKeywordSearch.mockReset(); - }); - - it('recovers a keyword-only chunk that vector search missed', async () => { - const vectorResults = [ - { - id: '1:0', - document: 'a semantic passage about felines', - embedding: [1, 0], - similarity: 0.8, - metadata: { documentId: 1, name: 'A' }, - }, - ]; - const vectorsById = { - '2:5': { - id: '2:5', - document: 'the exact code E4021 is documented here', - embedding: [0, 1], - metadata: JSON.stringify({ documentId: 2, name: 'B' }), - }, - }; - mockKeywordSearch.mockResolvedValue([ - { chunkId: '2:5', documentId: 2, score: -1.2 }, - ]); - - const result = await hybridRetrieve({ - prompt: 'E4021', - enabledSourceIds: [1, 2], - vectorStore: makeVectorStore(vectorResults, vectorsById), - sourceNamesById: new Map(), - embeddings: null, - }); - - const names = result.map((c) => c.metadata?.name); - expect(names).toContain('B'); - expect(names).toContain('A'); - }); - - it('gates out low-similarity vector filler with no keyword overlap', async () => { - const vectorResults = [ - { - id: '1:0', - document: 'exact code E4021 explained', - embedding: [1, 0], - similarity: 0.7, - metadata: { documentId: 1, name: 'Relevant' }, - }, - { - id: '1:9', - document: 'completely unrelated boilerplate text', - embedding: [0, 1], - similarity: 0.05, - metadata: { documentId: 1, name: 'Filler' }, - }, - ]; - mockKeywordSearch.mockResolvedValue([]); - - const result = await hybridRetrieve({ - prompt: 'E4021', - enabledSourceIds: [1], - vectorStore: makeVectorStore(vectorResults, {}), - sourceNamesById: new Map(), - embeddings: null, - }); - - const names = result.map((c) => c.metadata?.name); - expect(names).toContain('Relevant'); - expect(names).not.toContain('Filler'); - }); - - it('returns an empty list when nothing qualifies', async () => { - mockKeywordSearch.mockResolvedValue([]); - - const result = await hybridRetrieve({ - prompt: 'xyz', - enabledSourceIds: [1], - vectorStore: makeVectorStore( - [ - { - id: '1:0', - document: 'irrelevant', - embedding: [1, 0], - similarity: 0.02, - metadata: { documentId: 1, name: 'A' }, - }, - ], - {} - ), - sourceNamesById: new Map(), - embeddings: null, - }); - - expect(result).toEqual([]); - }); - - it('falls back to sourceNamesById when a chunk has no name in metadata', async () => { - mockKeywordSearch.mockResolvedValue([]); - - const result = await hybridRetrieve({ - prompt: 'anything relevant', - enabledSourceIds: [7], - vectorStore: makeVectorStore( - [ - { - id: '7:0', - document: 'relevant content', - embedding: [1, 0], - similarity: 0.9, - metadata: { documentId: 7 }, - }, - ], - {} - ), - sourceNamesById: new Map([[7, 'Resolved Name']]), - embeddings: null, - }); - - expect(result[0]?.metadata?.name).toBe('Resolved Name'); - }); - - it('ranks a freshly attached source first even when it would otherwise be gated out', async () => { - const vectorResults = [ - { - id: '1:0', - document: 'older enabled document that mentions the pdf keyword', - embedding: [1, 0], - similarity: 0.2, - metadata: { documentId: 1, name: 'Old' }, - }, - { - id: '2:0', - document: 'brand new attachment about an espresso machine', - embedding: [0, 1], - similarity: 0.05, - metadata: { documentId: 2, name: 'Attachment' }, - }, - ]; - mockKeywordSearch.mockResolvedValue([ - { chunkId: '1:0', documentId: 1, score: -1 }, - ]); - - const result = await hybridRetrieve({ - prompt: 'what is the pdf about', - enabledSourceIds: [1, 2], - attachmentSourceIds: [2], - vectorStore: makeVectorStore(vectorResults, {}), - sourceNamesById: new Map(), - embeddings: null, - }); - - const names = result.map((c) => c.metadata?.name); - expect(names[0]).toBe('Attachment'); - expect(names).toContain('Old'); - }); - - it('gates out a mid-similarity chunk with no lexical overlap (embedding noise floor)', async () => { - mockKeywordSearch.mockResolvedValue([]); - const result = await hybridRetrieve({ - prompt: 'napisz wiersz o jesieni', - enabledSourceIds: [1], - vectorStore: makeVectorStore( - [ - { - id: '1:0', - document: 'privacy policy: data never leaves the device', - embedding: [1, 0], - similarity: 0.45, - metadata: { documentId: 1, name: 'FAQ' }, - }, - ], - {} - ), - sourceNamesById: new Map(), - embeddings: null, - }); - - expect(result).toEqual([]); - }); - - it('keeps a mid-similarity chunk when the query shares terms with it', async () => { - mockKeywordSearch.mockResolvedValue([]); - const result = await hybridRetrieve({ - prompt: 'does data leave the device', - enabledSourceIds: [1], - vectorStore: makeVectorStore( - [ - { - id: '1:0', - document: 'privacy policy: data never leaves the device', - embedding: [1, 0], - similarity: 0.45, - metadata: { documentId: 1, name: 'FAQ' }, - }, - ], - {} - ), - sourceNamesById: new Map(), - embeddings: null, - }); - - expect(result.map((c) => c.metadata?.name)).toContain('FAQ'); - }); - - it('caps one document so a second enabled source is not fully evicted', async () => { - const vectorResults = [ - { - id: '1:0', - document: 'doc a passage one', - embedding: [1, 0, 0, 0, 0], - similarity: 0.9, - metadata: { documentId: 1, name: 'DocA' }, - }, - { - id: '1:1', - document: 'doc a passage two', - embedding: [0, 1, 0, 0, 0], - similarity: 0.88, - metadata: { documentId: 1, name: 'DocA' }, - }, - { - id: '1:2', - document: 'doc a passage three', - embedding: [0, 0, 1, 0, 0], - similarity: 0.86, - metadata: { documentId: 1, name: 'DocA' }, - }, - { - id: '1:3', - document: 'doc a passage four', - embedding: [0, 0, 0, 1, 0], - similarity: 0.84, - metadata: { documentId: 1, name: 'DocA' }, - }, - { - id: '1:4', - document: 'doc a passage five', - embedding: [0, 0, 0, 0, 1], - similarity: 0.82, - metadata: { documentId: 1, name: 'DocA' }, - }, - { - id: '2:0', - document: 'doc b passage', - embedding: [1, 1, 0, 0, 0], - similarity: 0.6, - metadata: { documentId: 2, name: 'DocB' }, - }, - ]; - mockKeywordSearch.mockResolvedValue([]); - - const result = await hybridRetrieve({ - prompt: 'zzz', - enabledSourceIds: [1, 2], - vectorStore: makeVectorStore(vectorResults, {}), - sourceNamesById: new Map(), - embeddings: null, - }); - - expect(result.map((c) => c.metadata?.name)).toContain('DocB'); - }); - - it('adaptive-k drops a weak non-adjacent chunk after a large relevance gap', async () => { - const vectorResults = [ - { - id: '1:0', - document: 'the exact code e4021 is here', - embedding: [1, 0], - similarity: 0.9, - metadata: { documentId: 1, name: 'DocA' }, - }, - { - id: '1:5', - document: 'unrelated filler paragraph', - embedding: [0, 1], - similarity: 0.58, - metadata: { documentId: 1, name: 'DocA' }, - }, - ]; - mockKeywordSearch.mockResolvedValue([ - { chunkId: '1:0', documentId: 1, score: -1 }, - ]); - - const result = await hybridRetrieve({ - prompt: 'e4021', - enabledSourceIds: [1], - vectorStore: makeVectorStore(vectorResults, {}), - sourceNamesById: new Map(), - embeddings: null, - }); - - const docs = result.map((c) => c.document); - expect(docs).toContain('the exact code e4021 is here'); - expect(docs).not.toContain('unrelated filler paragraph'); - }); - - it('orders a more-relevant later chunk ahead of a less-relevant earlier one', async () => { - const vectorResults = [ - { - id: '1:2', - document: 'table of contents item 14 principal accountant fees', - embedding: [1, 0], - similarity: 0.5, - metadata: { documentId: 1, name: 'AppleK' }, - }, - { - id: '1:20', - document: - 'ben borders will assume the role of principal accounting officer', - embedding: [0, 1], - similarity: 0.9, - metadata: { documentId: 1, name: 'AppleK' }, - }, - ]; - mockKeywordSearch.mockResolvedValue([ - { chunkId: '1:20', documentId: 1, score: -1 }, - { chunkId: '1:2', documentId: 1, score: -1.1 }, - ]); - - const result = await hybridRetrieve({ - prompt: 'who becomes principal accounting officer', - enabledSourceIds: [1], - vectorStore: makeVectorStore(vectorResults, {}), - sourceNamesById: new Map(), - embeddings: null, - }); - - const docs = result.map((c) => c.document); - expect(docs).toContain( - 'ben borders will assume the role of principal accounting officer' - ); - expect(docs[0]).toContain('ben borders'); - expect( - docs.indexOf( - 'ben borders will assume the role of principal accounting officer' - ) - ).toBeLessThan( - docs.indexOf('table of contents item 14 principal accountant fees') - ); - }); - - it('leads with the best seed window even when it sits late in the document (with neighbors)', async () => { - const vectorResults = [ - { - id: '1:2', - document: 'toc item 14 principal accountant fees and services', - embedding: [1, 0], - similarity: 0.44, - metadata: { documentId: 1, name: 'AppleK' }, - }, - { - id: '1:20', - document: - 'item 9b ben borders will assume principal accounting officer', - embedding: [0, 1], - similarity: 0.5, - metadata: { documentId: 1, name: 'AppleK' }, - }, - ]; - const vectorsById = { - '1:1': { - id: '1:1', - document: 'toc neighbor before', - embedding: [1, 0], - metadata: JSON.stringify({ documentId: 1, name: 'AppleK' }), - }, - '1:3': { - id: '1:3', - document: 'toc neighbor after', - embedding: [1, 0], - metadata: JSON.stringify({ documentId: 1, name: 'AppleK' }), - }, - '1:19': { - id: '1:19', - document: 'item 9b neighbor before', - embedding: [0, 1], - metadata: JSON.stringify({ documentId: 1, name: 'AppleK' }), - }, - '1:21': { - id: '1:21', - document: 'item 9b neighbor after', - embedding: [0, 1], - metadata: JSON.stringify({ documentId: 1, name: 'AppleK' }), - }, - }; - mockKeywordSearch.mockResolvedValue([ - { chunkId: '1:20', documentId: 1, score: -1 }, - { chunkId: '1:2', documentId: 1, score: -1.1 }, - ]); - - const result = await hybridRetrieve({ - prompt: 'who becomes principal accounting officer', - enabledSourceIds: [1], - vectorStore: makeVectorStore(vectorResults, vectorsById), - sourceNamesById: new Map(), - embeddings: null, - }); - - const docs = result.map((c) => c.document); - const lastNine = Math.max( - docs.indexOf('item 9b neighbor before'), - docs.indexOf( - 'item 9b ben borders will assume principal accounting officer' - ), - docs.indexOf('item 9b neighbor after') - ); - const firstToc = Math.min( - docs.indexOf('toc neighbor before'), - docs.indexOf('toc item 14 principal accountant fees and services'), - docs.indexOf('toc neighbor after') - ); - expect(lastNine).toBeLessThan(firstToc); - }); - - it('expands a selected chunk with its same-document neighbors, in order', async () => { - const vectorResults = [ - { - id: '1:2', - document: 'middle of the table row 3', - embedding: [1, 0], - similarity: 0.9, - metadata: { documentId: 1, name: 'Invoice' }, - }, - ]; - const vectorsById = { - '1:1': { - id: '1:1', - document: 'table header and rows 1-2', - embedding: [1, 0], - metadata: JSON.stringify({ documentId: 1, name: 'Invoice' }), - }, - '1:3': { - id: '1:3', - document: 'table rows 4-6 and totals', - embedding: [1, 0], - metadata: JSON.stringify({ documentId: 1, name: 'Invoice' }), - }, - }; - mockKeywordSearch.mockResolvedValue([]); - - const result = await hybridRetrieve({ - prompt: 'what is in the table', - enabledSourceIds: [1], - vectorStore: makeVectorStore(vectorResults, vectorsById), - sourceNamesById: new Map(), - embeddings: null, - }); - - expect(result.map((c) => c.document)).toEqual([ - 'table header and rows 1-2', - 'middle of the table row 3', - 'table rows 4-6 and totals', - ]); - expect(new Set(result.map((c) => c.metadata?.name))).toEqual( - new Set(['Invoice']) - ); - expect(result.map((c) => c.similarity)).toEqual([0, 0.9, 0]); - }); -}); - -describe('HybridRetriever', () => { - beforeEach(() => { - mockKeywordSearch.mockReset(); - }); - - // The cases above already exercise the hybrid logic; this proves the wrapper - // forwards 1:1 — query→prompt, store/embeddings from the constructor, and - // every option spread through. Inputs are chosen so the two options a naive - // spread could silently drop are load-bearing: sourceNamesById resolves doc - // 1's missing name, and attachmentSourceIds keeps doc 2's otherwise-gated - // low-similarity chunk and orders it first. A wrapper that dropped either - // would diverge from the raw call and fail the toEqual. - it('forwards to hybridRetrieve 1:1, including attachmentSourceIds and sourceNamesById', async () => { - const vectorResults = [ - { - id: '1:0', - document: 'a semantic passage about felines', - embedding: [1, 0], - similarity: 0.8, - metadata: { documentId: 1 }, // no name → resolved via sourceNamesById - }, - { - id: '2:0', - document: 'freshly attached, low semantic overlap', - embedding: [0, 1], - similarity: 0.05, // gated out unless treated as an attachment - metadata: { documentId: 2, name: 'Attachment' }, - }, - ]; - mockKeywordSearch.mockResolvedValue([]); - - const store = makeVectorStore(vectorResults, {}); - const options = { - enabledSourceIds: [1, 2], - sourceNamesById: new Map([[1, 'ResolvedName']]), - attachmentSourceIds: [2], - }; - - const viaWrapper = await new HybridRetriever(store, null).retrieve( - 'felines', - options - ); - const viaFunction = await hybridRetrieve({ - prompt: 'felines', - vectorStore: store, - embeddings: null, - ...options, - }); - - const names = viaWrapper.map((c) => c.metadata?.name); - expect(names).toContain('ResolvedName'); // sourceNamesById forwarded - expect(names).toContain('Attachment'); // attachmentSourceIds forwarded - expect(viaWrapper[0]?.metadata?.name).toBe('Attachment'); // attachment ordered first - expect(viaWrapper).toEqual(viaFunction); // and identical to the raw call - }); -}); diff --git a/__tests__/keywordIndex.test.ts b/__tests__/keywordIndex.test.ts deleted file mode 100644 index 1e678fd0..00000000 --- a/__tests__/keywordIndex.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { - buildKeywordMatchExpression, - foldForKeywordIndex, -} from '../database/keywordIndex'; - -describe('foldForKeywordIndex', () => { - it('folds the Polish stroke letter ł/Ł to l/L (leaving other letters intact)', () => { - expect(foldForKeywordIndex('płatność')).toBe('platność'); - expect(foldForKeywordIndex('usługę')).toBe('uslugę'); - expect(foldForKeywordIndex('Łódź')).toBe('Lódź'); - }); - - it('leaves decomposable diacritics for the FTS tokenizer to fold', () => { - expect(foldForKeywordIndex('księgową')).toBe('księgową'); - }); - - it('leaves plain ASCII untouched', () => { - expect(foldForKeywordIndex('invoice E4021')).toBe('invoice E4021'); - }); -}); - -describe('buildKeywordMatchExpression', () => { - it('prefix-matches the stem of an inflected word so "pliku" finds "plików"', () => { - expect(buildKeywordMatchExpression(['pliku'])).toBe('"plik"*'); - expect(buildKeywordMatchExpression(['plików'])).toBe('"plik"*'); - }); - - it('folds ł in terms and prefix-matches the stem', () => { - expect(buildKeywordMatchExpression(['płatność'])).toBe('"platno"*'); - }); - - it('OR-joins the stemmed terms', () => { - expect(buildKeywordMatchExpression(['invoice', 'total'])).toBe( - '"invoi"* OR "tota"*' - ); - }); - - it('matches identifiers exactly (no stemming, no prefix)', () => { - expect(buildKeywordMatchExpression(['219039'])).toBe('"219039"'); - expect(buildKeywordMatchExpression(['e-4021'])).toBe('"e-4021"'); - }); - - it('quotes terms so FTS5 operators are treated as literals', () => { - expect(buildKeywordMatchExpression(['e-4021', 'OR'])).toBe( - '"e-4021" OR "OR"' - ); - }); - - it('escapes embedded double quotes by doubling them', () => { - expect(buildKeywordMatchExpression(['22"'])).toBe('"22"""'); - }); - - it('drops blank terms', () => { - expect(buildKeywordMatchExpression([' ', 'ok'])).toBe('"ok"'); - }); - - it('returns null when there is nothing to search', () => { - expect(buildKeywordMatchExpression([])).toBeNull(); - expect(buildKeywordMatchExpression([' '])).toBeNull(); - }); -}); diff --git a/__tests__/messageSources.test.ts b/__tests__/messageSources.test.ts index 2e575d05..bc94c043 100644 --- a/__tests__/messageSources.test.ts +++ b/__tests__/messageSources.test.ts @@ -268,28 +268,6 @@ describe('pickCitationsByAnswer', () => { expect(result).toEqual([]); }); - it('cites nothing when a verbose refusal still overlaps the passages', () => { - const cited = [ - withPassage( - 1, - 'sample.pdf', - 'The report covers revenue and profit figures.' - ), - withPassage( - 2, - 'misja_ares_trzy.pdf', - 'The mission Ares III briefing and crew roster.' - ), - ]; - const answer = - 'Przeanalizowałem dokumenty: sample.pdf opisuje revenue i profit, a misja ' + - 'Ares III to briefing i crew roster. W żadnym nie ma informacji o L4.'; - - const result = pickCitationsByAnswer(cited, answer, []); - - expect(result).toEqual([]); - }); - it('attributes to the source the visible reply echoes, not the reasoning', () => { const cited = [ withPassage(1, 'sample.pdf', 'alpha beta gamma'), @@ -323,11 +301,6 @@ describe('visibleAnswer', () => { describe('looksLikeNoAnswer', () => { it.each([ - 'W dokumentach nie ma informacji o L4.', - 'Brak informacji na ten temat w załączonych plikach.', - 'Dokument nie zawiera danych o urlopie.', - 'Nie wiem, o tym nie ma mowy.', - 'Nie ma dokumentu z tematem "L4" w kontekście dostanych materiałów. Informacje zamieszczone w źródłach obejmują tylko raport testowy.', 'There is no information about L4 in the documents.', 'There is no mention of sick leave anywhere.', 'Sick leave is not mentioned in the provided documents.', diff --git a/__tests__/ragPipeline.integration.test.ts b/__tests__/ragPipeline.integration.test.ts index 0bb02ee1..d8dd5064 100644 --- a/__tests__/ragPipeline.integration.test.ts +++ b/__tests__/ragPipeline.integration.test.ts @@ -5,15 +5,8 @@ import { type SourceRow, } from '../utils/messageSources'; import { sourcesPresentInContext } from '../utils/contextUtils'; -import * as keywordIndex from '../database/keywordIndex'; import type { OPSQLiteVectorStore } from '@react-native-rag/op-sqlite'; -jest.mock('../database/keywordIndex', () => ({ - keywordSearch: jest.fn(), -})); - -const mockKeywordSearch = keywordIndex.keywordSearch as jest.Mock; - type VectorRow = { id: string; document: string; @@ -54,7 +47,6 @@ const presentNames = (context: string[]): Set => sourcesPresentInContext(context.join('\n')); beforeEach(() => { - mockKeywordSearch.mockReset(); jest.spyOn(console, 'error').mockImplementation(() => {}); jest.spyOn(console, 'warn').mockImplementation(() => {}); }); @@ -81,10 +73,6 @@ describe('buildMessageSources — retrieval → context → citation pipeline', metadata: { documentId: 2, name: 'q4_report.pdf' }, }, ]); - mockKeywordSearch.mockResolvedValue([ - { chunkId: '1:0', documentId: 1, score: -1 }, - { chunkId: '2:0', documentId: 2, score: -1.1 }, - ]); const { context, sourceDocuments, preferredSourceDocuments } = await buildMessageSources({ @@ -122,9 +110,6 @@ describe('buildMessageSources — retrieval → context → citation pipeline', metadata: { documentId: 2, name: 'attachment.txt' }, }, ]); - mockKeywordSearch.mockResolvedValue([ - { chunkId: '1:0', documentId: 1, score: -1 }, - ]); const { context, sourceDocuments, preferredSourceDocuments } = await buildMessageSources({ @@ -159,9 +144,6 @@ describe('buildMessageSources — retrieval → context → citation pipeline', metadata: { documentId: 1, name: 'library.pdf' }, }, ]); - mockKeywordSearch.mockResolvedValue([ - { chunkId: '1:0', documentId: 1, score: -1 }, - ]); const { context, sourceDocuments } = await buildMessageSources({ userInput: 'summarize everything', @@ -181,7 +163,6 @@ describe('buildMessageSources — retrieval → context → citation pipeline', it('takes the attachment-only path when there is no user query', async () => { const vectorStore = makeVectorStore([]); - mockKeywordSearch.mockResolvedValue([]); const { context, sourceDocuments } = await buildMessageSources({ userInput: ' ', @@ -205,7 +186,6 @@ describe('buildMessageSources — retrieval → context → citation pipeline', it('returns nothing and never touches retrieval when no sources are active', async () => { const vectorStore = makeVectorStore([]); - mockKeywordSearch.mockResolvedValue([]); const result = await buildMessageSources({ userInput: 'anything', @@ -222,7 +202,6 @@ describe('buildMessageSources — retrieval → context → citation pipeline', preferredSourceDocuments: [], }); expect(vectorStore.query).not.toHaveBeenCalled(); - expect(mockKeywordSearch).not.toHaveBeenCalled(); }); it('never emits a citation whose block is absent from the context sent to the model', async () => { @@ -242,10 +221,6 @@ describe('buildMessageSources — retrieval → context → citation pipeline', metadata: { documentId: 2, name: 'q4_report.pdf' }, }, ]); - mockKeywordSearch.mockResolvedValue([ - { chunkId: '1:0', documentId: 1, score: -1 }, - { chunkId: '2:0', documentId: 2, score: -1.1 }, - ]); const { context, sourceDocuments, preferredSourceDocuments } = await buildMessageSources({ diff --git a/__tests__/rankFusion.test.ts b/__tests__/rankFusion.test.ts deleted file mode 100644 index de6d539d..00000000 --- a/__tests__/rankFusion.test.ts +++ /dev/null @@ -1,182 +0,0 @@ -import { - reciprocalRankFusion, - cosineSimilarity, - termCoverage, - maximalMarginalRelevance, - adaptiveKeepCount, -} from '../utils/rankFusion'; - -describe('reciprocalRankFusion', () => { - it('ranks an item that appears near the top of both lists above single-list items', () => { - const scores = reciprocalRankFusion([ - { ids: ['a', 'b', 'c'] }, - { ids: ['b', 'd', 'a'] }, - ]); - - expect(scores.get('b')!).toBeGreaterThan(scores.get('c')!); - expect(scores.get('a')!).toBeGreaterThan(scores.get('d')!); - }); - - it('sums contributions for an item present in multiple lists', () => { - const k = 60; - const scores = reciprocalRankFusion([{ ids: ['x'] }, { ids: ['x'] }], k); - - expect(scores.get('x')!).toBeCloseTo(2 / (k + 1)); - }); - - it('respects per-list weights', () => { - const scores = reciprocalRankFusion([ - { ids: ['a'], weight: 3 }, - { ids: ['b'], weight: 1 }, - ]); - - expect(scores.get('a')!).toBeGreaterThan(scores.get('b')!); - }); - - it('returns an empty map for no lists', () => { - expect(reciprocalRankFusion([]).size).toBe(0); - }); -}); - -describe('cosineSimilarity', () => { - it('is 1 for identical direction vectors', () => { - expect(cosineSimilarity([1, 2, 3], [2, 4, 6])).toBeCloseTo(1); - }); - - it('is 0 for orthogonal vectors', () => { - expect(cosineSimilarity([1, 0], [0, 1])).toBeCloseTo(0); - }); - - it('is 0 when a vector is zero-length', () => { - expect(cosineSimilarity([0, 0], [1, 1])).toBe(0); - }); - - it('is 0 for empty vectors', () => { - expect(cosineSimilarity([], [])).toBe(0); - }); -}); - -describe('termCoverage', () => { - it('returns the fraction of query terms present in the text', () => { - const terms = new Set(['invoice', 'total', 'missing']); - expect(termCoverage('the invoice total was paid', terms)).toBeCloseTo( - 2 / 3 - ); - }); - - it('is case-insensitive', () => { - expect(termCoverage('ERROR E4021 raised', new Set(['e4021']))).toBe(1); - }); - - it('returns 0 for no terms', () => { - expect(termCoverage('anything', new Set())).toBe(0); - }); -}); - -describe('maximalMarginalRelevance', () => { - const embed = (v: number[]) => v; - - it('picks the most relevant item first', () => { - const selected = maximalMarginalRelevance( - [ - { id: 'low', relevance: 0.2, embedding: embed([1, 0]) }, - { id: 'high', relevance: 0.9, embedding: embed([0, 1]) }, - ], - 1 - ); - - expect(selected.map((s) => s.id)).toEqual(['high']); - }); - - it('prefers a diverse second pick over a near-duplicate of the first', () => { - const selected = maximalMarginalRelevance( - [ - { id: 'first', relevance: 1.0, embedding: embed([1, 0, 0]) }, - { id: 'duplicate', relevance: 0.95, embedding: embed([0.99, 0.01, 0]) }, - { id: 'diverse', relevance: 0.8, embedding: embed([0, 1, 0]) }, - ], - 2, - 0.7 - ); - - expect(selected.map((s) => s.id)).toEqual(['first', 'diverse']); - }); - - it('never returns more than the requested count', () => { - const selected = maximalMarginalRelevance( - [ - { id: 'a', relevance: 1, embedding: [1, 0] }, - { id: 'b', relevance: 1, embedding: [0, 1] }, - { id: 'c', relevance: 1, embedding: [1, 1] }, - ], - 2 - ); - - expect(selected).toHaveLength(2); - }); - - it('returns everything when count exceeds pool size', () => { - const selected = maximalMarginalRelevance( - [{ id: 'a', relevance: 1, embedding: [1, 0] }], - 5 - ); - - expect(selected).toHaveLength(1); - }); - - it('caps selections per group, leaving slots for other groups', () => { - const selected = maximalMarginalRelevance( - [ - { id: 'a1', relevance: 1.0, embedding: [1, 0] }, - { id: 'a2', relevance: 0.9, embedding: [0, 1] }, - { id: 'a3', relevance: 0.8, embedding: [1, 1] }, - { id: 'b1', relevance: 0.3, embedding: [1, 0.5] }, - ], - 4, - 0.9, - { groupOf: (c) => c.id[0], maxPerGroup: 2 } - ); - - const ids = selected.map((s) => s.id); - expect(ids.filter((id) => id.startsWith('a'))).toHaveLength(2); - expect(ids).toContain('b1'); - }); - - it('stops instead of overfilling when every remaining item is in a full group', () => { - const selected = maximalMarginalRelevance( - [ - { id: 'a1', relevance: 1.0, embedding: [1, 0] }, - { id: 'a2', relevance: 0.9, embedding: [0, 1] }, - { id: 'a3', relevance: 0.8, embedding: [1, 1] }, - ], - 3, - 0.9, - { groupOf: () => 'a', maxPerGroup: 2 } - ); - - expect(selected).toHaveLength(2); - }); -}); - -describe('adaptiveKeepCount', () => { - it('keeps everything when scores decay gently', () => { - expect(adaptiveKeepCount([1.0, 0.8, 0.6], 1, 0.45)).toBe(3); - }); - - it('cuts at the first large relative drop', () => { - expect(adaptiveKeepCount([0.9, 0.85, 0.2], 1, 0.45)).toBe(2); - }); - - it('cuts to a single strong chunk when the rest fall off a cliff', () => { - expect(adaptiveKeepCount([0.9, 0.1, 0.05], 1, 0.45)).toBe(1); - }); - - it('never trims below minKeep', () => { - expect(adaptiveKeepCount([0.9, 0.1], 2, 0.45)).toBe(2); - }); - - it('never returns fewer than minKeep or more than the list', () => { - expect(adaptiveKeepCount([0.9], 1, 0.45)).toBe(1); - expect(adaptiveKeepCount([], 1, 0.45)).toBe(0); - }); -}); diff --git a/constants/citations.ts b/constants/citations.ts index fbd93e53..9e965af9 100644 --- a/constants/citations.ts +++ b/constants/citations.ts @@ -11,8 +11,6 @@ export const THINK_CLOSE = ''; // Coverage nouns (does a source address the topic); a refusal negates one, a negative-fact answer does not. const NO_ANSWER_META_EN = 'information|info|mention|reference|data|details?|indication|records?'; -const NO_ANSWER_META_PL = - 'informacj\\w*|info|wzmian\\w*|danych|dane|mowy|odniesie\\w*|dokument\\w*|plik\\w*|tematu|tekst\\w*|materia\\w*|źród\\w*|nic|niczego'; // English "no information" refusal patterns; each negation is tied to a coverage noun. export const NO_ANSWER_PATTERNS_EN: RegExp[] = [ @@ -30,15 +28,3 @@ export const NO_ANSWER_PATTERNS_EN: RegExp[] = [ /\bi (do ?n['o]?t|cannot|can ?not|can['o]?t) (know|find|see|answer|tell|determine|locate)\b/i, /\bunable to (find|answer|determine|locate|provide)\b/i, ]; - -// Polish "brak informacji" refusal patterns; each negation is tied to a coverage noun. -export const NO_ANSWER_PATTERNS_PL: RegExp[] = [ - new RegExp( - `\\b(nie ma|brak|nie zawiera\\w*|nie znaleziono|nie podano|nie wymienia\\w*) (żadn\\w* )?(${NO_ANSWER_META_PL})\\b`, - 'i' - ), - /\bnie wspomina\w*\b/i, - /\bnie odnosi si\w*\b/i, - /\bnie wiem\b/i, - /\bnie mog\w* (znaleźć|odpowiedzieć|okre\w*)\b/i, -]; diff --git a/constants/keyword-index.ts b/constants/keyword-index.ts deleted file mode 100644 index f0566e38..00000000 --- a/constants/keyword-index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export const KEYWORD_TABLE = 'chunk_fts'; - -// unicode61 folds decomposable Polish letters but not ł (see foldForKeywordIndex). -export const FTS_TOKENIZER = 'unicode61 remove_diacritics 2'; diff --git a/constants/retrieval.ts b/constants/retrieval.ts index 81737a17..04f38f44 100644 --- a/constants/retrieval.ts +++ b/constants/retrieval.ts @@ -4,37 +4,15 @@ export const CANDIDATE_POOL = 20; /** Final chunks kept after re-ranking (MMR selection size). */ export const MAX_RELEVANT_CHUNKS = 5; -/** RRF constant in `weight / (k + rank)`; 60 is the paper default. */ -export const RRF_K = 60; - -/** Per-retriever RRF weights (KEYWORD = exact-match recall, VECTOR = semantic). */ -export const VECTOR_WEIGHT = 1; -export const KEYWORD_WEIGHT = 1; - -/** MMR trade-off: higher = relevance, lower = diversity. */ -export const MMR_LAMBDA = 0.7; - -/** Coverage boost: `relevance *= 1 + COVERAGE_ALPHA * coverage`. */ -export const COVERAGE_ALPHA = 0.5; - /** Bonus that floats a freshly-attached chunk past the gate to the pool's front. */ export const ATTACHMENT_RELEVANCE_BONUS = 10; /** Max chunks kept from one document in the final selection, applied only when ≥2 documents qualify. */ export const MAX_CHUNKS_PER_FILE = 3; -/** Adaptive-k: after MMR, drop trailing chunks once relevance falls below this fraction of the previous. */ -export const ADAPTIVE_K_DROP_RATIO = 0.45; - -/** Adaptive-k never trims below this many non-attachment chunks. */ -export const ADAPTIVE_K_MIN_KEEP = 1; - -/** Cosine floor to qualify on semantics alone; above LFM2.5's ~0.35–0.45 noise floor. */ +/** Cosine floor to qualify a chunk on semantic similarity; above LFM2.5's ~0.35–0.45 noise floor. */ export const STRONG_SEMANTIC_THRESHOLD = 0.55; -/** Min cosine to qualify via lexical overlap (paired with non-zero term coverage). */ -export const LEXICAL_MATCH_MIN_SIMILARITY = 0.1; - /** After generation, cite a non-attachment document only if its answer↔passage term overlap is at least this fraction of the strongest cited document's — attributes the reply to the source(s) it was actually based on. */ export const ANSWER_CITATION_OVERLAP_RATIO = 0.5; diff --git a/context/VectorStoreContext.tsx b/context/VectorStoreContext.tsx index 272b69db..3af4509a 100644 --- a/context/VectorStoreContext.tsx +++ b/context/VectorStoreContext.tsx @@ -7,7 +7,6 @@ import { } from '../constants/embedding-model'; import { migrateEmbeddingModelIfNeeded } from '../utils/embeddingModelMigration'; import { LFMEmbeddings } from '../utils/lfmEmbeddings'; -import { ensureKeywordIndex } from '../database/keywordIndex'; import { isEmbeddingModelDownloaded } from '../utils/embeddingModel'; import { useEmbeddingModelStore } from '../store/embeddingModelStore'; @@ -70,9 +69,6 @@ export const VectorStoreProvider = ({ LFM_2_5_EMBEDDING_MODEL_ID ); - if (cancelled) return; - await ensureKeywordIndex(store.db); - if (cancelled) return; const downloaded = await isEmbeddingModelDownloaded(); diff --git a/database/keywordIndex.ts b/database/keywordIndex.ts deleted file mode 100644 index 1b937c8a..00000000 --- a/database/keywordIndex.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { type DB, type Scalar } from '@op-engineering/op-sqlite'; -import { stemPrefix } from '../utils/queryTerms'; -import { KEYWORD_TABLE, FTS_TOKENIZER } from '../constants/keyword-index'; - -// FTS5 keyword index paralleling the vector store's chunks (same op-sqlite DB, -// same chunk id) for BM25 retrieval. FTS5 depends on the native build; when it's -// absent every op below no-ops and hybrid search degrades to vector-only. ł/Ł is -// folded by hand because the tokenizer's remove_diacritics leaves that stroke -// letter alone, so "platnosc" would otherwise never match "płatność". - -export const foldForKeywordIndex = (text: string): string => - text.replace(/Ł/g, 'L').replace(/ł/g, 'l'); - -let ftsAvailable = false; - -export const isKeywordIndexAvailable = (): boolean => ftsAvailable; - -export const ensureKeywordIndex = async (db: DB): Promise => { - try { - await db.execute( - `CREATE VIRTUAL TABLE IF NOT EXISTS ${KEYWORD_TABLE} USING fts5( - chunk_id UNINDEXED, - document_id UNINDEXED, - content, - tokenize = '${FTS_TOKENIZER}' - );` - ); - ftsAvailable = true; - } catch (error) { - console.warn( - 'FTS5 keyword index unavailable; hybrid search falls back to vector-only', - error - ); - ftsAvailable = false; - } - - return ftsAvailable; -}; - -export const addChunkToKeywordIndex = async ( - db: DB, - chunkId: string, - documentId: number, - content: string -): Promise => { - if (!ftsAvailable) return; - - try { - await db.execute( - `INSERT INTO ${KEYWORD_TABLE} (chunk_id, document_id, content) VALUES (?, ?, ?)`, - [chunkId, documentId, foldForKeywordIndex(content)] - ); - } catch (error) { - console.warn('Failed to index chunk for keyword search', { - chunkId, - documentId, - error, - }); - } -}; - -export const removeDocumentFromKeywordIndex = async ( - db: DB, - documentId: number -): Promise => { - if (!ftsAvailable) return; - - try { - await db.execute(`DELETE FROM ${KEYWORD_TABLE} WHERE document_id = ?`, [ - documentId, - ]); - } catch (error) { - console.warn('Failed to remove document from keyword index', { - documentId, - error, - }); - } -}; - -export const dropKeywordIndex = async (db: DB): Promise => { - try { - await db.execute(`DROP TABLE IF EXISTS ${KEYWORD_TABLE};`); - } catch (error) { - console.warn('Failed to drop keyword index', error); - } - ftsAvailable = false; -}; - -export const buildKeywordMatchExpression = (terms: string[]): string | null => { - const tokens = new Set(); - for (const term of terms) { - const folded = foldForKeywordIndex(term.trim()); - if (!folded) continue; - const stem = stemPrefix(folded); - const escaped = stem.replace(/"/g, '""'); - tokens.add(stem === folded ? `"${escaped}"` : `"${escaped}"*`); - } - - if (tokens.size === 0) return null; - return [...tokens].join(' OR '); -}; - -export type KeywordHit = { - chunkId: string; - documentId?: number; - score: number; -}; - -export const keywordSearch = async ( - db: DB, - terms: string[], - enabledDocumentIds: number[], - limit: number -): Promise => { - if (!ftsAvailable || enabledDocumentIds.length === 0) return []; - - const matchExpression = buildKeywordMatchExpression(terms); - if (!matchExpression) return []; - - const placeholders = enabledDocumentIds.map(() => '?').join(', '); - - try { - const result = await db.execute( - `SELECT chunk_id AS chunkId, document_id AS documentId, bm25(${KEYWORD_TABLE}) AS score - FROM ${KEYWORD_TABLE} - WHERE ${KEYWORD_TABLE} MATCH ? AND document_id IN (${placeholders}) - ORDER BY score - LIMIT ?`, - [matchExpression, ...enabledDocumentIds, limit] - ); - - return result.rows.map((row: Record) => ({ - chunkId: String(row.chunkId), - documentId: - typeof row.documentId === 'number' - ? row.documentId - : Number(row.documentId), - score: row.score as number, - })); - } catch (error) { - console.warn('Keyword search failed', { matchExpression, error }); - return []; - } -}; diff --git a/store/sourceStore.ts b/store/sourceStore.ts index 45ac3750..4afdc4e5 100644 --- a/store/sourceStore.ts +++ b/store/sourceStore.ts @@ -14,10 +14,6 @@ import { RecursiveCharacterTextSplitter } from 'react-native-rag'; import { readDocumentText } from '../utils/fileReaders'; import { useLLMStore } from './llmStore'; import { LFMEmbeddings } from '../utils/lfmEmbeddings'; -import { - addChunkToKeywordIndex, - removeDocumentFromKeywordIndex, -} from '../database/keywordIndex'; import { MAX_SOURCE_CHUNKS, TEXT_SPLITTER_CHUNK_OVERLAP, @@ -126,7 +122,6 @@ export const useSourceStore = create((set, get) => ({ await vectorStore.delete({ predicate: (value) => value.metadata?.documentId === sourceId, }); - await removeDocumentFromKeywordIndex(vectorStore.db, sourceId); } await deleteSource(db, sourceId); set((state) => ({ @@ -155,14 +150,6 @@ export const useSourceStore = create((set, get) => ({ isFirstChunk: i === 0, }, }); - if (vectorStore) { - await addChunkToKeywordIndex( - vectorStore.db, - chunkId, - sourceId, - chunks[i]! - ); - } onProgress?.((i + 1) / chunks.length); } @@ -227,7 +214,6 @@ export const useSourceStore = create((set, get) => ({ await vectorStore.delete({ predicate: (value) => value.metadata?.documentId === source.id, }); - await removeDocumentFromKeywordIndex(vectorStore.db, source.id); await deleteSource(db, source.id); } if (orphaned.length > 0) { diff --git a/utils/embeddingModelMigration.ts b/utils/embeddingModelMigration.ts index c5dc0275..fb07f1e0 100644 --- a/utils/embeddingModelMigration.ts +++ b/utils/embeddingModelMigration.ts @@ -1,7 +1,6 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import { type SQLiteDatabase } from 'expo-sqlite'; import { OPSQLiteVectorStore } from '@react-native-rag/op-sqlite'; -import { dropKeywordIndex } from '../database/keywordIndex'; import { ACTIVE_EMBEDDING_MODEL_KEY } from '../constants/embedding-model'; export const migrateEmbeddingModelIfNeeded = async ( @@ -14,7 +13,6 @@ export const migrateEmbeddingModelIfNeeded = async ( if (storedModelId === currentModelId) return false; await vectorStore.deleteVectorStore(); - await dropKeywordIndex(vectorStore.db); try { await db.runAsync(`DELETE FROM chatSources`); diff --git a/utils/messageSources.ts b/utils/messageSources.ts index 60c9a032..ae82816b 100644 --- a/utils/messageSources.ts +++ b/utils/messageSources.ts @@ -8,12 +8,11 @@ import { sourceKey, sourcesPresentInContext, } from './contextUtils'; -import { hybridRetrieve } from './hybridRetrieval'; +import { retrieve } from './retrieval'; import { extractQueryTerms, stemPrefix } from './queryTerms'; import { ANSWER_CITATION_OVERLAP_RATIO } from '../constants/retrieval'; import { NO_ANSWER_PATTERNS_EN, - NO_ANSWER_PATTERNS_PL, THINK_CLOSE, THINK_OPEN, } from '../constants/citations'; @@ -125,9 +124,7 @@ const answerTermsOf = (answer: string): Set => // True when the visible reply is an EN/PL "no information" refusal (negation tied to a coverage noun). export const looksLikeNoAnswer = (visibleReply: string): boolean => - [...NO_ANSWER_PATTERNS_EN, ...NO_ANSWER_PATTERNS_PL].some((pattern) => - pattern.test(visibleReply) - ); + NO_ANSWER_PATTERNS_EN.some((pattern) => pattern.test(visibleReply)); // Compact per-document overlap for logs: `name:overlap` per candidate, so a surprising citation set is diagnosable. export const answerCitationOverlaps = ( @@ -188,7 +185,7 @@ const retrieveChunks = async ( embeddings?: LFMEmbeddings | null ) => { try { - const relevantChunks = await hybridRetrieve({ + const relevantChunks = await retrieve({ prompt: userInput, enabledSourceIds: allSourceIds, vectorStore, diff --git a/utils/rankFusion.ts b/utils/rankFusion.ts deleted file mode 100644 index e74137a6..00000000 --- a/utils/rankFusion.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { - RRF_K, - MMR_LAMBDA, - ADAPTIVE_K_DROP_RATIO, -} from '../constants/retrieval'; - -// Pure scoring primitives for hybrid retrieval — plain arithmetic, no model/IO. - -export type RankedList = { - ids: string[]; - weight?: number; -}; - -// Fuses rank-ordered lists via `Σ weight / (k + rank)` — uses rank position, not -// raw scores, so it mixes incomparable scales (cosine vs BM25). `ids` run -// best-first; each list's `weight` defaults to 1; `k` dampens the top ranks. -export const reciprocalRankFusion = ( - lists: RankedList[], - k = RRF_K -): Map => { - const scores = new Map(); - - for (const { ids, weight = 1 } of lists) { - ids.forEach((id, index) => { - const contribution = weight / (k + index + 1); - scores.set(id, (scores.get(id) ?? 0) + contribution); - }); - } - - return scores; -}; - -// Cosine similarity; normalises internally (LFM2.5 embeddings are non-unit-length). -// Returns 0 when either vector is empty or zero-length. -export const cosineSimilarity = (a: number[], b: number[]): number => { - const len = Math.min(a.length, b.length); - if (len === 0) return 0; - - let dot = 0; - let normA = 0; - let normB = 0; - for (let i = 0; i < len; i++) { - dot += a[i]! * b[i]!; - normA += a[i]! * a[i]!; - normB += b[i]! * b[i]!; - } - - if (normA === 0 || normB === 0) return 0; - return dot / (Math.sqrt(normA) * Math.sqrt(normB)); -}; - -// Fraction (0..1) of `terms` present as substrings of `text` — rewards exact -// keyword coverage during re-ranking. -export const termCoverage = (text: string, terms: Set): number => { - if (terms.size === 0) return 0; - - const lower = text.toLowerCase(); - let hits = 0; - for (const term of terms) { - if (lower.includes(term)) hits += 1; - } - - return hits / terms.size; -}; - -export type MMRCandidate = { - id: string; - relevance: number; - embedding: number[]; -}; - -export type MMROptions = { - groupOf?: (candidate: MMRCandidate) => string | undefined; - maxPerGroup?: number; -}; - -// Maximal Marginal Relevance: greedily picks `count` candidates, each maximising -// `λ·relevance − (1−λ)·maxSimilarityToPicked` — relevant but non-duplicate. -// `relevance` may be any scale (only order matters); `lambda` trades relevance -// vs diversity. An optional per-group cap keeps one document (or any group) from -// filling every slot. O(count·pool·dim), no cross-encoder. -export const maximalMarginalRelevance = ( - candidates: MMRCandidate[], - count: number, - lambda = MMR_LAMBDA, - { groupOf, maxPerGroup }: MMROptions = {} -): MMRCandidate[] => { - const remaining = [...candidates]; - const selected: MMRCandidate[] = []; - const groupCounts = new Map(); - - const isGroupFull = (candidate: MMRCandidate): boolean => { - if (!groupOf || !maxPerGroup) return false; - const group = groupOf(candidate); - if (group === undefined) return false; - return (groupCounts.get(group) ?? 0) >= maxPerGroup; - }; - - while (selected.length < count && remaining.length > 0) { - let bestIndex = -1; - let bestScore = -Infinity; - - for (let i = 0; i < remaining.length; i++) { - const candidate = remaining[i]!; - if (isGroupFull(candidate)) continue; - - let maxSimilarity = 0; - for (const picked of selected) { - const similarity = cosineSimilarity( - candidate.embedding, - picked.embedding - ); - if (similarity > maxSimilarity) maxSimilarity = similarity; - } - - const score = lambda * candidate.relevance - (1 - lambda) * maxSimilarity; - if (score > bestScore) { - bestScore = score; - bestIndex = i; - } - } - - if (bestIndex === -1) break; - - const picked = remaining.splice(bestIndex, 1)[0]!; - selected.push(picked); - const group = groupOf?.(picked); - if (group !== undefined) { - groupCounts.set(group, (groupCounts.get(group) ?? 0) + 1); - } - } - - return selected; -}; - -// Keep leading items until relevance drops below `dropRatio × previous`; at least `minKeep`. -export const adaptiveKeepCount = ( - sortedScoresDesc: number[], - minKeep = 1, - dropRatio = ADAPTIVE_K_DROP_RATIO -): number => { - const total = sortedScoresDesc.length; - if (total <= minKeep) return total; - - for (let i = Math.max(1, minKeep); i < total; i++) { - const prev = sortedScoresDesc[i - 1]!; - const curr = sortedScoresDesc[i]!; - if (prev > 0 && curr < dropRatio * prev) return i; - } - - return total; -}; diff --git a/utils/hybridRetrieval.ts b/utils/retrieval.ts similarity index 52% rename from utils/hybridRetrieval.ts rename to utils/retrieval.ts index 2acd84d5..1cc0a43b 100644 --- a/utils/hybridRetrieval.ts +++ b/utils/retrieval.ts @@ -1,35 +1,15 @@ import { OPSQLiteVectorStore } from '@react-native-rag/op-sqlite'; import { type Scalar } from '@op-engineering/op-sqlite'; import { LFMEmbeddings } from './lfmEmbeddings'; -import { extractQueryTerms, stemPrefix } from './queryTerms'; -import { keywordSearch } from '../database/keywordIndex'; import { type ContextChunk, sourceKey } from './contextUtils'; import { - adaptiveKeepCount, - cosineSimilarity, - maximalMarginalRelevance, - reciprocalRankFusion, - termCoverage, - type MMRCandidate, -} from './rankFusion'; -import { - ADAPTIVE_K_MIN_KEEP, ATTACHMENT_RELEVANCE_BONUS, CANDIDATE_POOL, - COVERAGE_ALPHA, - KEYWORD_WEIGHT, - LEXICAL_MATCH_MIN_SIMILARITY, MAX_CHUNKS_PER_FILE, MAX_RELEVANT_CHUNKS, STRONG_SEMANTIC_THRESHOLD, - VECTOR_WEIGHT, } from '../constants/retrieval'; -// Hybrid retrieval: fuse semantic vector + keyword (BM25/FTS5) search, then -// re-rank (RRF + term-coverage boost + MMR) down to the final chunks. No -// cross-encoder — see rankFusion.ts. Output feeds formatContextChunks / -// getSourceDocumentsFromChunks unchanged, preserving the "Source N" ↔ citation map. - type Candidate = { id: string; document?: string; @@ -51,8 +31,6 @@ const resolveName = ( ? sourceNamesById.get(documentId) : undefined); -// Loads full chunk rows by id to hydrate keyword-only hits absent from the -// vector pool, so they can be re-ranked on equal footing. const hydrateChunksByIds = async ( vectorStore: OPSQLiteVectorStore, ids: string[] @@ -169,9 +147,6 @@ const expandSelectedWithNeighbors = async ( for (const key of groupOrder) { const group = groups.get(key)!; - // Order chunks as relevance-ranked windows: seeds most- to least-relevant, - // each emitting its [seed-1, seed, seed+1] run in document order (deduped), - // so the matched chunk leads and a later budget truncation trims the tail. const seeds = [...group.indices.entries()] .filter(([id]) => selectedSimilarity.has(id)) .sort( @@ -215,7 +190,7 @@ const expandSelectedWithNeighbors = async ( return result; }; -export type HybridRetrieveParams = { +export type RetrieveParams = { prompt: string; enabledSourceIds: number[]; vectorStore: OPSQLiteVectorStore; @@ -224,18 +199,19 @@ export type HybridRetrieveParams = { attachmentSourceIds?: number[]; }; -export const hybridRetrieve = async ({ +export const retrieve = async ({ prompt, enabledSourceIds, vectorStore, sourceNamesById, embeddings, attachmentSourceIds = [], -}: HybridRetrieveParams): Promise => { +}: RetrieveParams): Promise => { const attachmentSet = new Set(attachmentSourceIds); const enabledSet = new Set(enabledSourceIds); const isAttachment = (documentId?: number): boolean => typeof documentId === 'number' && attachmentSet.has(documentId); + let queryEmbedding: number[] | undefined; if (embeddings) { try { @@ -245,28 +221,16 @@ export const hybridRetrieve = async ({ } } - const terms = extractQueryTerms(prompt); - const coverageTerms = new Set([...terms].map(stemPrefix)); - - // Isolate the vector query: without a usable embedding the store re-embeds and - // rejects, so catch here to degrade to keyword-only instead of returning nothing. - const [vectorResults, keywordHits] = await Promise.all([ - vectorStore - .query({ - ...(queryEmbedding ? { queryEmbedding } : { queryText: prompt }), - predicate: (r) => enabledSet.has(r.metadata?.documentId), - nResults: CANDIDATE_POOL, - }) - .catch((error) => { - console.warn( - 'Vector query failed; degrading to keyword-only retrieval', - error - ); - return [] as Awaited>; - }), - keywordSearch(vectorStore.db, [...terms], enabledSourceIds, CANDIDATE_POOL), - ]); - const keywordIds = new Set(keywordHits.map((hit) => hit.chunkId)); + const vectorResults = await vectorStore + .query({ + ...(queryEmbedding ? { queryEmbedding } : { queryText: prompt }), + predicate: (r) => enabledSet.has(r.metadata?.documentId), + nResults: CANDIDATE_POOL, + }) + .catch((error) => { + console.warn('Vector query failed; returning no chunks', error); + return [] as Awaited>; + }); const byId = new Map(); for (const result of vectorResults) { @@ -284,122 +248,44 @@ export const hybridRetrieve = async ({ }); } - const missingIds = keywordHits - .map((hit) => hit.chunkId) - .filter((id) => !byId.has(id)); - const hydrated = await hydrateChunksByIds(vectorStore, missingIds); - for (const row of hydrated) { - byId.set(row.id, { - id: row.id, - document: row.document, - embedding: row.embedding, - documentId: row.documentId, - name: resolveName(row.name, row.documentId, sourceNamesById), - similarity: queryEmbedding - ? cosineSimilarity(queryEmbedding, row.embedding) - : 0, - }); - } - - const fused = reciprocalRankFusion([ - { ids: vectorResults.map((r) => r.id), weight: VECTOR_WEIGHT }, - { ids: keywordHits.map((h) => h.chunkId), weight: KEYWORD_WEIGHT }, - ]); - - const coverageById = new Map(); - const coverageOf = (candidate: Candidate): number => { - let coverage = coverageById.get(candidate.id); - if (coverage === undefined) { - coverage = termCoverage( - `${candidate.name ?? ''} ${candidate.document ?? ''}`, - coverageTerms - ); - coverageById.set(candidate.id, coverage); - } - return coverage; - }; - - const qualified = [...byId.values()].filter((candidate) => { - if (isAttachment(candidate.documentId)) return true; - if (keywordIds.has(candidate.id)) return true; - if (candidate.similarity >= STRONG_SEMANTIC_THRESHOLD) return true; - return ( - candidate.similarity >= LEXICAL_MATCH_MIN_SIMILARITY && - coverageOf(candidate) > 0 - ); - }); + const qualified = [...byId.values()].filter( + (candidate) => + isAttachment(candidate.documentId) || + candidate.similarity >= STRONG_SEMANTIC_THRESHOLD + ); if (qualified.length === 0) return []; - const maxFused = Math.max( - ...qualified.map((c) => fused.get(c.id) ?? 0), - Number.EPSILON - ); - - const baseRelevanceById = new Map(); - const mmrCandidates: MMRCandidate[] = qualified.map((candidate) => { - const base = (fused.get(candidate.id) ?? 0) / maxFused; - const coverage = coverageOf(candidate); - const baseRelevance = base * (1 + COVERAGE_ALPHA * coverage); - baseRelevanceById.set(candidate.id, baseRelevance); - return { + const scored = qualified + .map((candidate) => ({ id: candidate.id, + documentId: candidate.documentId, relevance: - baseRelevance + + candidate.similarity + (isAttachment(candidate.documentId) ? ATTACHMENT_RELEVANCE_BONUS : 0), - embedding: candidate.embedding, - }; - }); + })) + .sort((a, b) => b.relevance - a.relevance); - // Cap chunks per document only when the pool spans several documents, so one - // long or freshly-attached file can't evict every other enabled source. const distinctDocs = new Set(qualified.map((c) => c.documentId)).size; - const selected = maximalMarginalRelevance( - mmrCandidates, - MAX_RELEVANT_CHUNKS, - undefined, - distinctDocs > 1 - ? { - groupOf: (candidate) => { - const documentId = byId.get(candidate.id)?.documentId; - return typeof documentId === 'number' - ? String(documentId) - : undefined; - }, - maxPerGroup: MAX_CHUNKS_PER_FILE, - } - : undefined - ); - - // Adaptive-k on the non-attachment tail: drop chunks past the first large - // relevance gap so a weak distractor never reaches the small reader. - // Attachment chunks are always kept — they are the explicit subject of the turn. - const nonAttachmentByRelevance = selected - .filter((item) => !isAttachment(byId.get(item.id)?.documentId)) - .sort( - (a, b) => - (baseRelevanceById.get(b.id) ?? 0) - (baseRelevanceById.get(a.id) ?? 0) - ); - const keepCount = adaptiveKeepCount( - nonAttachmentByRelevance.map((item) => baseRelevanceById.get(item.id) ?? 0), - ADAPTIVE_K_MIN_KEEP - ); - const keptNonAttachmentIds = new Set( - nonAttachmentByRelevance.slice(0, keepCount).map((item) => item.id) - ); - const kept = selected.filter( - (item) => - isAttachment(byId.get(item.id)?.documentId) || - keptNonAttachmentIds.has(item.id) - ); + const perFileCount = new Map(); + const selected: typeof scored = []; + for (const item of scored) { + if (selected.length >= MAX_RELEVANT_CHUNKS) break; + if (distinctDocs > 1) { + const count = perFileCount.get(item.documentId) ?? 0; + if (count >= MAX_CHUNKS_PER_FILE) continue; + perFileCount.set(item.documentId, count + 1); + } + selected.push(item); + } const ordered = attachmentSet.size - ? [...kept].sort( + ? [...selected].sort( (a, b) => - Number(isAttachment(byId.get(b.id)?.documentId)) - - Number(isAttachment(byId.get(a.id)?.documentId)) + Number(isAttachment(b.documentId)) - + Number(isAttachment(a.documentId)) ) - : kept; + : selected; return expandSelectedWithNeighbors( ordered.map((item) => item.id), @@ -409,29 +295,19 @@ export const hybridRetrieve = async ({ ); }; -// Thin app↔library boundary. Binds the store + embeddings so retrieval is a -// single retrieve(query, options) call, making the hybrid testable and portable -// in isolation. It forwards to hybridRetrieve unchanged — no interface, because -// there is one implementation and one caller; extract a Retriever interface only -// if a second retriever ever appears. Deliberately NOT `implements VectorStore`: -// the hybrid is read-only and its ContextChunk output drops id/embedding, so -// coercing to QueryResult would change retrieval results. -export type HybridRetrieveOptions = Omit< - HybridRetrieveParams, +export type RetrieveOptions = Omit< + RetrieveParams, 'prompt' | 'vectorStore' | 'embeddings' >; -export class HybridRetriever { +export class Retriever { constructor( private vectorStore: OPSQLiteVectorStore, private embeddings?: LFMEmbeddings | null ) {} - retrieve( - query: string, - options: HybridRetrieveOptions - ): Promise { - return hybridRetrieve({ + retrieve(query: string, options: RetrieveOptions): Promise { + return retrieve({ prompt: query, vectorStore: this.vectorStore, embeddings: this.embeddings, From f0e39ae9d1a4acc6f5671fdca1542b400c5702c4 Mon Sep 17 00:00:00 2001 From: Krzysztof Faracik Date: Thu, 16 Jul 2026 18:19:06 +0200 Subject: [PATCH 29/42] fix(rag): recalibrate semantic gate and harden source ingestion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retrieval no longer returns empty for paraphrase queries: lower the strong-semantic threshold to 0.40 (measured on-device, true matches land ~0.28-0.54) and always keep the single best candidate above a 0.25 floor, so the top semantic match is never fully gated out. Ingestion: cap extracted text at MAX_SOURCE_TEXT_CHARS before splitting so a pathological multi-MB document can't blow up the chunk array (and memory) before the chunk backstop applies. Migration: a missing ACTIVE_EMBEDDING_MODEL_KEY (e.g. cleared AsyncStorage on a populated store) no longer reads as a model change that wipes the user's imported sources — adopt the current model instead. Co-Authored-By: Claude Opus 4.8 --- __tests__/embeddingModelMigration.test.ts | 76 +++++++++++++++++++++++ __tests__/ragPipeline.integration.test.ts | 72 +++++++++++++++++++++ __tests__/sourceStore.test.ts | 20 +++++- constants/retrieval.ts | 15 +++-- store/sourceStore.ts | 12 +++- utils/embeddingModelMigration.ts | 5 ++ utils/retrieval.ts | 19 ++++-- 7 files changed, 208 insertions(+), 11 deletions(-) create mode 100644 __tests__/embeddingModelMigration.test.ts diff --git a/__tests__/embeddingModelMigration.test.ts b/__tests__/embeddingModelMigration.test.ts new file mode 100644 index 00000000..f177729b --- /dev/null +++ b/__tests__/embeddingModelMigration.test.ts @@ -0,0 +1,76 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { migrateEmbeddingModelIfNeeded } from '../utils/embeddingModelMigration'; +import { ACTIVE_EMBEDDING_MODEL_KEY } from '../constants/embedding-model'; +import type { OPSQLiteVectorStore } from '@react-native-rag/op-sqlite'; +import type { SQLiteDatabase } from 'expo-sqlite'; + +const makeVectorStore = () => { + const deleteVectorStore = jest.fn().mockResolvedValue(undefined); + return { + store: { deleteVectorStore } as unknown as OPSQLiteVectorStore, + deleteVectorStore, + }; +}; + +const makeDb = () => { + const runAsync = jest.fn().mockResolvedValue(undefined); + return { db: { runAsync } as unknown as SQLiteDatabase, runAsync }; +}; + +beforeEach(async () => { + jest.clearAllMocks(); + await AsyncStorage.clear(); + jest.spyOn(console, 'warn').mockImplementation(() => {}); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +describe('migrateEmbeddingModelIfNeeded', () => { + it('no-ops when the stored model matches the current one', async () => { + await AsyncStorage.setItem(ACTIVE_EMBEDDING_MODEL_KEY, 'model-a'); + const { store, deleteVectorStore } = makeVectorStore(); + const { db, runAsync } = makeDb(); + + const migrated = await migrateEmbeddingModelIfNeeded(store, db, 'model-a'); + + expect(migrated).toBe(false); + expect(deleteVectorStore).not.toHaveBeenCalled(); + expect(runAsync).not.toHaveBeenCalled(); + }); + + it('adopts the current model without wiping when no model was recorded', async () => { + const { store, deleteVectorStore } = makeVectorStore(); + const { db, runAsync } = makeDb(); + + const migrated = await migrateEmbeddingModelIfNeeded(store, db, 'model-a'); + + expect(migrated).toBe(false); + expect(deleteVectorStore).not.toHaveBeenCalled(); + expect(runAsync).not.toHaveBeenCalled(); + expect(await AsyncStorage.getItem(ACTIVE_EMBEDDING_MODEL_KEY)).toBe( + 'model-a' + ); + }); + + it('wipes the vector store and source metadata on a genuine model change', async () => { + await AsyncStorage.setItem(ACTIVE_EMBEDDING_MODEL_KEY, 'model-old'); + const { store, deleteVectorStore } = makeVectorStore(); + const { db, runAsync } = makeDb(); + + const migrated = await migrateEmbeddingModelIfNeeded( + store, + db, + 'model-new' + ); + + expect(migrated).toBe(true); + expect(deleteVectorStore).toHaveBeenCalledTimes(1); + expect(runAsync).toHaveBeenCalledWith('DELETE FROM chatSources'); + expect(runAsync).toHaveBeenCalledWith('DELETE FROM sources'); + expect(await AsyncStorage.getItem(ACTIVE_EMBEDDING_MODEL_KEY)).toBe( + 'model-new' + ); + }); +}); diff --git a/__tests__/ragPipeline.integration.test.ts b/__tests__/ragPipeline.integration.test.ts index d8dd5064..0cfa7426 100644 --- a/__tests__/ragPipeline.integration.test.ts +++ b/__tests__/ragPipeline.integration.test.ts @@ -252,4 +252,76 @@ describe('buildMessageSources — retrieval → context → citation pipeline', expect(present.has(cited.name)).toBe(true); } }); + + it('surfaces a paraphrase match that clears the lowered semantic threshold', async () => { + const vectorStore = makeVectorStore([ + { + id: '1:0', + document: 'the summit of Everest is the highest point on Earth', + embedding: [1, 0], + similarity: 0.45, + metadata: { documentId: 1, name: 'everest.txt' }, + }, + ]); + + const { context, sourceDocuments } = await buildMessageSources({ + userInput: 'which mountain is the tallest', + attachmentSourceIds: [], + enabledSources: [1], + sources: [source(1, 'everest.txt')], + vectorStore, + embeddings: null, + }); + + expect(context.join('\n')).toContain('--- Source 1:'); + expect(sourceDocuments.map((d) => d.name)).toEqual(['everest.txt']); + }); + + it('keeps the single best chunk above the top-keep floor even below the main threshold', async () => { + const vectorStore = makeVectorStore([ + { + id: '1:0', + document: 'a loosely related passage about alpine geography', + embedding: [1, 0], + similarity: 0.3, + metadata: { documentId: 1, name: 'geography.txt' }, + }, + ]); + + const { context, sourceDocuments } = await buildMessageSources({ + userInput: 'which mountain is the tallest', + attachmentSourceIds: [], + enabledSources: [1], + sources: [source(1, 'geography.txt')], + vectorStore, + embeddings: null, + }); + + expect(context.join('\n')).toContain('--- Source 1:'); + expect(sourceDocuments.map((d) => d.name)).toEqual(['geography.txt']); + }); + + it('returns nothing when even the best chunk is below the top-keep floor', async () => { + const vectorStore = makeVectorStore([ + { + id: '1:0', + document: 'entirely unrelated noise passage', + embedding: [1, 0], + similarity: 0.2, + metadata: { documentId: 1, name: 'noise.txt' }, + }, + ]); + + const { context, sourceDocuments } = await buildMessageSources({ + userInput: 'which mountain is the tallest', + attachmentSourceIds: [], + enabledSources: [1], + sources: [source(1, 'noise.txt')], + vectorStore, + embeddings: null, + }); + + expect(context).toEqual([]); + expect(sourceDocuments).toEqual([]); + }); }); diff --git a/__tests__/sourceStore.test.ts b/__tests__/sourceStore.test.ts index 315ff534..4c27cc10 100644 --- a/__tests__/sourceStore.test.ts +++ b/__tests__/sourceStore.test.ts @@ -6,7 +6,10 @@ import { useLLMStore } from '../store/llmStore'; import type { SQLiteDatabase } from 'expo-sqlite'; import type { OPSQLiteVectorStore } from '@react-native-rag/op-sqlite'; import type { LFMEmbeddings } from '../utils/lfmEmbeddings'; -import { MAX_SOURCE_CHUNKS } from '../constants/retrieval'; +import { + MAX_SOURCE_CHUNKS, + MAX_SOURCE_TEXT_CHARS, +} from '../constants/retrieval'; jest.mock('../database/sourcesRepository'); jest.mock('../utils/fileReaders'); @@ -169,6 +172,21 @@ describe('addSource', () => { expect(vectorStoreAdd).toHaveBeenCalledTimes(MAX_SOURCE_CHUNKS); }); + it('caps oversized extracted text before splitting and flags it truncated', async () => { + const oversized = 'a'.repeat(MAX_SOURCE_TEXT_CHARS + 500); + mockReadDocumentText.mockResolvedValue(oversized); + mockInsertSource.mockResolvedValue(99); + const splitText = jest.fn().mockResolvedValue(['chunk']); + MockSplitter.mockImplementation(() => ({ splitText })); + + const result = await useSourceStore + .getState() + .addSource(baseSource, '/path/doc.txt', mockVectorStore); + + expect(result).toEqual({ success: true, sourceId: 99, truncated: true }); + expect(splitText.mock.calls[0][0]).toHaveLength(MAX_SOURCE_TEXT_CHARS); + }); + it('aborts embedding and rolls back the partial source when the signal is aborted', async () => { mockReadDocumentText.mockResolvedValue('content'); mockInsertSource.mockResolvedValue(99); diff --git a/constants/retrieval.ts b/constants/retrieval.ts index 04f38f44..03fa6a22 100644 --- a/constants/retrieval.ts +++ b/constants/retrieval.ts @@ -1,7 +1,7 @@ -/** Candidates each retriever contributes before fusion. */ +/** Candidates pulled from the vector store before gating and re-ranking. */ export const CANDIDATE_POOL = 20; -/** Final chunks kept after re-ranking (MMR selection size). */ +/** Final chunks kept after gating and per-file capping. */ export const MAX_RELEVANT_CHUNKS = 5; /** Bonus that floats a freshly-attached chunk past the gate to the pool's front. */ @@ -10,8 +10,11 @@ export const ATTACHMENT_RELEVANCE_BONUS = 10; /** Max chunks kept from one document in the final selection, applied only when ≥2 documents qualify. */ export const MAX_CHUNKS_PER_FILE = 3; -/** Cosine floor to qualify a chunk on semantic similarity; above LFM2.5's ~0.35–0.45 noise floor. */ -export const STRONG_SEMANTIC_THRESHOLD = 0.55; +/** Cosine floor to qualify on semantics alone. Measured on-device: LFM2.5 true paraphrase matches land ~0.28–0.54, so 0.40 admits clear matches while most unrelated passages stay below. */ +export const STRONG_SEMANTIC_THRESHOLD = 0.4; + +/** The single highest-similarity candidate always qualifies above this floor, so the best semantic match is never fully gated out (empty result) on a paraphrase that falls just short of the main threshold. */ +export const SEMANTIC_TOP_KEEP_FLOOR = 0.25; /** After generation, cite a non-attachment document only if its answer↔passage term overlap is at least this fraction of the strongest cited document's — attributes the reply to the source(s) it was actually based on. */ export const ANSWER_CITATION_OVERLAP_RATIO = 0.5; @@ -22,6 +25,10 @@ export const TEXT_SPLITTER_CHUNK_OVERLAP = 200; /** Safety backstop on chunks embedded per source; high enough that large real documents (~1.6 MB of text) index in full, low enough to stop a pathological multi-MB file from embedding for tens of minutes. */ export const MAX_SOURCE_CHUNKS = 2000; +/** Hard cap on extracted text fed to the splitter, so a pathological multi-MB document can't blow up the chunk array (and memory) before the chunk backstop applies. Derived from the chunk backstop and chunk size. */ +export const MAX_SOURCE_TEXT_CHARS = + MAX_SOURCE_CHUNKS * TEXT_SPLITTER_CHUNK_SIZE; + /** Min matched run to treat as overlap when stitching passages — below the splitter overlap, above coincidental repetition. */ export const MIN_STITCH_OVERLAP = 24; diff --git a/store/sourceStore.ts b/store/sourceStore.ts index 4afdc4e5..24f76a39 100644 --- a/store/sourceStore.ts +++ b/store/sourceStore.ts @@ -16,6 +16,7 @@ import { useLLMStore } from './llmStore'; import { LFMEmbeddings } from '../utils/lfmEmbeddings'; import { MAX_SOURCE_CHUNKS, + MAX_SOURCE_TEXT_CHARS, TEXT_SPLITTER_CHUNK_OVERLAP, TEXT_SPLITTER_CHUNK_SIZE, } from '../constants/retrieval'; @@ -96,12 +97,19 @@ export const useSourceStore = create((set, get) => ({ const tempSource: Source = { ...source, id: tempId, isProcessing: true }; set((state) => ({ sources: [...state.sources, tempSource] })); + const cappedText = + sourceTextContent.length > MAX_SOURCE_TEXT_CHARS + ? sourceTextContent.slice(0, MAX_SOURCE_TEXT_CHARS) + : sourceTextContent; + const textSplitter = new RecursiveCharacterTextSplitter({ chunkSize: TEXT_SPLITTER_CHUNK_SIZE, chunkOverlap: TEXT_SPLITTER_CHUNK_OVERLAP, }); - const allChunks = await textSplitter.splitText(sourceTextContent); - const truncated = allChunks.length > MAX_SOURCE_CHUNKS; + const allChunks = await textSplitter.splitText(cappedText); + const truncated = + cappedText.length < sourceTextContent.length || + allChunks.length > MAX_SOURCE_CHUNKS; const chunks = truncated ? allChunks.slice(0, MAX_SOURCE_CHUNKS) : allChunks; diff --git a/utils/embeddingModelMigration.ts b/utils/embeddingModelMigration.ts index fb07f1e0..ee2a4255 100644 --- a/utils/embeddingModelMigration.ts +++ b/utils/embeddingModelMigration.ts @@ -12,6 +12,11 @@ export const migrateEmbeddingModelIfNeeded = async ( if (storedModelId === currentModelId) return false; + if (storedModelId === null) { + await AsyncStorage.setItem(ACTIVE_EMBEDDING_MODEL_KEY, currentModelId); + return false; + } + await vectorStore.deleteVectorStore(); try { diff --git a/utils/retrieval.ts b/utils/retrieval.ts index 1cc0a43b..93442020 100644 --- a/utils/retrieval.ts +++ b/utils/retrieval.ts @@ -7,6 +7,7 @@ import { CANDIDATE_POOL, MAX_CHUNKS_PER_FILE, MAX_RELEVANT_CHUNKS, + SEMANTIC_TOP_KEEP_FLOOR, STRONG_SEMANTIC_THRESHOLD, } from '../constants/retrieval'; @@ -248,12 +249,22 @@ export const retrieve = async ({ }); } - const qualified = [...byId.values()].filter( - (candidate) => - isAttachment(candidate.documentId) || - candidate.similarity >= STRONG_SEMANTIC_THRESHOLD + const candidates = [...byId.values()]; + const topSemantic = candidates.reduce( + (best, candidate) => + candidate.similarity > (best?.similarity ?? -Infinity) ? candidate : best, + null ); + const qualified = candidates.filter((candidate) => { + if (isAttachment(candidate.documentId)) return true; + if (candidate.similarity >= STRONG_SEMANTIC_THRESHOLD) return true; + return ( + candidate === topSemantic && + candidate.similarity >= SEMANTIC_TOP_KEEP_FLOOR + ); + }); + if (qualified.length === 0) return []; const scored = qualified From 4119c3bc9e15cba346a7c07d7e0c5b016d1a6b34 Mon Sep 17 00:00:00 2001 From: Krzysztof Faracik Date: Mon, 20 Jul 2026 14:46:21 +0200 Subject: [PATCH 30/42] refactor(chat): animate AttachmentThumbnail progress with Reanimated --- .../chat-screen/AttachmentThumbnail.tsx | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/components/chat-screen/AttachmentThumbnail.tsx b/components/chat-screen/AttachmentThumbnail.tsx index 1948729a..e126c12c 100644 --- a/components/chat-screen/AttachmentThumbnail.tsx +++ b/components/chat-screen/AttachmentThumbnail.tsx @@ -1,14 +1,18 @@ -import React, { useEffect, useMemo, useRef } from 'react'; +import React, { useEffect, useMemo } from 'react'; import { View, Image, Text, TouchableOpacity, ActivityIndicator, - Animated, - Easing, StyleSheet, } from 'react-native'; +import Animated, { + Easing, + useAnimatedStyle, + useSharedValue, + withTiming, +} from 'react-native-reanimated'; import { useTheme } from '../../context/ThemeContext'; import { Theme } from '../../styles/colors'; import { fontFamily, fontSizes } from '../../styles/fontStyles'; @@ -26,17 +30,19 @@ const AttachmentThumbnail = ({ attachment, onRemove }: Props) => { const { theme } = useTheme(); const styles = useMemo(() => createStyles(theme), [theme]); - const fill = useRef(new Animated.Value(0)).current; + const fill = useSharedValue(0); useEffect(() => { if (attachment.progress == null) return; - Animated.timing(fill, { - toValue: attachment.progress * ATTACHMENT_PROGRESS_TRACK_WIDTH, - duration: 250, - easing: Easing.out(Easing.cubic), - useNativeDriver: false, - }).start(); + fill.set( + withTiming(attachment.progress * ATTACHMENT_PROGRESS_TRACK_WIDTH, { + duration: 250, + easing: Easing.out(Easing.cubic), + }) + ); }, [attachment.progress, fill]); + const fillStyle = useAnimatedStyle(() => ({ width: fill.get() })); + const renderContent = () => { if (attachment.status === 'loading') { if (attachment.progress == null) { @@ -58,7 +64,7 @@ const AttachmentThumbnail = ({ attachment, onRemove }: Props) => { {Math.round(attachment.progress * 100)}% - + ); From 811e338cdf9f62c1e3902710c125166c161019a5 Mon Sep 17 00:00:00 2001 From: Krzysztof Faracik Date: Tue, 21 Jul 2026 11:39:15 +0200 Subject: [PATCH 31/42] fix(rag): wipe dimension-incompatible legacy vectors when adopting a null model key --- __tests__/embeddingModelMigration.test.ts | 80 +++++++++++++++++++++-- context/VectorStoreContext.tsx | 4 +- utils/embeddingModelMigration.ts | 53 +++++++++++---- 3 files changed, 116 insertions(+), 21 deletions(-) diff --git a/__tests__/embeddingModelMigration.test.ts b/__tests__/embeddingModelMigration.test.ts index f177729b..135977d9 100644 --- a/__tests__/embeddingModelMigration.test.ts +++ b/__tests__/embeddingModelMigration.test.ts @@ -4,11 +4,27 @@ import { ACTIVE_EMBEDDING_MODEL_KEY } from '../constants/embedding-model'; import type { OPSQLiteVectorStore } from '@react-native-rag/op-sqlite'; import type { SQLiteDatabase } from 'expo-sqlite'; -const makeVectorStore = () => { +const CURRENT_DIM = 1024; + +const makeVectorStore = (persistedDim?: number) => { const deleteVectorStore = jest.fn().mockResolvedValue(undefined); + const execute = jest.fn().mockResolvedValue({ + rows: + persistedDim === undefined + ? [] + : [ + { + sql: `CREATE TABLE vectors (id TEXT, embedding F32_BLOB(${persistedDim}) NOT NULL)`, + }, + ], + }); return { - store: { deleteVectorStore } as unknown as OPSQLiteVectorStore, + store: { + deleteVectorStore, + db: { execute }, + } as unknown as OPSQLiteVectorStore, deleteVectorStore, + execute, }; }; @@ -33,18 +49,47 @@ describe('migrateEmbeddingModelIfNeeded', () => { const { store, deleteVectorStore } = makeVectorStore(); const { db, runAsync } = makeDb(); - const migrated = await migrateEmbeddingModelIfNeeded(store, db, 'model-a'); + const migrated = await migrateEmbeddingModelIfNeeded( + store, + db, + 'model-a', + CURRENT_DIM + ); expect(migrated).toBe(false); expect(deleteVectorStore).not.toHaveBeenCalled(); expect(runAsync).not.toHaveBeenCalled(); }); - it('adopts the current model without wiping when no model was recorded', async () => { + it('adopts the current model without wiping on a fresh install (no vectors table)', async () => { const { store, deleteVectorStore } = makeVectorStore(); const { db, runAsync } = makeDb(); - const migrated = await migrateEmbeddingModelIfNeeded(store, db, 'model-a'); + const migrated = await migrateEmbeddingModelIfNeeded( + store, + db, + 'model-a', + CURRENT_DIM + ); + + expect(migrated).toBe(false); + expect(deleteVectorStore).not.toHaveBeenCalled(); + expect(runAsync).not.toHaveBeenCalled(); + expect(await AsyncStorage.getItem(ACTIVE_EMBEDDING_MODEL_KEY)).toBe( + 'model-a' + ); + }); + + it('adopts without wiping when a lost key sits over dimension-compatible vectors', async () => { + const { store, deleteVectorStore } = makeVectorStore(CURRENT_DIM); + const { db, runAsync } = makeDb(); + + const migrated = await migrateEmbeddingModelIfNeeded( + store, + db, + 'model-a', + CURRENT_DIM + ); expect(migrated).toBe(false); expect(deleteVectorStore).not.toHaveBeenCalled(); @@ -54,15 +99,36 @@ describe('migrateEmbeddingModelIfNeeded', () => { ); }); + it('wipes legacy pre-key sources when the persisted dimension is incompatible (384 to 1024)', async () => { + const { store, deleteVectorStore } = makeVectorStore(384); + const { db, runAsync } = makeDb(); + + const migrated = await migrateEmbeddingModelIfNeeded( + store, + db, + 'lfm-2-5', + CURRENT_DIM + ); + + expect(migrated).toBe(true); + expect(deleteVectorStore).toHaveBeenCalledTimes(1); + expect(runAsync).toHaveBeenCalledWith('DELETE FROM chatSources'); + expect(runAsync).toHaveBeenCalledWith('DELETE FROM sources'); + expect(await AsyncStorage.getItem(ACTIVE_EMBEDDING_MODEL_KEY)).toBe( + 'lfm-2-5' + ); + }); + it('wipes the vector store and source metadata on a genuine model change', async () => { await AsyncStorage.setItem(ACTIVE_EMBEDDING_MODEL_KEY, 'model-old'); - const { store, deleteVectorStore } = makeVectorStore(); + const { store, deleteVectorStore } = makeVectorStore(CURRENT_DIM); const { db, runAsync } = makeDb(); const migrated = await migrateEmbeddingModelIfNeeded( store, db, - 'model-new' + 'model-new', + CURRENT_DIM ); expect(migrated).toBe(true); diff --git a/context/VectorStoreContext.tsx b/context/VectorStoreContext.tsx index 3af4509a..9a1d3d0b 100644 --- a/context/VectorStoreContext.tsx +++ b/context/VectorStoreContext.tsx @@ -2,6 +2,7 @@ import React, { createContext, useState, useEffect, useContext } from 'react'; import { useSQLiteContext } from 'expo-sqlite'; import { OPSQLiteVectorStore } from '@react-native-rag/op-sqlite'; import { + LFM_2_5_EMBEDDING_DIM, LFM_2_5_EMBEDDING_MODEL_ID, LFM_2_5_EMBEDDING_SOURCES, } from '../constants/embedding-model'; @@ -66,7 +67,8 @@ export const VectorStoreProvider = ({ await migrateEmbeddingModelIfNeeded( store, db, - LFM_2_5_EMBEDDING_MODEL_ID + LFM_2_5_EMBEDDING_MODEL_ID, + LFM_2_5_EMBEDDING_DIM ); if (cancelled) return; diff --git a/utils/embeddingModelMigration.ts b/utils/embeddingModelMigration.ts index ee2a4255..ed370421 100644 --- a/utils/embeddingModelMigration.ts +++ b/utils/embeddingModelMigration.ts @@ -3,22 +3,26 @@ import { type SQLiteDatabase } from 'expo-sqlite'; import { OPSQLiteVectorStore } from '@react-native-rag/op-sqlite'; import { ACTIVE_EMBEDDING_MODEL_KEY } from '../constants/embedding-model'; -export const migrateEmbeddingModelIfNeeded = async ( - vectorStore: OPSQLiteVectorStore, - db: SQLiteDatabase, - currentModelId: string -): Promise => { - const storedModelId = await AsyncStorage.getItem(ACTIVE_EMBEDDING_MODEL_KEY); - - if (storedModelId === currentModelId) return false; - - if (storedModelId === null) { - await AsyncStorage.setItem(ACTIVE_EMBEDDING_MODEL_KEY, currentModelId); - return false; +const readPersistedVectorDim = async ( + vectorStore: OPSQLiteVectorStore +): Promise => { + try { + const result = await vectorStore.db.execute( + `SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'vectors'` + ); + const createSql = result.rows[0]?.sql as string | undefined; + const match = createSql?.match(/F32_BLOB\((\d+)\)/i); + return match ? Number(match[1]) : null; + } catch { + return null; } +}; +const clearImportedSources = async ( + vectorStore: OPSQLiteVectorStore, + db: SQLiteDatabase +): Promise => { await vectorStore.deleteVectorStore(); - try { await db.runAsync(`DELETE FROM chatSources`); await db.runAsync(`DELETE FROM sources`); @@ -28,7 +32,30 @@ export const migrateEmbeddingModelIfNeeded = async ( error ); } +}; + +export const migrateEmbeddingModelIfNeeded = async ( + vectorStore: OPSQLiteVectorStore, + db: SQLiteDatabase, + currentModelId: string, + currentModelDim: number +): Promise => { + const storedModelId = await AsyncStorage.getItem(ACTIVE_EMBEDDING_MODEL_KEY); + + if (storedModelId === currentModelId) return false; + + if (storedModelId === null) { + const persistedDim = await readPersistedVectorDim(vectorStore); + const incompatible = + persistedDim !== null && persistedDim !== currentModelDim; + if (incompatible) { + await clearImportedSources(vectorStore, db); + } + await AsyncStorage.setItem(ACTIVE_EMBEDDING_MODEL_KEY, currentModelId); + return incompatible; + } + await clearImportedSources(vectorStore, db); await AsyncStorage.setItem(ACTIVE_EMBEDDING_MODEL_KEY, currentModelId); return true; }; From 26eefa874515fed608ebc583c63bb0d218fd0579 Mon Sep 17 00:00:00 2001 From: Krzysztof Faracik Date: Tue, 21 Jul 2026 12:12:29 +0200 Subject: [PATCH 32/42] perf(chat): host a single SourcesSheet at the screen level --- __tests__/MessageItem.test.tsx | 65 +----- __tests__/SourcesSheet.test.tsx | 131 +++++++++++ components/chat-screen/MessageItem.tsx | 20 +- components/chat-screen/Messages.tsx | 13 +- components/chat-screen/SourcesSheet.tsx | 292 ++++++++++++------------ 5 files changed, 309 insertions(+), 212 deletions(-) create mode 100644 __tests__/SourcesSheet.test.tsx diff --git a/__tests__/MessageItem.test.tsx b/__tests__/MessageItem.test.tsx index 31e3de1d..fa64958d 100644 --- a/__tests__/MessageItem.test.tsx +++ b/__tests__/MessageItem.test.tsx @@ -168,81 +168,30 @@ describe('assistant messages', () => { expect(screen.queryByText(/tps:/)).toBeNull(); }); - it('renders a sources button that opens a deduplicated list without the "Source document" label', () => { + it('hands deduplicated sources to onShowSources when the sources button is pressed', () => { + const onShowSources = jest.fn(); renderItem({ role: 'assistant', content: 'The answer is in the report.', + userQuestion: 'What was the revenue?', sourceDocuments: [ { documentId: 1, name: 'financial_report.pdf' }, { documentId: 1, name: 'financial_report.pdf' }, ], + onShowSources, }); expect(screen.getByTestId('source-action-button')).toBeTruthy(); expect(screen.getByLabelText('Sources')).toBeTruthy(); - expect(screen.getAllByText('Sources').length).toBeGreaterThanOrEqual(1); - expect(screen.getByText('PDF')).toBeTruthy(); - expect(screen.getAllByText('financial_report.pdf')).toHaveLength(1); - expect(screen.queryByText('Source document')).toBeNull(); fireEvent.press(screen.getByTestId('source-action-button')); - }); - - it('keeps the cited passage collapsed until the source row is tapped', () => { - renderItem({ - role: 'assistant', - content: 'The answer is in the report.', - sourceDocuments: [ - { - documentId: 1, - name: 'financial_report.pdf', - passage: 'Net revenue grew 12% year over year.', - }, - ], - }); - - expect(screen.queryByTestId('source-passage')).toBeNull(); - fireEvent.press(screen.getByTestId('source-item')); - - expect(screen.getByTestId('source-passage')).toBeTruthy(); - expect( - screen.getByText('Net revenue grew 12% year over year.') - ).toBeTruthy(); - }); - - it('emphasises the passage span relevant to the user question', () => { - const passage = - 'Intro sentence. Net revenue grew 12% year over year. Outro.'; - renderItem({ - role: 'assistant', - content: 'Net revenue grew 12% last year, showing strong growth.', - userQuestion: 'What was the net revenue growth?', - sourceDocuments: [ - { documentId: 1, name: 'financial_report.pdf', passage }, - ], - }); - - fireEvent.press(screen.getByTestId('source-item')); - - const cited = screen.getByText('Net revenue grew 12% year over year.'); - expect(cited).toBeTruthy(); - expect(cited.props.style).toEqual( - expect.objectContaining({ fontFamily: expect.any(String) }) + expect(onShowSources).toHaveBeenCalledWith( + [{ documentId: 1, name: 'financial_report.pdf' }], + 'What was the revenue?' ); }); - it('does not render a passage block when the source has no passage', () => { - renderItem({ - role: 'assistant', - content: 'The answer is in the report.', - sourceDocuments: [{ documentId: 1, name: 'financial_report.pdf' }], - }); - - fireEvent.press(screen.getByTestId('source-item')); - expect(screen.queryByTestId('source-passage')).toBeNull(); - }); - it('strips inline [n] citation markers from the rendered answer', () => { renderItem({ role: 'assistant', diff --git a/__tests__/SourcesSheet.test.tsx b/__tests__/SourcesSheet.test.tsx new file mode 100644 index 00000000..5fe88fd2 --- /dev/null +++ b/__tests__/SourcesSheet.test.tsx @@ -0,0 +1,131 @@ +import React from 'react'; +import type { ViewProps } from 'react-native'; +import { act, fireEvent, render, screen } from '@testing-library/react-native'; +import type { SourceDocument } from '../database/chatRepository'; + +type BottomSheetModalHandle = { + present: jest.Mock; + dismiss: jest.Mock; +}; + +type BottomSheetModalMockProps = { + children?: React.ReactNode; +}; + +jest.mock('../context/ThemeContext', () => ({ + useTheme: () => ({ + theme: { + ...require('../styles/colors').lightTheme, + insets: { top: 0, bottom: 0, left: 0, right: 0 }, + }, + }), +})); + +jest.mock('@gorhom/bottom-sheet', () => { + const MockReact = require('react') as typeof import('react'); + const { View } = require('react-native'); + + const BottomSheetModal = MockReact.forwardRef< + BottomSheetModalHandle, + BottomSheetModalMockProps + >(({ children }, ref) => { + MockReact.useImperativeHandle(ref, () => ({ + present: jest.fn(), + dismiss: jest.fn(), + })); + return {children}; + }); + + return { + BottomSheetBackdrop: (props: ViewProps) => , + BottomSheetModal, + BottomSheetView: View, + BottomSheetScrollView: View, + useBottomSheet: () => ({ close: jest.fn() }), + useBottomSheetSpringConfigs: (config: unknown) => config, + }; +}); + +import SourcesSheet, { + type SourcesSheetHandle, +} from '../components/chat-screen/SourcesSheet'; + +const presentWith = (sources: SourceDocument[], userQuestion?: string) => { + const ref = React.createRef(); + render(); + act(() => { + ref.current?.present(sources, userQuestion); + }); + return ref; +}; + +describe('SourcesSheet', () => { + it('renders nothing until present() supplies sources', () => { + render(()} />); + + expect(screen.queryByTestId('source-item')).toBeNull(); + }); + + it('renders the sources handed to present()', () => { + presentWith([{ documentId: 1, name: 'financial_report.pdf' }]); + + expect(screen.getByText('financial_report.pdf')).toBeTruthy(); + expect(screen.getByText('PDF')).toBeTruthy(); + expect(screen.queryByText('Source document')).toBeNull(); + }); + + it('ignores a second present() while the sheet is still open', () => { + const ref = presentWith([{ documentId: 1, name: 'first.pdf' }]); + + act(() => { + ref.current?.present([{ documentId: 2, name: 'second.pdf' }]); + }); + + expect(screen.getByText('first.pdf')).toBeTruthy(); + expect(screen.queryByText('second.pdf')).toBeNull(); + }); + + it('keeps the cited passage collapsed until the source row is tapped', () => { + presentWith([ + { + documentId: 1, + name: 'financial_report.pdf', + passage: 'Net revenue grew 12% year over year.', + }, + ]); + + expect(screen.queryByTestId('source-passage')).toBeNull(); + + fireEvent.press(screen.getByTestId('source-item')); + + expect(screen.getByTestId('source-passage')).toBeTruthy(); + expect( + screen.getByText('Net revenue grew 12% year over year.') + ).toBeTruthy(); + }); + + it('emphasises the passage span relevant to the user question', () => { + const passage = + 'Intro sentence. Net revenue grew 12% year over year. Outro.'; + presentWith( + [{ documentId: 1, name: 'financial_report.pdf', passage }], + 'What was the net revenue growth?' + ); + + fireEvent.press(screen.getByTestId('source-item')); + + const cited = screen.getByText('Net revenue grew 12% year over year.'); + expect(cited).toBeTruthy(); + expect(cited.props.style).toEqual( + expect.objectContaining({ fontFamily: expect.any(String) }) + ); + }); + + it('does not render a passage block when the source has no passage', () => { + presentWith([{ documentId: 1, name: 'financial_report.pdf' }]); + + fireEvent.press(screen.getByTestId('source-item')); + + expect(screen.queryByTestId('source-passage')).toBeNull(); + }); +}); diff --git a/components/chat-screen/MessageItem.tsx b/components/chat-screen/MessageItem.tsx index 89812fbe..cb0e36b8 100644 --- a/components/chat-screen/MessageItem.tsx +++ b/components/chat-screen/MessageItem.tsx @@ -1,4 +1,4 @@ -import React, { memo, useCallback, useMemo, useRef, useState } from 'react'; +import React, { memo, useCallback, useMemo, useState } from 'react'; import { View, StyleSheet, @@ -11,7 +11,6 @@ import { import MarkdownComponent from './MarkdownComponent'; import ThinkingBlock from './ThinkingBlock'; import AnimatedChatLoading from './AnimatedChatLoading'; -import SourcesSheet, { type SourcesSheetHandle } from './SourcesSheet'; import { fontFamily, fontSizes, lineHeights } from '../../styles/fontStyles'; import { useTheme } from '../../context/ThemeContext'; import { useLLMStore } from '../../store/llmStore'; @@ -34,6 +33,7 @@ interface MessageItemProps { documentName?: string; sourceDocuments?: SourceDocument[]; userQuestion?: string; + onShowSources?: (sources: SourceDocument[], userQuestion?: string) => void; } const THINK_OPEN = ''; @@ -80,12 +80,13 @@ const MessageItem = memo( documentName, sourceDocuments, userQuestion, + onShowSources, }: MessageItemProps) => { const { theme } = useTheme(); const styles = useMemo(() => createStyles(theme), [theme]); - const { isGenerating, isProcessingPrompt } = useLLMStore(); + const isGenerating = useLLMStore((state) => state.isGenerating); + const isProcessingPrompt = useLLMStore((state) => state.isProcessingPrompt); const [lightboxVisible, setLightboxVisible] = useState(false); - const sourcesSheetRef = useRef(null); const contentParts = parseThinkingContent(content); const hasSources = !!sourceDocuments?.length; @@ -230,7 +231,9 @@ const MessageItem = memo( styles.sourcesButton, pressed && styles.sourcesButtonPressed, ]} - onPress={() => sourcesSheetRef.current?.present()} + onPress={() => + onShowSources?.(displayedSources, userQuestion) + } hitSlop={8} accessibilityRole="button" accessibilityLabel="Sources" @@ -248,13 +251,6 @@ const MessageItem = memo( )} - {role === 'assistant' && canShowSourcesAction && ( - - )} ); } diff --git a/components/chat-screen/Messages.tsx b/components/chat-screen/Messages.tsx index afe9fea8..8474e3f7 100644 --- a/components/chat-screen/Messages.tsx +++ b/components/chat-screen/Messages.tsx @@ -28,7 +28,8 @@ import Reanimated, { } from 'react-native-reanimated'; import type { SharedValue } from 'react-native-reanimated'; import MessageItem from './MessageItem'; -import { Message } from '../../database/chatRepository'; +import SourcesSheet, { type SourcesSheetHandle } from './SourcesSheet'; +import { Message, SourceDocument } from '../../database/chatRepository'; import { useTheme } from '../../context/ThemeContext'; import { Theme } from '../../styles/colors'; import ChevronDown from '../../assets/icons/chevron-down.svg'; @@ -77,6 +78,13 @@ const Messages = ({ const [showScrollButton, setShowScrollButton] = useState(false); const lastScrollOffset = useRef(0); const lastLayoutHeight = useRef(0); + const sourcesSheetRef = useRef(null); + + const handleShowSources = useCallback( + (sources: SourceDocument[], question?: string) => + sourcesSheetRef.current?.present(sources, question), + [] + ); // v0-style initial scroll: hide the view until we've snapped to // the bottom, then fade in so the user never sees content flying by. @@ -401,6 +409,7 @@ const Messages = ({ documentName={message.documentName} sourceDocuments={message.sourceDocuments} userQuestion={userQuestion} + onShowSources={handleShowSources} /> ); @@ -435,6 +444,8 @@ const Messages = ({ )} + + ); }; diff --git a/components/chat-screen/SourcesSheet.tsx b/components/chat-screen/SourcesSheet.tsx index 3b2f93e2..d566c0f8 100644 --- a/components/chat-screen/SourcesSheet.tsx +++ b/components/chat-screen/SourcesSheet.tsx @@ -1,5 +1,6 @@ import React, { forwardRef, + memo, useCallback, useEffect, useImperativeHandle, @@ -136,171 +137,180 @@ const SourceRow = ({ }; export interface SourcesSheetHandle { - present: (highlightIndex?: number | null) => void; + present: ( + sources: SourceDocument[], + userQuestion?: string, + highlightIndex?: number | null + ) => void; } -interface SourcesSheetProps { +interface SourcesSheetPayload { sources: SourceDocument[]; userQuestion?: string; } -const SourcesSheet = forwardRef( - ({ sources, userQuestion }, ref) => { - const { theme } = useTheme(); - const styles = useMemo(() => createStyles(theme), [theme]); - const { height: screenHeight } = useWindowDimensions(); +const SourcesSheet = forwardRef((_props, ref) => { + const { theme } = useTheme(); + const styles = useMemo(() => createStyles(theme), [theme]); + const { height: screenHeight } = useWindowDimensions(); - const sheetRef = useRef(null); - const isOpenRef = useRef(false); - const scrollRef = useRef(null); - const rowYRef = useRef>({}); - const listYRef = useRef(0); - const scrollTimerRef = useRef | null>(null); - const [highlightedIndex, setHighlightedIndex] = useState( - null - ); - const [expandedIndex, setExpandedIndex] = useState(null); - const [contentHeight, setContentHeight] = useState(0); + const sheetRef = useRef(null); + const isOpenRef = useRef(false); + const scrollRef = useRef(null); + const rowYRef = useRef>({}); + const listYRef = useRef(0); + const scrollTimerRef = useRef | null>(null); + const [highlightedIndex, setHighlightedIndex] = useState(null); + const [expandedIndex, setExpandedIndex] = useState(null); + const [contentHeight, setContentHeight] = useState(0); + const [payload, setPayload] = useState({ + sources: [], + }); + const { sources, userQuestion } = payload; - const clearScrollTimer = useCallback(() => { - if (scrollTimerRef.current) clearTimeout(scrollTimerRef.current); - scrollTimerRef.current = null; - }, []); + const clearScrollTimer = useCallback(() => { + if (scrollTimerRef.current) clearTimeout(scrollTimerRef.current); + scrollTimerRef.current = null; + }, []); - useEffect(() => clearScrollTimer, [clearScrollTimer]); + useEffect(() => clearScrollTimer, [clearScrollTimer]); - useImperativeHandle( - ref, - () => ({ - present: (highlightIndex: number | null = null) => { - if (isOpenRef.current) return; - isOpenRef.current = true; - setHighlightedIndex(highlightIndex); - setExpandedIndex(highlightIndex); - sheetRef.current?.present(); - }, - }), - [] - ); + useImperativeHandle( + ref, + () => ({ + present: ( + nextSources: SourceDocument[], + nextUserQuestion?: string, + highlightIndex: number | null = null + ) => { + if (isOpenRef.current) return; + isOpenRef.current = true; + setPayload({ sources: nextSources, userQuestion: nextUserQuestion }); + setHighlightedIndex(highlightIndex); + setExpandedIndex(highlightIndex); + sheetRef.current?.present(); + }, + }), + [] + ); - const renderBackdrop = useCallback( - (props: BottomSheetBackdropProps) => , - [] - ); + const renderBackdrop = useCallback( + (props: BottomSheetBackdropProps) => , + [] + ); - const toggleExpanded = useCallback( - (index: number) => { - const willExpand = expandedIndex !== index; - setExpandedIndex(willExpand ? index : null); - clearScrollTimer(); - if (!willExpand) return; + const toggleExpanded = useCallback( + (index: number) => { + const willExpand = expandedIndex !== index; + setExpandedIndex(willExpand ? index : null); + clearScrollTimer(); + if (!willExpand) return; - scrollTimerRef.current = setTimeout(() => { - const y = listYRef.current + (rowYRef.current[index] ?? 0); - scrollRef.current?.scrollTo({ - y: Math.max(space.none, y - space.two), - animated: true, - }); - }, ROW_EXPAND_SCROLL_DELAY); - }, - [expandedIndex, clearScrollTimer] - ); + scrollTimerRef.current = setTimeout(() => { + const y = listYRef.current + (rowYRef.current[index] ?? 0); + scrollRef.current?.scrollTo({ + y: Math.max(space.none, y - space.two), + animated: true, + }); + }, ROW_EXPAND_SCROLL_DELAY); + }, + [expandedIndex, clearScrollTimer] + ); - const anyNamedSource = useMemo( - () => - sources.some((source) => - queryNamesDocument(userQuestion ?? '', source.name) - ), - [sources, userQuestion] - ); + const anyNamedSource = useMemo( + () => + sources.some((source) => + queryNamesDocument(userQuestion ?? '', source.name) + ), + [sources, userQuestion] + ); - const expandedExcerpt = useMemo(() => { - if (expandedIndex === null) return null; - const source = sources[expandedIndex]; - const passage = source?.passage; - const suppressHighlight = - anyNamedSource && - !queryNamesDocument(userQuestion ?? '', source?.name ?? ''); - const span = suppressHighlight - ? null - : findCitedSpan(passage, userQuestion ?? ''); - return buildCitationExcerpt(passage, span); - }, [expandedIndex, sources, userQuestion, anyNamedSource]); + const expandedExcerpt = useMemo(() => { + if (expandedIndex === null) return null; + const source = sources[expandedIndex]; + const passage = source?.passage; + const suppressHighlight = + anyNamedSource && + !queryNamesDocument(userQuestion ?? '', source?.name ?? ''); + const span = suppressHighlight + ? null + : findCitedSpan(passage, userQuestion ?? ''); + return buildCitationExcerpt(passage, span); + }, [expandedIndex, sources, userQuestion, anyNamedSource]); - const onContentSizeChange = useCallback((width: number, height: number) => { - const next = Math.round(height); - setContentHeight((prev) => (Math.abs(prev - next) > 1 ? next : prev)); - }, []); + const onContentSizeChange = useCallback((width: number, height: number) => { + const next = Math.round(height); + setContentHeight((prev) => (Math.abs(prev - next) > 1 ? next : prev)); + }, []); - const snapPoints = useMemo(() => { - const seed = - EST_SHEET_CHROME + - theme.insets.bottom + - sources.length * EST_ROW_HEIGHT + - Math.max(0, sources.length - 1) * EST_ROW_GAP; - const target = contentHeight ? contentHeight + SHEET_HANDLE_HEIGHT : seed; - return [Math.min(target, screenHeight * MAX_SHEET_HEIGHT_RATIO)]; - }, [contentHeight, sources.length, theme.insets.bottom, screenHeight]); + const snapPoints = useMemo(() => { + const seed = + EST_SHEET_CHROME + + theme.insets.bottom + + sources.length * EST_ROW_HEIGHT + + Math.max(0, sources.length - 1) * EST_ROW_GAP; + const target = contentHeight ? contentHeight + SHEET_HANDLE_HEIGHT : seed; + return [Math.min(target, screenHeight * MAX_SHEET_HEIGHT_RATIO)]; + }, [contentHeight, sources.length, theme.insets.bottom, screenHeight]); - const animationConfigs = useBottomSheetSpringConfigs(SHEET_SPRING_CONFIG); + const animationConfigs = useBottomSheetSpringConfigs(SHEET_SPRING_CONFIG); - return ( - { - isOpenRef.current = index >= 0; - }} - onDismiss={() => { - isOpenRef.current = false; - clearScrollTimer(); - setHighlightedIndex(null); - setExpandedIndex(null); - }} + return ( + { + isOpenRef.current = index >= 0; + }} + onDismiss={() => { + isOpenRef.current = false; + clearScrollTimer(); + setHighlightedIndex(null); + setExpandedIndex(null); + }} + > + - Sources + { + listYRef.current = e.nativeEvent.layout.y; + }} > - Sources - { - listYRef.current = e.nativeEvent.layout.y; - }} - > - {sources.map((source, index) => ( - toggleExpanded(index)} - onLayout={(e) => { - rowYRef.current[index] = e.nativeEvent.layout.y; - }} - /> - ))} - - - - ); - } -); + {sources.map((source, index) => ( + toggleExpanded(index)} + onLayout={(e) => { + rowYRef.current[index] = e.nativeEvent.layout.y; + }} + /> + ))} + + + + ); +}); SourcesSheet.displayName = 'SourcesSheet'; -export default SourcesSheet; +export default memo(SourcesSheet); const createStyles = (theme: Theme) => StyleSheet.create({ From 92e3c987b4dbc93492a8af2e71001fb6fd7bcb7b Mon Sep 17 00:00:00 2001 From: Krzysztof Faracik Date: Tue, 21 Jul 2026 13:33:35 +0200 Subject: [PATCH 33/42] fix(context): give shipped model families a real context window CONTEXT_WINDOW_TOKENS_BY_FAMILY was an empty map, so every model fell through to the 2048 default and the prompt budget was a flat 4608 chars regardless of the model. That truncates retrieved context far earlier than any shipped model requires. The numbers are deliberately conservative rather than the upstream ones: the window is baked into the ExecuTorch export, not the base model, and nothing exposes it at runtime, so overshooting would overflow. Unknown and imported models keep the old default. --- constants/context-window.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/constants/context-window.ts b/constants/context-window.ts index 8d40ba8a..717eaea8 100644 --- a/constants/context-window.ts +++ b/constants/context-window.ts @@ -6,7 +6,17 @@ const DEFAULT_CONTEXT_WINDOW_TOKENS = 2048; const GENERATION_RESERVE_TOKENS = 512; -const CONTEXT_WINDOW_TOKENS_BY_FAMILY: Record = {}; +// TODO: replace with each export's real window. Every family here is ≥32k +// upstream, but the ExecuTorch .pte bakes in a smaller one, so these are +// conservative floors. Unknown/imported models keep the 2048 default. +const CONTEXT_WINDOW_TOKENS_BY_FAMILY: Record = { + 'Qwen 3': 4096, + 'Qwen 2.5': 4096, + 'LLaMA 3.2': 4096, + 'LFM 2.5': 4096, + 'Bielik': 4096, + 'Gemma 4': 4096, +}; export const getContextWindowTokens = (model: Model): number => (model.family ? CONTEXT_WINDOW_TOKENS_BY_FAMILY[model.family] : undefined) ?? From a383da7d82b656d4467b4eafe723295ca6eea15b Mon Sep 17 00:00:00 2001 From: Krzysztof Faracik Date: Tue, 21 Jul 2026 13:33:35 +0200 Subject: [PATCH 34/42] fix(rag): keep the embedding-model key unset when the wipe is partial clearImportedSources swallowed a failing DELETE, and the caller stored the new model id anyway. The vectors were already gone at that point, so a partial failure left source rows pointing at nothing and, because the key had advanced, no later launch would retry. It now reports whether the wipe completed and the key is only written on success. Both delete paths are idempotent, so the retry is safe. --- __tests__/embeddingModelMigration.test.ts | 36 +++++++++++++++++++++++ utils/embeddingModelMigration.ts | 12 +++++--- 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/__tests__/embeddingModelMigration.test.ts b/__tests__/embeddingModelMigration.test.ts index 135977d9..46e68383 100644 --- a/__tests__/embeddingModelMigration.test.ts +++ b/__tests__/embeddingModelMigration.test.ts @@ -139,4 +139,40 @@ describe('migrateEmbeddingModelIfNeeded', () => { 'model-new' ); }); + + it('leaves the key unset when clearing source metadata fails, so the next launch retries', async () => { + await AsyncStorage.setItem(ACTIVE_EMBEDDING_MODEL_KEY, 'model-old'); + const { store, deleteVectorStore } = makeVectorStore(CURRENT_DIM); + const { db, runAsync } = makeDb(); + runAsync.mockRejectedValueOnce(new Error('database is locked')); + + const migrated = await migrateEmbeddingModelIfNeeded( + store, + db, + 'model-new', + CURRENT_DIM + ); + + expect(migrated).toBe(true); + expect(deleteVectorStore).toHaveBeenCalledTimes(1); + expect(await AsyncStorage.getItem(ACTIVE_EMBEDDING_MODEL_KEY)).toBe( + 'model-old' + ); + }); + + it('leaves a lost key unset when a dimension-incompatible wipe fails', async () => { + const { store } = makeVectorStore(384); + const { db, runAsync } = makeDb(); + runAsync.mockRejectedValueOnce(new Error('database is locked')); + + const migrated = await migrateEmbeddingModelIfNeeded( + store, + db, + 'model-new', + CURRENT_DIM + ); + + expect(migrated).toBe(true); + expect(await AsyncStorage.getItem(ACTIVE_EMBEDDING_MODEL_KEY)).toBeNull(); + }); }); diff --git a/utils/embeddingModelMigration.ts b/utils/embeddingModelMigration.ts index ed370421..27f72f32 100644 --- a/utils/embeddingModelMigration.ts +++ b/utils/embeddingModelMigration.ts @@ -21,16 +21,18 @@ const readPersistedVectorDim = async ( const clearImportedSources = async ( vectorStore: OPSQLiteVectorStore, db: SQLiteDatabase -): Promise => { +): Promise => { await vectorStore.deleteVectorStore(); try { await db.runAsync(`DELETE FROM chatSources`); await db.runAsync(`DELETE FROM sources`); + return true; } catch (error) { console.warn( 'Failed to clear source metadata during embedding migration', error ); + return false; } }; @@ -48,14 +50,16 @@ export const migrateEmbeddingModelIfNeeded = async ( const persistedDim = await readPersistedVectorDim(vectorStore); const incompatible = persistedDim !== null && persistedDim !== currentModelDim; - if (incompatible) { - await clearImportedSources(vectorStore, db); + // Leave the key unset when the wipe was partial so the next launch retries; + // deleting again is idempotent. + if (incompatible && !(await clearImportedSources(vectorStore, db))) { + return true; } await AsyncStorage.setItem(ACTIVE_EMBEDDING_MODEL_KEY, currentModelId); return incompatible; } - await clearImportedSources(vectorStore, db); + if (!(await clearImportedSources(vectorStore, db))) return true; await AsyncStorage.setItem(ACTIVE_EMBEDDING_MODEL_KEY, currentModelId); return true; }; From 9519d4da180c05bd392f996892e0ef80353be70b Mon Sep 17 00:00:00 2001 From: Krzysztof Faracik Date: Tue, 21 Jul 2026 13:33:49 +0200 Subject: [PATCH 35/42] fix(citations): stop citing sources for facts the answer negates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Citation overlap counted every term in the reply, so "the report does not mention revenue" shared "revenue" with the revenue passage and cited it — as support for the opposite of what the reply said. looksLikeNoAnswer only catches whole-reply refusals, not a negated clause inside an answer. Terms are now taken from the asserted clauses only, so "covers X but does not mention Y" still cites X. English cues only; the Polish side of refusal detection is handled separately on feat/rag-hybrid. --- __tests__/messageSources.test.ts | 33 ++++++++++++++++++++++++++++++++ constants/citations.ts | 9 +++++++++ utils/messageSources.ts | 18 ++++++++++++++++- 3 files changed, 59 insertions(+), 1 deletion(-) diff --git a/__tests__/messageSources.test.ts b/__tests__/messageSources.test.ts index bc94c043..b0c085f8 100644 --- a/__tests__/messageSources.test.ts +++ b/__tests__/messageSources.test.ts @@ -183,6 +183,39 @@ describe('pickCitationsByAnswer', () => { expect(result.map((d) => d.documentId)).toEqual([24]); }); + it('does not cite a source for a fact the answer says it lacks', () => { + const cited = [ + withPassage( + 1, + 'revenue.txt', + 'Total revenue grew to five million dollars.' + ), + withPassage(2, 'headcount.txt', 'The company hired forty new engineers.'), + ]; + const answer = 'The report does not mention revenue.'; + + const result = pickCitationsByAnswer(cited, answer, []); + + expect(result).toEqual([]); + }); + + it('keeps the asserted half of a sentence and drops the negated half', () => { + const cited = [ + withPassage( + 1, + 'revenue.txt', + 'Total revenue grew to five million dollars.' + ), + withPassage(2, 'headcount.txt', 'The company hired forty new engineers.'), + ]; + const answer = + 'The company hired forty engineers, but the report does not mention total revenue or the five million dollars figure.'; + + const result = pickCitationsByAnswer(cited, answer, []); + + expect(result.map((d) => d.documentId)).toEqual([2]); + }); + it('keeps both sources when the answer draws on each', () => { const cited = [ withPassage( diff --git a/constants/citations.ts b/constants/citations.ts index 9e965af9..cb78de36 100644 --- a/constants/citations.ts +++ b/constants/citations.ts @@ -8,6 +8,15 @@ export const CITATION_DOCUMENT_NAME_TOKEN_PATTERN = /[^a-z0-9ąćęłńóśźż] export const THINK_OPEN = ''; export const THINK_CLOSE = ''; +// English negation cues. Terms inside a negated clause say what a source does NOT +// support, so they must not count as overlap evidence for citing it. +export const NEGATION_CUE_EN = + /\b(no|not|n['’]t|never|none|neither|nor|without|lacks?|lacking)\b/i; + +// Clause boundaries, so "covers X but does not mention Y" keeps X and drops only Y. +export const CLAUSE_SPLIT_PATTERN = + /[,;]|\b(?:but|however|although|though|whereas|while)\b/i; + // Coverage nouns (does a source address the topic); a refusal negates one, a negative-fact answer does not. const NO_ANSWER_META_EN = 'information|info|mention|reference|data|details?|indication|records?'; diff --git a/utils/messageSources.ts b/utils/messageSources.ts index ae82816b..dd389403 100644 --- a/utils/messageSources.ts +++ b/utils/messageSources.ts @@ -12,6 +12,9 @@ import { retrieve } from './retrieval'; import { extractQueryTerms, stemPrefix } from './queryTerms'; import { ANSWER_CITATION_OVERLAP_RATIO } from '../constants/retrieval'; import { + CITATION_SENTENCE_PATTERN, + CLAUSE_SPLIT_PATTERN, + NEGATION_CUE_EN, NO_ANSWER_PATTERNS_EN, THINK_CLOSE, THINK_OPEN, @@ -119,8 +122,21 @@ export const visibleAnswer = (answer: string): string => { return `${answer.slice(0, open)} ${after}`; }; +// Keep only the clauses the reply actually asserts; a negated clause names a topic +// the source does not cover, and scoring it as overlap cites the source for the +// opposite of what it says. English-only for now. +const affirmativeAnswer = (visibleReply: string): string => + (visibleReply.match(CITATION_SENTENCE_PATTERN) ?? [visibleReply]) + .flatMap((sentence) => sentence.split(CLAUSE_SPLIT_PATTERN)) + .filter((clause) => clause && !NEGATION_CUE_EN.test(clause)) + .join(' '); + const answerTermsOf = (answer: string): Set => - new Set([...extractQueryTerms(visibleAnswer(answer))].map(stemPrefix)); + new Set( + [...extractQueryTerms(affirmativeAnswer(visibleAnswer(answer)))].map( + stemPrefix + ) + ); // True when the visible reply is an EN/PL "no information" refusal (negation tied to a coverage noun). export const looksLikeNoAnswer = (visibleReply: string): boolean => From ff23ebcb9e97a3b1a4c5b12d0849ca72743b6bca Mon Sep 17 00:00:00 2001 From: Krzysztof Faracik Date: Tue, 21 Jul 2026 13:33:49 +0200 Subject: [PATCH 36/42] fix(rag): emit expanded chunks in document order Neighbor windows were emitted in seed-similarity order, so one document could reach the model as chunks 9,10,11 followed by 2,3,4. The passage stitcher also assumes adjacency when it dedups overlapping text. Relevance still decides which chunks are selected and how documents are ranked against each other; only the order within a document changes. Note: feat/rag-hybrid replaces this file with utils/hybridRetrieval.ts, which carries a verbatim copy of expandSelectedWithNeighbors. That copy needs the same change or this fix disappears when the branch lands. --- __tests__/retrieval.test.ts | 63 +++++++++++++++++++++++++++++++++++++ utils/retrieval.ts | 7 +++++ 2 files changed, 70 insertions(+) create mode 100644 __tests__/retrieval.test.ts diff --git a/__tests__/retrieval.test.ts b/__tests__/retrieval.test.ts new file mode 100644 index 00000000..ca0e2f94 --- /dev/null +++ b/__tests__/retrieval.test.ts @@ -0,0 +1,63 @@ +import { retrieve } from '../utils/retrieval'; +import type { OPSQLiteVectorStore } from '@react-native-rag/op-sqlite'; + +const embeddingBuffer = () => new Float32Array([1, 0]).buffer; + +const seed = (chunkIndex: number, similarity: number) => ({ + id: `1:${chunkIndex}`, + document: `chunk-${chunkIndex}`, + embedding: [1, 0], + similarity, + metadata: { documentId: 1, name: 'report.txt' }, +}); + +const neighborRow = (chunkIndex: number) => ({ + id: `1:${chunkIndex}`, + document: `chunk-${chunkIndex}`, + embedding: embeddingBuffer(), + metadata: JSON.stringify({ documentId: 1, name: 'report.txt' }), +}); + +const makeVectorStore = ( + queryResults: unknown[], + rowsById: Record +) => + ({ + query: jest.fn().mockResolvedValue(queryResults), + db: { + execute: jest + .fn() + .mockImplementation(async (_sql: string, ids: string[]) => ({ + rows: ids.map((id) => rowsById[id]).filter(Boolean), + })), + }, + }) as unknown as OPSQLiteVectorStore; + +describe('retrieve — neighbor expansion', () => { + it('emits one document in reading order even when a later passage ranks higher', async () => { + // Seed 1:10 outranks seed 1:2, so seed order alone would emit 9,10,11,1,2,3. + const vectorStore = makeVectorStore([seed(10, 0.9), seed(2, 0.5)], { + '1:9': neighborRow(9), + '1:11': neighborRow(11), + '1:1': neighborRow(1), + '1:3': neighborRow(3), + }); + + const result = await retrieve({ + prompt: 'anything', + enabledSourceIds: [1], + vectorStore, + sourceNamesById: new Map([[1, 'report.txt']]), + embeddings: null, + }); + + expect(result.map((chunk) => chunk.document)).toEqual([ + 'chunk-1', + 'chunk-2', + 'chunk-3', + 'chunk-9', + 'chunk-10', + 'chunk-11', + ]); + }); +}); diff --git a/utils/retrieval.ts b/utils/retrieval.ts index 93442020..78b52b2a 100644 --- a/utils/retrieval.ts +++ b/utils/retrieval.ts @@ -175,6 +175,13 @@ const expandSelectedWithNeighbors = async ( } } + // Seeds are ranked by similarity, so emitting their windows in that order + // hands the model one document's prose out of sequence (9,10,11 then 2,3,4). + // Relevance already decided which chunks got in; read them in document order. + orderedIds.sort( + (a, b) => (group.indices.get(a) ?? 0) - (group.indices.get(b) ?? 0) + ); + for (const id of orderedIds) { const info = chunkById.get(id)!; result.push({ From de42e2d004a216cc6638bac8d40c5dacdca50aa9 Mon Sep 17 00:00:00 2001 From: Krzysztof Faracik Date: Tue, 21 Jul 2026 13:33:58 +0200 Subject: [PATCH 37/42] fix(attachments): sweep sources abandoned without sending MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A document is embedded as a source the moment it is attached, but it is only tied to a chat on send, so abandoning one left the source behind forever. cleanupOrphanedSources existed but was unreachable: 822bf9b flipped the cleanupSources default to false, both remaining callers pass false explicitly, and the only caller relying on the default sits in a ChatBar handle nothing invokes. Cleanup now runs where a source is actually abandoned — removing an embedded attachment, and unmounting with one still in the composer. The send paths keep passing false, since enableSource is about to link the source to the chat. The handle's call is explicit now too, so no caller depends on the default. --- __tests__/useAttachment.test.ts | 83 ++++++++++++++++++++++++++++-- components/chat-screen/ChatBar.tsx | 2 +- hooks/useAttachment.ts | 40 ++++++++++---- 3 files changed, 110 insertions(+), 15 deletions(-) diff --git a/__tests__/useAttachment.test.ts b/__tests__/useAttachment.test.ts index dfc25e81..a08b2898 100644 --- a/__tests__/useAttachment.test.ts +++ b/__tests__/useAttachment.test.ts @@ -7,10 +7,12 @@ jest.mock('react-native-image-picker', () => ({ jest.mock('expo-document-picker', () => ({ getDocumentAsync: jest.fn(), })); +const mockCleanupOrphanedSources = jest.fn(); jest.mock('../store/sourceStore', () => ({ useSourceStore: { getState: jest.fn(() => ({ addSource: jest.fn(), + cleanupOrphanedSources: mockCleanupOrphanedSources, })), }, })); @@ -98,7 +100,10 @@ describe('useAttachment', () => { .fn() .mockResolvedValue({ success: true, sourceId: 42 }); const { useSourceStore } = require('../store/sourceStore'); - useSourceStore.getState.mockReturnValue({ addSource: mockAddSource }); + useSourceStore.getState.mockReturnValue({ + addSource: mockAddSource, + cleanupOrphanedSources: mockCleanupOrphanedSources, + }); const { result } = renderHook(() => useAttachment()); await act(async () => { @@ -112,6 +117,72 @@ describe('useAttachment', () => { expect(att.status).toBe('ready'); }); + describe('abandoned source cleanup', () => { + const pickReadyDocument = async () => { + mockGetDocumentAsync.mockResolvedValue({ + canceled: false, + assets: [{ uri: 'file://doc.txt', name: 'doc.txt', size: 100 }], + }); + const { useSourceStore } = require('../store/sourceStore'); + useSourceStore.getState.mockReturnValue({ + addSource: jest.fn().mockResolvedValue({ success: true, sourceId: 42 }), + cleanupOrphanedSources: mockCleanupOrphanedSources, + }); + + const view = renderHook(() => useAttachment()); + await act(async () => { + await view.result.current.pickDocument(); + }); + mockCleanupOrphanedSources.mockClear(); + return view; + }; + + it('sweeps when an embedded document is removed before it is ever sent', async () => { + const { result } = await pickReadyDocument(); + + act(() => { + result.current.removeAttachment(result.current.attachments[0].id); + }); + + expect(mockCleanupOrphanedSources).toHaveBeenCalledTimes(1); + }); + + it('sweeps when the screen unmounts with the document still attached', async () => { + const { unmount } = await pickReadyDocument(); + + unmount(); + + expect(mockCleanupOrphanedSources).toHaveBeenCalledTimes(1); + }); + + it('does not sweep on send, when the source is about to be linked to the chat', async () => { + const { result } = await pickReadyDocument(); + + act(() => { + result.current.clearAll({ cleanupSources: false }); + }); + + expect(mockCleanupOrphanedSources).not.toHaveBeenCalled(); + }); + + it('does not sweep when a plain image is removed', async () => { + mockLaunchImageLibrary.mockResolvedValue({ + assets: [{ uri: 'file://photo.jpg' }], + }); + const { result } = renderHook(() => useAttachment()); + await act(async () => { + await result.current.pickFromLibrary(); + }); + mockCleanupOrphanedSources.mockClear(); + + act(() => { + result.current.removeAttachment(result.current.attachments[0].id); + }); + + expect(mockCleanupOrphanedSources).not.toHaveBeenCalled(); + }); + }); + it('ignores a stale document result when a second document replaces it', async () => { const firstSource = createDeferred<{ success: boolean; @@ -126,7 +197,10 @@ describe('useAttachment', () => { .mockReturnValueOnce(firstSource.promise) .mockReturnValueOnce(secondSource.promise); const { useSourceStore } = require('../store/sourceStore'); - useSourceStore.getState.mockReturnValue({ addSource: mockAddSource }); + useSourceStore.getState.mockReturnValue({ + addSource: mockAddSource, + cleanupOrphanedSources: mockCleanupOrphanedSources, + }); mockGetDocumentAsync .mockResolvedValueOnce({ canceled: false, @@ -241,7 +315,10 @@ describe('useAttachment', () => { .fn() .mockResolvedValue({ success: true, sourceId: 7 }); const { useSourceStore } = require('../store/sourceStore'); - useSourceStore.getState.mockReturnValue({ addSource: mockAddSource }); + useSourceStore.getState.mockReturnValue({ + addSource: mockAddSource, + cleanupOrphanedSources: mockCleanupOrphanedSources, + }); const { result } = renderHook(() => useAttachment()); await act(async () => { diff --git a/components/chat-screen/ChatBar.tsx b/components/chat-screen/ChatBar.tsx index 1542388d..61ab55ce 100644 --- a/components/chat-screen/ChatBar.tsx +++ b/components/chat-screen/ChatBar.tsx @@ -122,7 +122,7 @@ const ChatBar = ({ () => ({ clear: () => { setUserInput(''); - clearAll(); + clearAll({ cleanupSources: true }); extraContentPadding.set(0); if (Platform.OS === 'ios') { textInputRef.current?.setNativeProps({ text: ' ' }); diff --git a/hooks/useAttachment.ts b/hooks/useAttachment.ts index 09347997..5ff36184 100644 --- a/hooks/useAttachment.ts +++ b/hooks/useAttachment.ts @@ -71,12 +71,25 @@ export const useAttachment = () => { const embeddingDownloadSheetRef = useRef(null); const embeddingDownloadSheetOpenRef = useRef(false); const { vectorStore, embeddings } = useVectorStore(); + const vectorStoreRef = useRef(vectorStore); + vectorStoreRef.current = vectorStore; + + // A document is embedded as a source the moment it is attached, but it is only + // tied to a chat on send. Sweep whenever one is abandoned instead, or it stays + // in the store forever. cleanupOrphanedSources only removes unreferenced rows. + const sweepAbandonedSources = useCallback(() => { + const store = vectorStoreRef.current; + if (store) useSourceStore.getState().cleanupOrphanedSources(store); + }, []); useEffect(() => { return () => { embeddingDownloadSheetOpenRef.current = false; + if (attachmentsRef.current.some((a) => a.sourceId)) { + sweepAbandonedSources(); + } }; - }, []); + }, [sweepAbandonedSources]); const replaceWithImage = useCallback((uri: string) => { currentDocumentAttachmentIdRef.current = null; @@ -277,13 +290,18 @@ export const useAttachment = () => { } }, [vectorStore, runDocumentPicker]); - const removeAttachment = useCallback((id: string) => { - if (currentDocumentAttachmentIdRef.current === id) { - currentDocumentAttachmentIdRef.current = null; - documentAbortRef.current?.abort(); - } - setAttachments((prev) => prev.filter((a) => a.id !== id)); - }, []); + const removeAttachment = useCallback( + (id: string) => { + if (currentDocumentAttachmentIdRef.current === id) { + currentDocumentAttachmentIdRef.current = null; + documentAbortRef.current?.abort(); + } + const removed = attachmentsRef.current.find((a) => a.id === id); + setAttachments((prev) => prev.filter((a) => a.id !== id)); + if (removed?.sourceId) sweepAbandonedSources(); + }, + [sweepAbandonedSources] + ); const clearAll = useCallback( (options: ClearAllOptions = {}) => { @@ -292,11 +310,11 @@ export const useAttachment = () => { currentDocumentAttachmentIdRef.current = null; documentAbortRef.current?.abort(); setAttachments([]); - if (cleanupSources && hadDocuments && vectorStore) { - useSourceStore.getState().cleanupOrphanedSources(vectorStore); + if (cleanupSources && hadDocuments) { + sweepAbandonedSources(); } }, - [vectorStore] + [sweepAbandonedSources] ); const openSheet = useCallback(() => { From c1135560b64ae3ec7d090b3537e6d1caa4fcdf77 Mon Sep 17 00:00:00 2001 From: Krzysztof Faracik Date: Tue, 21 Jul 2026 13:38:56 +0200 Subject: [PATCH 38/42] style: restore the prettier 3.9.4 formatting of the status union The merge resolution reformatted this union with a locally stale prettier 3.8.3, undoing 08f66da and failing lint on CI, which installs the 3.9.4 from the lockfile. Restores the single-line form 3.9.4 produces. --- store/embeddingModelStore.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/store/embeddingModelStore.ts b/store/embeddingModelStore.ts index 2adc692f..b3f5cc09 100644 --- a/store/embeddingModelStore.ts +++ b/store/embeddingModelStore.ts @@ -2,11 +2,7 @@ import { create } from 'zustand'; import { OPSQLiteVectorStore } from '@react-native-rag/op-sqlite'; export type EmbeddingModelStatus = - | 'unknown' - | 'not_downloaded' - | 'downloading' - | 'ready' - | 'error'; + 'unknown' | 'not_downloaded' | 'downloading' | 'ready' | 'error'; type EmbeddingModelStore = { status: EmbeddingModelStatus; From 4a432165343cabded7d0def5810d2f34141abfb2 Mon Sep 17 00:00:00 2001 From: Krzysztof Faracik Date: Tue, 21 Jul 2026 13:49:24 +0200 Subject: [PATCH 39/42] fix(citations): strip every think block, not just the first visibleAnswer cut at the first and resumed after the first , so a second reasoning block landed in the text treated as the visible reply. Citation scoring runs on that text, which meant hidden reasoning could decide which sources got cited. An unterminated block still ends the visible reply, since the model is mid-thought and nothing after it has been said yet. --- __tests__/messageSources.test.ts | 8 ++++++++ utils/messageSources.ts | 20 +++++++++++++++----- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/__tests__/messageSources.test.ts b/__tests__/messageSources.test.ts index b0c085f8..a85dc3ae 100644 --- a/__tests__/messageSources.test.ts +++ b/__tests__/messageSources.test.ts @@ -330,6 +330,14 @@ describe('visibleAnswer', () => { it('returns the text unchanged when there is no think block', () => { expect(visibleAnswer('plain answer')).toBe('plain answer'); }); + + it('drops every think block, not just the first', () => { + expect( + visibleAnswer( + 'onehiddentwoalso hiddenthree' + ) + ).toBe('one two three'); + }); }); describe('looksLikeNoAnswer', () => { diff --git a/utils/messageSources.ts b/utils/messageSources.ts index dd389403..1267af58 100644 --- a/utils/messageSources.ts +++ b/utils/messageSources.ts @@ -115,11 +115,21 @@ const overlapWithAnswer = ( // Attribute against the visible reply only; the block surveys every source and inflates overlap. export const visibleAnswer = (answer: string): string => { - const open = answer.indexOf(THINK_OPEN); - if (open === -1) return answer; - const close = answer.indexOf(THINK_CLOSE); - const after = close === -1 ? '' : answer.slice(close + THINK_CLOSE.length); - return `${answer.slice(0, open)} ${after}`; + const parts: string[] = []; + let cursor = 0; + let open = answer.indexOf(THINK_OPEN); + + while (open !== -1) { + parts.push(answer.slice(cursor, open)); + const close = answer.indexOf(THINK_CLOSE, open + THINK_OPEN.length); + // Unterminated: the model is still reasoning, so nothing after it is visible. + if (close === -1) return `${parts.join(' ')} `; + cursor = close + THINK_CLOSE.length; + open = answer.indexOf(THINK_OPEN, cursor); + } + + parts.push(answer.slice(cursor)); + return parts.join(' '); }; // Keep only the clauses the reply actually asserts; a negated clause names a topic From 2a9ae999c18a2770336cdc36a56ea8ac5178ef7d Mon Sep 17 00:00:00 2001 From: Krzysztof Faracik Date: Tue, 21 Jul 2026 13:49:24 +0200 Subject: [PATCH 40/42] chore: drop dead estimateTokens and the unused ChatBar clear handle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit estimateTokens had no callers. The imperative clear() had none either — it was the only caller left relying on clearAll's cleanupSources default, which is why abandoned sources looked like they were being swept when nothing invoked the path. setInput stays; it is used for prompt suggestions. --- components/chat-screen/ChatBar.tsx | 12 +----------- components/chat-screen/ChatScreen.tsx | 1 - constants/context-window.ts | 3 --- 3 files changed, 1 insertion(+), 15 deletions(-) diff --git a/components/chat-screen/ChatBar.tsx b/components/chat-screen/ChatBar.tsx index 61ab55ce..4c70e4c4 100644 --- a/components/chat-screen/ChatBar.tsx +++ b/components/chat-screen/ChatBar.tsx @@ -54,7 +54,6 @@ interface Props { onSelectModel: () => void; onSelectPrompt: (prompt: string) => void; ref: Ref<{ - clear: () => void; setInput: (text: string) => void; }>; model: Model | undefined; @@ -120,15 +119,6 @@ const ChatBar = ({ useImperativeHandle( ref, () => ({ - clear: () => { - setUserInput(''); - clearAll({ cleanupSources: true }); - extraContentPadding.set(0); - if (Platform.OS === 'ios') { - textInputRef.current?.setNativeProps({ text: ' ' }); - textInputRef.current?.setNativeProps({ text: '' }); - } - }, setInput: (text: string) => { setUserInput(text); if (Platform.OS === 'ios') { @@ -136,7 +126,7 @@ const ChatBar = ({ } }, }), - [clearAll, extraContentPadding] + [] ); const handleBarLayoutForPadding = useCallback( diff --git a/components/chat-screen/ChatScreen.tsx b/components/chat-screen/ChatScreen.tsx index 17d45905..dd088e3c 100644 --- a/components/chat-screen/ChatScreen.tsx +++ b/components/chat-screen/ChatScreen.tsx @@ -64,7 +64,6 @@ export default function ChatScreen({ openModelSheetRef, }: Props) { const inputRef = useRef<{ - clear: () => void; setInput: (text: string) => void; }>(null); const messagesRef = useRef(null); diff --git a/constants/context-window.ts b/constants/context-window.ts index 717eaea8..305921d0 100644 --- a/constants/context-window.ts +++ b/constants/context-window.ts @@ -22,9 +22,6 @@ export const getContextWindowTokens = (model: Model): number => (model.family ? CONTEXT_WINDOW_TOKENS_BY_FAMILY[model.family] : undefined) ?? DEFAULT_CONTEXT_WINDOW_TOKENS; -export const estimateTokens = (text: string): number => - Math.ceil(text.length / CHARS_PER_TOKEN); - export const getPromptCharBudget = (model: Model): number => { const promptTokenBudget = Math.max( 0, From ba68d599de95c17f989e2d35dc1e9c4e1304c5d7 Mon Sep 17 00:00:00 2001 From: Krzysztof Faracik Date: Tue, 21 Jul 2026 14:36:12 +0200 Subject: [PATCH 41/42] style: drop the explanatory comments added during review --- __tests__/retrieval.test.ts | 1 - constants/citations.ts | 3 --- hooks/useAttachment.ts | 3 --- utils/embeddingModelMigration.ts | 2 -- utils/messageSources.ts | 4 ---- utils/retrieval.ts | 3 --- 6 files changed, 16 deletions(-) diff --git a/__tests__/retrieval.test.ts b/__tests__/retrieval.test.ts index ca0e2f94..a67704b0 100644 --- a/__tests__/retrieval.test.ts +++ b/__tests__/retrieval.test.ts @@ -35,7 +35,6 @@ const makeVectorStore = ( describe('retrieve — neighbor expansion', () => { it('emits one document in reading order even when a later passage ranks higher', async () => { - // Seed 1:10 outranks seed 1:2, so seed order alone would emit 9,10,11,1,2,3. const vectorStore = makeVectorStore([seed(10, 0.9), seed(2, 0.5)], { '1:9': neighborRow(9), '1:11': neighborRow(11), diff --git a/constants/citations.ts b/constants/citations.ts index cb78de36..a38f2bae 100644 --- a/constants/citations.ts +++ b/constants/citations.ts @@ -8,12 +8,9 @@ export const CITATION_DOCUMENT_NAME_TOKEN_PATTERN = /[^a-z0-9ąćęłńóśźż] export const THINK_OPEN = ''; export const THINK_CLOSE = ''; -// English negation cues. Terms inside a negated clause say what a source does NOT -// support, so they must not count as overlap evidence for citing it. export const NEGATION_CUE_EN = /\b(no|not|n['’]t|never|none|neither|nor|without|lacks?|lacking)\b/i; -// Clause boundaries, so "covers X but does not mention Y" keeps X and drops only Y. export const CLAUSE_SPLIT_PATTERN = /[,;]|\b(?:but|however|although|though|whereas|while)\b/i; diff --git a/hooks/useAttachment.ts b/hooks/useAttachment.ts index 5ff36184..54de75ab 100644 --- a/hooks/useAttachment.ts +++ b/hooks/useAttachment.ts @@ -74,9 +74,6 @@ export const useAttachment = () => { const vectorStoreRef = useRef(vectorStore); vectorStoreRef.current = vectorStore; - // A document is embedded as a source the moment it is attached, but it is only - // tied to a chat on send. Sweep whenever one is abandoned instead, or it stays - // in the store forever. cleanupOrphanedSources only removes unreferenced rows. const sweepAbandonedSources = useCallback(() => { const store = vectorStoreRef.current; if (store) useSourceStore.getState().cleanupOrphanedSources(store); diff --git a/utils/embeddingModelMigration.ts b/utils/embeddingModelMigration.ts index 27f72f32..8edaa952 100644 --- a/utils/embeddingModelMigration.ts +++ b/utils/embeddingModelMigration.ts @@ -50,8 +50,6 @@ export const migrateEmbeddingModelIfNeeded = async ( const persistedDim = await readPersistedVectorDim(vectorStore); const incompatible = persistedDim !== null && persistedDim !== currentModelDim; - // Leave the key unset when the wipe was partial so the next launch retries; - // deleting again is idempotent. if (incompatible && !(await clearImportedSources(vectorStore, db))) { return true; } diff --git a/utils/messageSources.ts b/utils/messageSources.ts index 1267af58..b54929f2 100644 --- a/utils/messageSources.ts +++ b/utils/messageSources.ts @@ -122,7 +122,6 @@ export const visibleAnswer = (answer: string): string => { while (open !== -1) { parts.push(answer.slice(cursor, open)); const close = answer.indexOf(THINK_CLOSE, open + THINK_OPEN.length); - // Unterminated: the model is still reasoning, so nothing after it is visible. if (close === -1) return `${parts.join(' ')} `; cursor = close + THINK_CLOSE.length; open = answer.indexOf(THINK_OPEN, cursor); @@ -132,9 +131,6 @@ export const visibleAnswer = (answer: string): string => { return parts.join(' '); }; -// Keep only the clauses the reply actually asserts; a negated clause names a topic -// the source does not cover, and scoring it as overlap cites the source for the -// opposite of what it says. English-only for now. const affirmativeAnswer = (visibleReply: string): string => (visibleReply.match(CITATION_SENTENCE_PATTERN) ?? [visibleReply]) .flatMap((sentence) => sentence.split(CLAUSE_SPLIT_PATTERN)) diff --git a/utils/retrieval.ts b/utils/retrieval.ts index 78b52b2a..565b806a 100644 --- a/utils/retrieval.ts +++ b/utils/retrieval.ts @@ -175,9 +175,6 @@ const expandSelectedWithNeighbors = async ( } } - // Seeds are ranked by similarity, so emitting their windows in that order - // hands the model one document's prose out of sequence (9,10,11 then 2,3,4). - // Relevance already decided which chunks got in; read them in document order. orderedIds.sort( (a, b) => (group.indices.get(a) ?? 0) - (group.indices.get(b) ?? 0) ); From dadfaf5a72a469268db619e682341543ab0680de Mon Sep 17 00:00:00 2001 From: Krzysztof Faracik Date: Thu, 23 Jul 2026 10:34:22 +0200 Subject: [PATCH 42/42] test(llmStore): deterministic clock in the benchmark first-token test performance.now is Date.now under the RN jest preset (1 ms resolution), so on a fast CI machine the benchmark start and the first token could land in the same millisecond, collapsing timeToFirstToken to 0 and flaking the run. Drive the measurement with a virtual monotonic clock. Co-Authored-By: Claude Fable 5 --- __tests__/llmStore.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/__tests__/llmStore.test.ts b/__tests__/llmStore.test.ts index ddd557e3..235e43f2 100644 --- a/__tests__/llmStore.test.ts +++ b/__tests__/llmStore.test.ts @@ -761,6 +761,12 @@ describe('runBenchmark', () => { await loadModel(); useLLMStore.setState({ model: baseModel }); + // The RN jest preset aliases performance.now to Date.now (1 ms resolution), + // so on a fast machine startTime and the first token can share a millisecond + // and the measured delta collapses to 0. Advance a virtual clock instead. + let now = 0; + jest.spyOn(performance, 'now').mockImplementation(() => (now += 10)); + mockInstance.generate.mockImplementation(async () => { await flushFrame(); capturedTokenCallback!('tok');