From af486b6595594064c7a121d321208f5fe10b7b45 Mon Sep 17 00:00:00 2001 From: Visvesh Jegadheesh Date: Wed, 7 Oct 2026 14:54:29 -0700 Subject: [PATCH 01/38] feat: date-range filter for knowledge table file list --- frontend/app/api/queries/useListFiles.ts | 6 + frontend/app/knowledge/page.tsx | 70 ++++++-- .../knowledge-date-range-filter.tsx | 151 ++++++++++++++++++ frontend/components/knowledge-search-bar.tsx | 2 + frontend/components/ui/calendar.tsx | 73 +++++++++ .../contexts/knowledge-filter-context.tsx | 8 + frontend/lib/time-utils.ts | 12 ++ frontend/package-lock.json | 59 ++++--- frontend/package.json | 1 + src/api/files.py | 4 + src/api/v2/files.py | 4 + src/services/file_service_v2.py | 20 ++- src/utils/opensearch_queries.py | 24 +++ 13 files changed, 397 insertions(+), 37 deletions(-) create mode 100644 frontend/components/knowledge-date-range-filter.tsx create mode 100644 frontend/components/ui/calendar.tsx diff --git a/frontend/app/api/queries/useListFiles.ts b/frontend/app/api/queries/useListFiles.ts index 3969750226..623ea86c95 100644 --- a/frontend/app/api/queries/useListFiles.ts +++ b/frontend/app/api/queries/useListFiles.ts @@ -16,6 +16,8 @@ export interface ListFilesParams { dataSources?: string[]; search?: string; afterKey?: Record | null; //composite pagination cursor, could be undefined in the beginning + createdAfter?: string; + createdBefore?: string; } export interface ListFilesResponse { @@ -49,6 +51,10 @@ export const useListFiles = ( if (params.search) searchParams.set("search", params.search); if (params.afterKey) searchParams.set("after_key", JSON.stringify(params.afterKey)); + if (params.createdAfter) + searchParams.set("created_after", params.createdAfter); + if (params.createdBefore) + searchParams.set("created_before", params.createdBefore); const url = `/api/files?${searchParams.toString()}`; //internal (cookie auth) diff --git a/frontend/app/knowledge/page.tsx b/frontend/app/knowledge/page.tsx index 52a4cfceb3..5ad23443d3 100644 --- a/frontend/app/knowledge/page.tsx +++ b/frontend/app/knowledge/page.tsx @@ -14,6 +14,7 @@ import { AgGridReact, type CustomCellRendererProps } from "ag-grid-react"; import { AlertTriangle, Cloud, FileIcon, Globe, RefreshCw } from "lucide-react"; import { useRouter } from "next/navigation"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { KnowledgeDateRangeFilter } from "@/components/knowledge-date-range-filter"; import { KnowledgeDropdown } from "@/components/knowledge-dropdown"; import { ProtectedRoute } from "@/components/protected-route"; import { Banner, BannerIcon, BannerTitle } from "@/components/ui/banner"; @@ -54,7 +55,11 @@ import { buildKnowledgeTableRows, getKnowledgeFileIdentity, } from "@/lib/knowledge-table-state"; -import { parseTimestampMs } from "@/lib/time-utils"; +import { + parseTimestampMs, + toUtcDayEndISOString, + toUtcDayStartISOString, +} from "@/lib/time-utils"; import { cn } from "@/lib/utils"; import { DeleteConfirmationDialog, @@ -430,6 +435,7 @@ function SearchPage() { queryOverride, selectedFilter, setSelectedSources, + dateRange, } = useKnowledgeFilter(); const [selectedRows, setSelectedRows] = useState([]); @@ -621,6 +627,13 @@ function SearchPage() { !hasActiveFilters; const filterPageResetKey = buildFilterPageResetKey(parsedFilterData); + const createdAfter = dateRange?.from + ? toUtcDayStartISOString(dateRange.from) + : undefined; + const createdBefore = dateRange?.to + ? toUtcDayEndISOString(dateRange.to) + : undefined; + const { data: listFilesData, isLoading: isListFilesLoading, @@ -644,6 +657,8 @@ function SearchPage() { dataSources: listFilesFilterValues( parsedFilterData?.filters?.data_sources, ), + createdAfter, + createdBefore, }, { refetchInterval: 5000, @@ -754,7 +769,7 @@ function SearchPage() { useEffect(() => { cursorCacheRef.current = new Map(); setCurrentPage(1); - }, [effectiveSearchText, filterPageResetKey]); + }, [effectiveSearchText, filterPageResetKey, createdAfter, createdBefore]); // when the server responds with an after_key for page N, cache it as the cursor for page N+1 useEffect(() => { @@ -1258,6 +1273,7 @@ function SearchPage() { Delete )} +
@@ -1324,12 +1340,25 @@ function SearchPage() { rowHeight={64} noRowsOverlayComponent={() => (
-
- No knowledge -
-
- Add files from local or your preferred cloud. -
+ {dateRange && serverTotal === 0 ? ( + <> +
+ No results for the selected date range +
+
+ Try a wider range or clear the date filter. +
+ + ) : ( + <> +
+ No knowledge +
+
+ Add files from local or your preferred cloud. +
+ + )}
)} /> @@ -1358,12 +1387,25 @@ function SearchPage() { onSortChanged={onSortChanged} noRowsOverlayComponent={() => (
-
- No knowledge -
-
- Add files from local or your preferred cloud. -
+ {dateRange && serverTotal === 0 ? ( + <> +
+ No results for the selected date range +
+
+ Try a wider range or clear the date filter. +
+ + ) : ( + <> +
+ No knowledge +
+
+ Add files from local or your preferred cloud. +
+ + )}
)} /> diff --git a/frontend/components/knowledge-date-range-filter.tsx b/frontend/components/knowledge-date-range-filter.tsx new file mode 100644 index 0000000000..a55d2ea0c4 --- /dev/null +++ b/frontend/components/knowledge-date-range-filter.tsx @@ -0,0 +1,151 @@ +"use client"; + +import { CalendarIcon, X } from "lucide-react"; +import { useState } from "react"; +import type { DateRange } from "react-day-picker"; +import { Button } from "@/components/ui/button"; +import { Calendar } from "@/components/ui/calendar"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { useKnowledgeFilter } from "@/contexts/knowledge-filter-context"; +import { cn } from "@/lib/utils"; + +function formatDateLabel(date: Date): string { + return date.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + year: "numeric", + }); +} + +export function KnowledgeDateRangeFilter() { + const { dateRange, setDateRange } = useKnowledgeFilter(); + const [open, setOpen] = useState(false); + + const selectedRange: DateRange | undefined = dateRange + ? { from: dateRange.from, to: dateRange.to } + : undefined; + + const handleSelect = (range: DateRange | undefined) => { + setDateRange(range ? { from: range.from, to: range.to } : null); + if (range?.from && range?.to) { + setOpen(false); + } + }; + + const handlePreset = (days: number) => { + const to = new Date(); + const from = new Date(); + from.setDate(from.getDate() - days + 1); + setDateRange({ from, to }); + setOpen(false); + }; + + const handleThisMonth = () => { + const now = new Date(); + const from = new Date(now.getFullYear(), now.getMonth(), 1); + const to = new Date(now.getFullYear(), now.getMonth() + 1, 0); + setDateRange({ from, to }); + setOpen(false); + }; + + const handleClear = () => { + setDateRange(null); + setOpen(false); + }; + + const label = dateRange?.from + ? dateRange.to + ? `${formatDateLabel(dateRange.from)} – ${formatDateLabel(dateRange.to)}` + : formatDateLabel(dateRange.from) + : null; + + return ( + + + + + +
+ + + +
+ + {dateRange && ( +
+ +
+ )} +
+
+ ); +} diff --git a/frontend/components/knowledge-search-bar.tsx b/frontend/components/knowledge-search-bar.tsx index ea945a45d7..a9f3d34bdc 100644 --- a/frontend/components/knowledge-search-bar.tsx +++ b/frontend/components/knowledge-search-bar.tsx @@ -7,6 +7,7 @@ import { useSyncAllConnectors, useSyncAllConnectorsPreview, } from "@/app/api/mutations/useSyncConnector"; +import { KnowledgeDateRangeFilter } from "@/components/knowledge-date-range-filter"; import { RequirePermission } from "@/components/require-permission"; import { Button } from "@/components/ui/button"; import { useKnowledgeFilter } from "@/contexts/knowledge-filter-context"; @@ -203,6 +204,7 @@ export const KnowledgeSearchBar = () => { +
diff --git a/frontend/components/ui/calendar.tsx b/frontend/components/ui/calendar.tsx new file mode 100644 index 0000000000..a295213ffe --- /dev/null +++ b/frontend/components/ui/calendar.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { ChevronLeft, ChevronRight } from "lucide-react"; +import * as React from "react"; +import { DayPicker } from "react-day-picker"; +import { buttonVariants } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +export type CalendarProps = React.ComponentProps; + +function Calendar({ + className, + classNames, + showOutsideDays = true, + ...props +}: CalendarProps) { + return ( + , + IconRight: () => , + }} + {...props} + /> + ); +} +Calendar.displayName = "Calendar"; + +export { Calendar }; diff --git a/frontend/contexts/knowledge-filter-context.tsx b/frontend/contexts/knowledge-filter-context.tsx index 3f9acc0c82..f3d04602ef 100644 --- a/frontend/contexts/knowledge-filter-context.tsx +++ b/frontend/contexts/knowledge-filter-context.tsx @@ -56,6 +56,9 @@ interface KnowledgeFilterContextType { /** Filenames checked in the knowledge table; seeds data_sources on create. */ selectedSources: string[]; setSelectedSources: (sources: string[]) => void; + /** Ephemeral date-range filter for the file grid; never persisted to Knowledge Filters. */ + dateRange: { from?: Date; to?: Date } | null; + setDateRange: (range: { from?: Date; to?: Date } | null) => void; } const KnowledgeFilterContext = createContext< @@ -90,6 +93,9 @@ export function KnowledgeFilterProvider({ const [createMode, setCreateMode] = useState(false); const [queryOverride, setQueryOverride] = useState(""); const [selectedSources, setSelectedSources] = useState([]); + const [dateRange, setDateRange] = useState<{ from?: Date; to?: Date } | null>( + null, + ); const setSelectedFilter = (filter: KnowledgeFilter | null) => { setSelectedFilterState(filter); @@ -202,6 +208,8 @@ export function KnowledgeFilterProvider({ setQueryOverride, selectedSources, setSelectedSources, + dateRange, + setDateRange, }; return ( diff --git a/frontend/lib/time-utils.ts b/frontend/lib/time-utils.ts index ce7da67606..a9a08ed7c8 100644 --- a/frontend/lib/time-utils.ts +++ b/frontend/lib/time-utils.ts @@ -64,3 +64,15 @@ export function formatTaskTimestamp( return `${diffHours} hours ago · ${formatClockTime(date)}`; return `${Math.floor(diffHours / 24)} days ago · ${formatClockTime(date)}`; } + +export function toUtcDayStartISOString(date: Date): string { + const d = new Date(date); + d.setHours(0, 0, 0, 0); + return d.toISOString(); +} + +export function toUtcDayEndISOString(date: Date): string { + const d = new Date(date); + d.setHours(23, 59, 59, 999); + return d.toISOString(); +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1bdc6e7ed0..63fc80b0a1 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -41,6 +41,7 @@ "next-themes": "^0.4.6", "prom-client": "^15.1.3", "react": "^19.2.7", + "react-day-picker": "^8.10.2", "react-dom": "^19.2.7", "react-dropzone": "^15.0.0", "react-hook-form": "^7.80.0", @@ -145,6 +146,7 @@ "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", @@ -508,7 +510,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" }, @@ -557,7 +558,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" } @@ -596,7 +596,6 @@ "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" @@ -608,7 +607,6 @@ "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -1104,7 +1102,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -1809,7 +1806,6 @@ "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "playwright": "1.61.1" }, @@ -3530,7 +3526,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/chai": { "version": "5.2.3", @@ -3605,7 +3602,6 @@ "integrity": "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~8.3.0" } @@ -3621,7 +3617,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -3632,7 +3627,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -3722,7 +3716,6 @@ "integrity": "sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.11", @@ -4157,7 +4150,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.44", "caniuse-lite": "^1.0.30001806", @@ -4654,6 +4646,17 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/date-fns": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", + "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==", + "license": "MIT", + "peer": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debug": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", @@ -4755,7 +4758,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/dotenv": { "version": "17.4.2", @@ -6385,6 +6389,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -7392,7 +7397,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@inquirer/confirm": "^6.0.11", "@mswjs/interceptors": "^0.41.3", @@ -7857,7 +7861,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", @@ -8001,6 +8004,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -8016,6 +8020,7 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -8026,6 +8031,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -8038,7 +8044,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/prismjs": { "version": "1.30.0", @@ -8117,17 +8124,29 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } }, + "node_modules/react-day-picker": { + "version": "8.10.2", + "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-8.10.2.tgz", + "integrity": "sha512-LK68OTbHB3oJNhl9cA0qVizzp3o26w61YSjAFkYi67N86iro32wx86kSNeFU/hq+gI8m1yzWhnomMLfZ041RzQ==", + "license": "MIT", + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/gpbl" + }, + "peerDependencies": { + "date-fns": "^2.28.0 || ^3.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/react-dom": { "version": "19.2.7", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -9056,7 +9075,6 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", "license": "MIT", - "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -9299,7 +9317,6 @@ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9680,7 +9697,6 @@ "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", @@ -9774,7 +9790,6 @@ "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.1.11", "@vitest/mocker": "4.1.11", diff --git a/frontend/package.json b/frontend/package.json index d8ffb58f0e..ff9acfc3a6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -54,6 +54,7 @@ "next-themes": "^0.4.6", "prom-client": "^15.1.3", "react": "^19.2.7", + "react-day-picker": "^8.10.2", "react-dom": "^19.2.7", "react-dropzone": "^15.0.0", "react-hook-form": "^7.80.0", diff --git a/src/api/files.py b/src/api/files.py index c5ea921671..c916535726 100644 --- a/src/api/files.py +++ b/src/api/files.py @@ -52,6 +52,8 @@ async def list_files( search: str | None = Query(None, description="Search filename"), after_key: str | None = Query(None, description="Composite pagination cursor (JSON-encoded)"), data_sources: list[str] | None = Query(None, description="Filename whitelist (repeatable)"), + created_after: str | None = Query(None, description="Filter files created after ISO timestamp"), + created_before: str | None = Query(None, description="Filter files created before ISO timestamp"), file_service=Depends(get_file_service_v2), user: User = Depends(get_current_user), ): @@ -72,6 +74,8 @@ async def list_files( search=search, after_key=parsed_after_key, data_sources=data_sources, + created_after=created_after, + created_before=created_before, ) return JSONResponse(result) except Exception as e: diff --git a/src/api/v2/files.py b/src/api/v2/files.py index cec3b70d78..fc54609b87 100644 --- a/src/api/v2/files.py +++ b/src/api/v2/files.py @@ -30,6 +30,8 @@ async def list_files( search: str | None = Query(None, description="Search filename"), after_key: str | None = Query(None, description="Composite pagination cursor (JSON-encoded)"), data_sources: list[str] | None = Query(None, description="Filename whitelist (repeatable)"), + created_after: str | None = Query(None, description="Filter files created after ISO timestamp"), + created_before: str | None = Query(None, description="Filter files created before ISO timestamp"), file_service=Depends(get_file_service_v2), user: User = Depends(require_api_key_permission("knowledge:read:own")), ): @@ -45,6 +47,8 @@ async def list_files( search=search, after_key=after_key, data_sources=data_sources, + created_after=created_after, + created_before=created_before, file_service=file_service, user=user, ) diff --git a/src/services/file_service_v2.py b/src/services/file_service_v2.py index ce41326d8c..a0b6ae8d54 100644 --- a/src/services/file_service_v2.py +++ b/src/services/file_service_v2.py @@ -21,6 +21,7 @@ from config.settings import get_index_name from utils.logging_config import get_logger +from utils.opensearch_queries import build_date_range_query logger = get_logger(__name__) @@ -55,6 +56,8 @@ async def list_files( search: str | None = None, after_key: dict | None = None, data_sources: list[str] | None = None, + created_after: str | None = None, + created_before: str | None = None, ) -> dict[str, Any]: """ List files with server-side pagination via composite aggregation. @@ -71,7 +74,14 @@ async def list_files( opensearch_client = self.session_manager.get_user_opensearch_client(user_id, jwt_token) query = self._build_filter_query( - user_id, connector_type, mimetype, owner, search, data_sources + user_id, + connector_type, + mimetype, + owner, + search, + data_sources, + created_after, + created_before, ) total, is_approximate = await self._get_file_count(opensearch_client, query) @@ -232,6 +242,8 @@ def _build_filter_query( owner: list[str] | None = None, search: str | None = None, data_sources: list[str] | None = None, + created_after: str | None = None, + created_before: str | None = None, ) -> dict[str, Any]: def _effective_values(values: str | list[str] | None) -> list[str]: if values is None: @@ -279,6 +291,12 @@ def _effective_values(values: str | list[str] | None) -> list[str]: ) filter_clauses.append(clause) + date_range_clause = build_date_range_query( + "indexed_time", gte=created_after, lte=created_before + ) + if date_range_clause is not None: + filter_clauses.append(date_range_clause) + if search: must.append( { diff --git a/src/utils/opensearch_queries.py b/src/utils/opensearch_queries.py index d16dfb7b5b..24dda5b50b 100644 --- a/src/utils/opensearch_queries.py +++ b/src/utils/opensearch_queries.py @@ -116,3 +116,27 @@ def build_replace_filename_query(filename: str, owner: str) -> dict: ] } } + + +def build_date_range_query( + field: str, gte: str | None = None, lte: str | None = None +) -> dict | None: + """ + Build a date range query clause for OpenSearch. + + Args: + field: The date field name to filter on (e.g. 'created_time') + gte: Lower bound ISO-8601 string (inclusive) + lte: Upper bound ISO-8601 string (inclusive) + + Returns: + A dict containing {"range": {field: {...}}} or None if both bounds are None. + """ + range_bounds: dict[str, str] = {} + if gte is not None: + range_bounds["gte"] = gte + if lte is not None: + range_bounds["lte"] = lte + if not range_bounds: + return None + return {"range": {field: range_bounds}} From dba1a057b7425d359339b1ac1fde7c2149dfbef4 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 7 Oct 2026 21:56:29 +0000 Subject: [PATCH 02/38] style: ruff autofix (auto) --- src/api/files.py | 4 +++- src/api/v2/files.py | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/api/files.py b/src/api/files.py index c916535726..715fc6dee7 100644 --- a/src/api/files.py +++ b/src/api/files.py @@ -53,7 +53,9 @@ async def list_files( after_key: str | None = Query(None, description="Composite pagination cursor (JSON-encoded)"), data_sources: list[str] | None = Query(None, description="Filename whitelist (repeatable)"), created_after: str | None = Query(None, description="Filter files created after ISO timestamp"), - created_before: str | None = Query(None, description="Filter files created before ISO timestamp"), + created_before: str | None = Query( + None, description="Filter files created before ISO timestamp" + ), file_service=Depends(get_file_service_v2), user: User = Depends(get_current_user), ): diff --git a/src/api/v2/files.py b/src/api/v2/files.py index fc54609b87..c09b39f159 100644 --- a/src/api/v2/files.py +++ b/src/api/v2/files.py @@ -31,7 +31,9 @@ async def list_files( after_key: str | None = Query(None, description="Composite pagination cursor (JSON-encoded)"), data_sources: list[str] | None = Query(None, description="Filename whitelist (repeatable)"), created_after: str | None = Query(None, description="Filter files created after ISO timestamp"), - created_before: str | None = Query(None, description="Filter files created before ISO timestamp"), + created_before: str | None = Query( + None, description="Filter files created before ISO timestamp" + ), file_service=Depends(get_file_service_v2), user: User = Depends(require_api_key_permission("knowledge:read:own")), ): From 1b7a222ae090198e59b69c9de13d1841ed01d217 Mon Sep 17 00:00:00 2001 From: Visvesh Jegadheesh Date: Wed, 7 Oct 2026 15:51:16 -0700 Subject: [PATCH 03/38] coderabbit + react suggestions --- frontend/app/api/queries/useGetSearchQuery.ts | 1 + frontend/app/api/queries/useListFiles.ts | 1 + frontend/app/knowledge/page.tsx | 22 +++ .../knowledge-date-range-filter.tsx | 150 ++++++++---------- src/api/files.py | 16 ++ 5 files changed, 110 insertions(+), 80 deletions(-) diff --git a/frontend/app/api/queries/useGetSearchQuery.ts b/frontend/app/api/queries/useGetSearchQuery.ts index e97b9f7bf8..33d76df981 100644 --- a/frontend/app/api/queries/useGetSearchQuery.ts +++ b/frontend/app/api/queries/useGetSearchQuery.ts @@ -81,6 +81,7 @@ export interface File { chunks?: ChunkResult[]; allowed_users?: string[]; allowed_groups?: string[]; + indexed_time?: string; } // Non-fatal signal from the backend — e.g. an embedding provider was removed diff --git a/frontend/app/api/queries/useListFiles.ts b/frontend/app/api/queries/useListFiles.ts index 623ea86c95..fd762c061c 100644 --- a/frontend/app/api/queries/useListFiles.ts +++ b/frontend/app/api/queries/useListFiles.ts @@ -86,6 +86,7 @@ export const useListFiles = ( embedding_dimensions: f.embedding_dimensions as number | undefined, allowed_users: (f.allowed_users as string[]) || [], allowed_groups: (f.allowed_groups as string[]) || [], + indexed_time: (f.indexed_time as string) || undefined, status: "active" as const, }), ); diff --git a/frontend/app/knowledge/page.tsx b/frontend/app/knowledge/page.tsx index 5ad23443d3..b9f0447c3a 100644 --- a/frontend/app/knowledge/page.tsx +++ b/frontend/app/knowledge/page.tsx @@ -135,6 +135,7 @@ const AG_FIELD_TO_SORT_BY: Record = { mimetype: "mimetype", owner: "owner", chunkCount: "chunk_count", + indexed_time: "indexed_time", status: "status", }; @@ -944,6 +945,26 @@ function SearchPage() { comparator: serverSideComparator, }; + const colIndexedTime: ColDef = { + field: "indexed_time", + headerName: "Date Uploaded", + ...(isCloudBrand ? { flex: 1.2, minWidth: 140 } : { minWidth: 130 }), + sortable: true, + comparator: serverSideComparator, + cellClass: isCloudBrand ? "text-muted-foreground" : undefined, + valueFormatter: ({ value }: ValueFormatterParams) => { + if (!value) return "—"; + const d = new Date(value); + return Number.isNaN(d.getTime()) + ? "—" + : d.toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + }); + }, + }; + const colChunks: ColDef = { field: "chunkCount", headerName: "Chunks", @@ -1041,6 +1062,7 @@ function SearchPage() { colSize, colType, colOwner, + colIndexedTime, colChunks, colAvgScore, colStatus, diff --git a/frontend/components/knowledge-date-range-filter.tsx b/frontend/components/knowledge-date-range-filter.tsx index a55d2ea0c4..688f0b3b9e 100644 --- a/frontend/components/knowledge-date-range-filter.tsx +++ b/frontend/components/knowledge-date-range-filter.tsx @@ -24,6 +24,8 @@ function formatDateLabel(date: Date): string { export function KnowledgeDateRangeFilter() { const { dateRange, setDateRange } = useKnowledgeFilter(); const [open, setOpen] = useState(false); + // Captured once on mount so server/client renders agree on the same Date. + const [today] = useState(() => new Date()); const selectedRange: DateRange | undefined = dateRange ? { from: dateRange.from, to: dateRange.to } @@ -47,8 +49,7 @@ export function KnowledgeDateRangeFilter() { const handleThisMonth = () => { const now = new Date(); const from = new Date(now.getFullYear(), now.getMonth(), 1); - const to = new Date(now.getFullYear(), now.getMonth() + 1, 0); - setDateRange({ from, to }); + setDateRange({ from, to: now }); setOpen(false); }; @@ -64,88 +65,77 @@ export function KnowledgeDateRangeFilter() { : null; return ( - - - - - -
- +
+ + - -
- - {dateRange && ( -
- + +
- )} - - + + {dateRange && ( +
+ +
+ )} + + + {dateRange && ( + + )} +
); } diff --git a/src/api/files.py b/src/api/files.py index 715fc6dee7..68dcbbcfd6 100644 --- a/src/api/files.py +++ b/src/api/files.py @@ -8,6 +8,7 @@ """ import json +from datetime import datetime from fastapi import Depends, HTTPException, Query from fastapi.responses import JSONResponse @@ -19,6 +20,19 @@ logger = get_logger(__name__) +def _validate_iso_timestamp(value: str | None, param_name: str) -> None: + """Raise HTTP 422 when *value* is present but is not a valid ISO 8601 timestamp.""" + if value is None: + return + try: + datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError as err: + raise HTTPException( + status_code=422, + detail=f"{param_name} must be a valid ISO 8601 timestamp", + ) from err + + def _parse_after_key(after_key: str | None) -> dict | None: """Parse a JSON-encoded composite cursor. @@ -60,6 +74,8 @@ async def list_files( user: User = Depends(get_current_user), ): """List ingested files with composite-aggregation pagination, filtering, and sorting.""" + _validate_iso_timestamp(created_after, "created_after") + _validate_iso_timestamp(created_before, "created_before") parsed_after_key = _parse_after_key(after_key) try: From d154315a6fdba211094f964b469798f8fd047475 Mon Sep 17 00:00:00 2001 From: Visvesh Jegadheesh Date: Wed, 7 Oct 2026 15:54:46 -0700 Subject: [PATCH 04/38] other coderabbit suggestion --- frontend/app/knowledge/page.tsx | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/frontend/app/knowledge/page.tsx b/frontend/app/knowledge/page.tsx index b9f0447c3a..76d410c289 100644 --- a/frontend/app/knowledge/page.tsx +++ b/frontend/app/knowledge/page.tsx @@ -685,9 +685,25 @@ function SearchPage() { const error = isWildcardQuery ? listFilesError : searchError; const isError = isWildcardQuery ? isListFilesError : isSearchError; + // When in search mode, filter client-side by date so the range picker applies + // to both list and search results. + const dateFilteredSearchFiles = isWildcardQuery + ? [] + : searchFiles.filter((file) => { + if (!createdAfter && !createdBefore) return true; + const t = file.indexed_time + ? new Date(file.indexed_time).getTime() + : null; + if (t === null || Number.isNaN(t)) return true; + if (createdAfter && t < new Date(createdAfter).getTime()) return false; + if (createdBefore && t > new Date(createdBefore).getTime()) + return false; + return true; + }); + const effectiveData: File[] = isWildcardQuery ? (listFilesData?.files ?? []) - : searchFiles.slice( + : dateFilteredSearchFiles.slice( (currentPage - 1) * currentPageSize, currentPage * currentPageSize, ); @@ -763,7 +779,7 @@ function SearchPage() { const serverTotal = isWildcardQuery ? (listFilesData?.total ?? 0) - : searchFiles.length; + : dateFilteredSearchFiles.length; const gridRows: File[] = fileResults; const totalPages = Math.max(1, Math.ceil(serverTotal / currentPageSize)); From 66bcf84cf460e3167e8abad39c6818f6b7763dc2 Mon Sep 17 00:00:00 2001 From: Visvesh Jegadheesh Date: Thu, 8 Oct 2026 10:10:16 -0700 Subject: [PATCH 05/38] coderabbit suggestions --- frontend/app/api/queries/useGetSearchQuery.ts | 4 + frontend/app/api/queries/useListFiles.test.ts | 162 +++++++++++++++ frontend/app/knowledge/page.tsx | 17 +- .../knowledge-date-range-filter.test.tsx | 192 ++++++++++++++++++ frontend/components/ui/calendar.test.tsx | 31 +++ frontend/lib/time-utils.test.ts | 62 ++++++ src/api/files.py | 2 +- src/services/search_service.py | 2 + 8 files changed, 466 insertions(+), 6 deletions(-) create mode 100644 frontend/app/api/queries/useListFiles.test.ts create mode 100644 frontend/components/knowledge-date-range-filter.test.tsx create mode 100644 frontend/components/ui/calendar.test.tsx create mode 100644 frontend/lib/time-utils.test.ts diff --git a/frontend/app/api/queries/useGetSearchQuery.ts b/frontend/app/api/queries/useGetSearchQuery.ts index 33d76df981..a22b89589d 100644 --- a/frontend/app/api/queries/useGetSearchQuery.ts +++ b/frontend/app/api/queries/useGetSearchQuery.ts @@ -45,6 +45,7 @@ export interface ChunkResult { index?: number; allowed_users?: string[]; allowed_groups?: string[]; + indexed_time?: string; } export interface File { @@ -198,6 +199,7 @@ export const useGetSearchQuery = ( embedding_dimensions?: number; allowed_users?: string[]; allowed_groups?: string[]; + indexed_time?: string; } >(); @@ -238,6 +240,7 @@ export const useGetSearchQuery = ( embedding_dimensions: chunk.embedding_dimensions, allowed_users: chunk.allowed_users || [], allowed_groups: chunk.allowed_groups || [], + indexed_time: chunk.indexed_time, }); } }); @@ -258,6 +261,7 @@ export const useGetSearchQuery = ( chunks: file.chunks, allowed_users: file.allowed_users || [], allowed_groups: file.allowed_groups || [], + indexed_time: file.indexed_time, })); const warnings: SearchWarning[] = Array.isArray(data.warnings) diff --git a/frontend/app/api/queries/useListFiles.test.ts b/frontend/app/api/queries/useListFiles.test.ts new file mode 100644 index 0000000000..bad4b6262f --- /dev/null +++ b/frontend/app/api/queries/useListFiles.test.ts @@ -0,0 +1,162 @@ +/** + * Tests for useListFiles. + * + * Verifies URL parameter serialization (including the new createdAfter / + * createdBefore date-range params), response parsing, and error handling. + */ +import { renderHook, waitFor } from "@testing-library/react"; +import { HttpResponse, http } from "msw"; +import { describe, expect, it } from "vitest"; +import { server } from "@/test-utils/msw/server"; +import { createQueryWrapper } from "@/test-utils/render"; +import { useListFiles } from "./useListFiles"; + +function makeFileResponse(overrides: Record = {}) { + return { + files: [], + total: 0, + is_approximate: false, + page: 1, + page_size: 25, + after_key: null, + ...overrides, + }; +} + +describe("useListFiles", () => { + it("sends created_after param when createdAfter is provided", async () => { + let capturedUrl: string | undefined; + server.use( + http.get("/api/files", ({ request }) => { + capturedUrl = request.url; + return HttpResponse.json(makeFileResponse()); + }), + ); + + const { result } = renderHook( + () => + useListFiles({ + createdAfter: "2024-01-01T00:00:00.000Z", + }), + { wrapper: createQueryWrapper() }, + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + const url = new URL(capturedUrl!); + expect(url.searchParams.get("created_after")).toBe( + "2024-01-01T00:00:00.000Z", + ); + }); + + it("sends created_before param when createdBefore is provided", async () => { + let capturedUrl: string | undefined; + server.use( + http.get("/api/files", ({ request }) => { + capturedUrl = request.url; + return HttpResponse.json(makeFileResponse()); + }), + ); + + const { result } = renderHook( + () => + useListFiles({ + createdBefore: "2024-01-31T23:59:59.999Z", + }), + { wrapper: createQueryWrapper() }, + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + const url = new URL(capturedUrl!); + expect(url.searchParams.get("created_before")).toBe( + "2024-01-31T23:59:59.999Z", + ); + }); + + it("sends both date params together", async () => { + let capturedUrl: string | undefined; + server.use( + http.get("/api/files", ({ request }) => { + capturedUrl = request.url; + return HttpResponse.json(makeFileResponse()); + }), + ); + + const { result } = renderHook( + () => + useListFiles({ + createdAfter: "2024-03-01T00:00:00.000Z", + createdBefore: "2024-03-31T23:59:59.999Z", + }), + { wrapper: createQueryWrapper() }, + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + const url = new URL(capturedUrl!); + expect(url.searchParams.get("created_after")).toBe( + "2024-03-01T00:00:00.000Z", + ); + expect(url.searchParams.get("created_before")).toBe( + "2024-03-31T23:59:59.999Z", + ); + }); + + it("omits date params when not provided", async () => { + let capturedUrl: string | undefined; + server.use( + http.get("/api/files", ({ request }) => { + capturedUrl = request.url; + return HttpResponse.json(makeFileResponse()); + }), + ); + + const { result } = renderHook(() => useListFiles({}), { + wrapper: createQueryWrapper(), + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + const url = new URL(capturedUrl!); + expect(url.searchParams.has("created_after")).toBe(false); + expect(url.searchParams.has("created_before")).toBe(false); + }); + + it("maps indexed_time from the response", async () => { + server.use( + http.get("/api/files", () => + HttpResponse.json( + makeFileResponse({ + files: [ + { + filename: "doc.pdf", + mimetype: "application/pdf", + chunk_count: 5, + source_url: "", + owner: "user1", + owner_name: "User One", + owner_email: "user@example.com", + file_size: 1024, + connector_type: "local", + allowed_users: [], + allowed_groups: [], + indexed_time: "2024-06-15T10:30:00.000Z", + }, + ], + }), + ), + ), + ); + + const { result } = renderHook(() => useListFiles({}), { + wrapper: createQueryWrapper(), + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data?.files[0]?.indexed_time).toBe( + "2024-06-15T10:30:00.000Z", + ); + }); +}); diff --git a/frontend/app/knowledge/page.tsx b/frontend/app/knowledge/page.tsx index 76d410c289..904e709d30 100644 --- a/frontend/app/knowledge/page.tsx +++ b/frontend/app/knowledge/page.tsx @@ -686,18 +686,25 @@ function SearchPage() { const isError = isWildcardQuery ? isListFilesError : isSearchError; // When in search mode, filter client-side by date so the range picker applies - // to both list and search results. + // to both list and search results. Compute bound timestamps once outside the + // predicate to avoid repeated Date construction per file. + const createdAfterMs = createdAfter + ? new Date(createdAfter).getTime() + : undefined; + const createdBeforeMs = createdBefore + ? new Date(createdBefore).getTime() + : undefined; const dateFilteredSearchFiles = isWildcardQuery ? [] : searchFiles.filter((file) => { - if (!createdAfter && !createdBefore) return true; + if (createdAfterMs === undefined && createdBeforeMs === undefined) + return true; const t = file.indexed_time ? new Date(file.indexed_time).getTime() : null; if (t === null || Number.isNaN(t)) return true; - if (createdAfter && t < new Date(createdAfter).getTime()) return false; - if (createdBefore && t > new Date(createdBefore).getTime()) - return false; + if (createdAfterMs !== undefined && t < createdAfterMs) return false; + if (createdBeforeMs !== undefined && t > createdBeforeMs) return false; return true; }); diff --git a/frontend/components/knowledge-date-range-filter.test.tsx b/frontend/components/knowledge-date-range-filter.test.tsx new file mode 100644 index 0000000000..327638486c --- /dev/null +++ b/frontend/components/knowledge-date-range-filter.test.tsx @@ -0,0 +1,192 @@ +import { describe, expect, it, vi } from "vitest"; +import { renderWithProviders, screen, userEvent } from "@/test-utils/render"; +import { KnowledgeDateRangeFilter } from "./knowledge-date-range-filter"; + +// Radix Popover does not open via click in JSDOM (portal + pointer-event +// restrictions). Mock it so PopoverContent renders inline, driven by the +// controlled open/onOpenChange props the component passes. +// vi.mock is hoisted before imports, so React must be imported inside the factory. +vi.mock("@/components/ui/popover", async () => { + const React = await import("react"); + + type CtxVal = { open: boolean; setOpen: (v: boolean) => void }; + const Ctx = React.createContext({ open: false, setOpen: () => {} }); + + function Popover({ + open = false, + onOpenChange = () => {}, + children, + }: { + open?: boolean; + onOpenChange?: (v: boolean) => void; + children?: React.ReactNode; + }) { + return ( + + {children} + + ); + } + + function PopoverTrigger({ + asChild, + children, + }: { + asChild?: boolean; + children: React.ReactElement; + }) { + const { open, setOpen } = React.useContext(Ctx); + const toggle = () => setOpen(!open); + if (asChild && React.isValidElement(children)) { + return React.cloneElement(children as React.ReactElement, { + onClick: (e: React.MouseEvent) => { + toggle(); + (children.props as any).onClick?.(e); + }, + }); + } + return ; + } + + function PopoverContent({ + children, + className, + }: { + children?: React.ReactNode; + className?: string; + align?: string; + }) { + const { open } = React.useContext(Ctx); + return open ?
{children}
: null; + } + + return { Popover, PopoverTrigger, PopoverContent }; +}); + +describe("KnowledgeDateRangeFilter", () => { + it("renders the trigger button with accessible label", () => { + renderWithProviders(, { + providers: ["knowledgeFilter"], + }); + expect( + screen.getByRole("button", { name: /filter by date range/i }), + ).toBeInTheDocument(); + }); + + it("shows Date range label when no range is selected", () => { + renderWithProviders(, { + providers: ["knowledgeFilter"], + }); + expect(screen.getByText("Date range")).toBeInTheDocument(); + }); + + it("opens the popover and shows presets on click", async () => { + const user = userEvent.setup(); + renderWithProviders(, { + providers: ["knowledgeFilter"], + }); + await user.click( + screen.getByRole("button", { name: /filter by date range/i }), + ); + expect(screen.getByText("Last 7 days")).toBeInTheDocument(); + expect(screen.getByText("Last 30 days")).toBeInTheDocument(); + expect(screen.getByText("This month")).toBeInTheDocument(); + }); + + it("applies Last 7 days preset and closes popover", async () => { + const user = userEvent.setup(); + renderWithProviders(, { + providers: ["knowledgeFilter"], + }); + await user.click( + screen.getByRole("button", { name: /filter by date range/i }), + ); + await user.click(screen.getByText("Last 7 days")); + expect(screen.queryByText("Last 7 days")).not.toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /clear date range/i }), + ).toBeInTheDocument(); + }); + + it("applies Last 30 days preset and closes popover", async () => { + const user = userEvent.setup(); + renderWithProviders(, { + providers: ["knowledgeFilter"], + }); + await user.click( + screen.getByRole("button", { name: /filter by date range/i }), + ); + await user.click(screen.getByText("Last 30 days")); + expect(screen.queryByText("Last 30 days")).not.toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /clear date range/i }), + ).toBeInTheDocument(); + }); + + it("applies This month preset and closes popover", async () => { + const user = userEvent.setup(); + renderWithProviders(, { + providers: ["knowledgeFilter"], + }); + await user.click( + screen.getByRole("button", { name: /filter by date range/i }), + ); + await user.click(screen.getByText("This month")); + expect(screen.queryByText("This month")).not.toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /clear date range/i }), + ).toBeInTheDocument(); + }); + + it("clears the date range when the clear button is clicked", async () => { + const user = userEvent.setup(); + renderWithProviders(, { + providers: ["knowledgeFilter"], + }); + await user.click( + screen.getByRole("button", { name: /filter by date range/i }), + ); + await user.click(screen.getByText("Last 7 days")); + await user.click(screen.getByRole("button", { name: /clear date range/i })); + expect( + screen.queryByRole("button", { name: /clear date range/i }), + ).not.toBeInTheDocument(); + expect(screen.getByText("Date range")).toBeInTheDocument(); + }); + + it("shows a date range label in the trigger after selecting a preset", async () => { + const user = userEvent.setup(); + renderWithProviders(, { + providers: ["knowledgeFilter"], + }); + await user.click( + screen.getByRole("button", { name: /filter by date range/i }), + ); + await user.click(screen.getByText("Last 7 days")); + expect(screen.queryByText("Date range")).not.toBeInTheDocument(); + }); + + it("shows a Clear button inside the popover when a range is active", async () => { + const user = userEvent.setup(); + renderWithProviders(, { + providers: ["knowledgeFilter"], + }); + await user.click( + screen.getByRole("button", { name: /filter by date range/i }), + ); + await user.click(screen.getByText("Last 7 days")); + + // Re-open: click trigger again (no longer has aria-label "filter by date range" + // because the label changed to show dates — find by role instead) + const buttons = screen.getAllByRole("button"); + const triggerBtn = buttons.find( + (b) => + b.getAttribute("aria-label") !== "Clear date range" && + !b.textContent?.includes("Clear"), + ); + if (triggerBtn) await user.click(triggerBtn); + + const clearButtons = screen.getAllByRole("button", { name: /clear/i }); + expect(clearButtons.length).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/frontend/components/ui/calendar.test.tsx b/frontend/components/ui/calendar.test.tsx new file mode 100644 index 0000000000..3604aa8eb3 --- /dev/null +++ b/frontend/components/ui/calendar.test.tsx @@ -0,0 +1,31 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { Calendar } from "./calendar"; + +describe("Calendar", () => { + it("renders the calendar grid", () => { + render(); + expect(screen.getByRole("grid")).toBeInTheDocument(); + }); + + it("renders navigation buttons with chevron icons", () => { + render(); + // DayPicker renders two nav buttons (previous/next month) that use our custom icons + const navButtons = screen.getAllByRole("button"); + expect(navButtons.length).toBeGreaterThanOrEqual(2); + }); + + it("applies extra className to the root element", () => { + const { container } = render( + , + ); + expect(container.firstChild).toHaveClass("my-custom"); + }); + + it("renders day numbers for the current month", () => { + render(); + // At least one day button should be visible + const dayButtons = screen.getAllByRole("gridcell"); + expect(dayButtons.length).toBeGreaterThan(0); + }); +}); diff --git a/frontend/lib/time-utils.test.ts b/frontend/lib/time-utils.test.ts new file mode 100644 index 0000000000..5c74c7c14b --- /dev/null +++ b/frontend/lib/time-utils.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { toUtcDayEndISOString, toUtcDayStartISOString } from "./time-utils"; + +describe("toUtcDayStartISOString", () => { + it("sets time to local midnight (00:00:00.000)", () => { + const date = new Date("2024-03-15T15:30:45.123"); + const result = toUtcDayStartISOString(date); + const d = new Date(result); + // getHours/getMinutes/etc. return local time, matching what setHours set + expect(d.getHours()).toBe(0); + expect(d.getMinutes()).toBe(0); + expect(d.getSeconds()).toBe(0); + expect(d.getMilliseconds()).toBe(0); + }); + + it("does not mutate the input date", () => { + const date = new Date("2024-06-01T12:00:00"); + const original = date.getTime(); + toUtcDayStartISOString(date); + expect(date.getTime()).toBe(original); + }); + + it("returns a valid ISO 8601 string", () => { + const date = new Date("2024-03-15"); + const result = toUtcDayStartISOString(date); + expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); + expect(new Date(result).getTime()).not.toBeNaN(); + }); +}); + +describe("toUtcDayEndISOString", () => { + it("sets time to local end-of-day (23:59:59.999)", () => { + const date = new Date("2024-03-15T10:00:00"); + const result = toUtcDayEndISOString(date); + const d = new Date(result); + expect(d.getHours()).toBe(23); + expect(d.getMinutes()).toBe(59); + expect(d.getSeconds()).toBe(59); + expect(d.getMilliseconds()).toBe(999); + }); + + it("does not mutate the input date", () => { + const date = new Date("2024-06-01T12:00:00"); + const original = date.getTime(); + toUtcDayEndISOString(date); + expect(date.getTime()).toBe(original); + }); + + it("returns a valid ISO 8601 string", () => { + const date = new Date("2024-03-15"); + const result = toUtcDayEndISOString(date); + expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); + expect(new Date(result).getTime()).not.toBeNaN(); + }); + + it("end is later than start for the same input date", () => { + const date = new Date("2024-03-15"); + const start = toUtcDayStartISOString(date); + const end = toUtcDayEndISOString(date); + expect(new Date(end).getTime()).toBeGreaterThan(new Date(start).getTime()); + }); +}); diff --git a/src/api/files.py b/src/api/files.py index 68dcbbcfd6..8194fee7b1 100644 --- a/src/api/files.py +++ b/src/api/files.py @@ -22,7 +22,7 @@ def _validate_iso_timestamp(value: str | None, param_name: str) -> None: """Raise HTTP 422 when *value* is present but is not a valid ISO 8601 timestamp.""" - if value is None: + if not isinstance(value, str): return try: datetime.fromisoformat(value.replace("Z", "+00:00")) diff --git a/src/services/search_service.py b/src/services/search_service.py index 762f4c6712..d5696d0ba3 100644 --- a/src/services/search_service.py +++ b/src/services/search_service.py @@ -640,6 +640,7 @@ async def embed_with_space( "allowed_users", "allowed_groups", "allowed_principal_labels", + "indexed_time", ], "size": limit, } @@ -800,6 +801,7 @@ async def embed_with_space( "allowed_users": source.get("allowed_users", []), "allowed_groups": source.get("allowed_groups", []), "allowed_principal_labels": source.get("allowed_principal_labels", []), + "indexed_time": source.get("indexed_time"), } ) From fb367cc19651148b52dc867f05e0bffa52222985 Mon Sep 17 00:00:00 2001 From: Visvesh Jegadheesh Date: Thu, 8 Oct 2026 10:22:49 -0700 Subject: [PATCH 06/38] fix frontend testing --- .../knowledge-date-range-filter.test.tsx | 229 +++++------------- 1 file changed, 66 insertions(+), 163 deletions(-) diff --git a/frontend/components/knowledge-date-range-filter.test.tsx b/frontend/components/knowledge-date-range-filter.test.tsx index 327638486c..6ad5f64e22 100644 --- a/frontend/components/knowledge-date-range-filter.test.tsx +++ b/frontend/components/knowledge-date-range-filter.test.tsx @@ -1,192 +1,95 @@ -import { describe, expect, it, vi } from "vitest"; -import { renderWithProviders, screen, userEvent } from "@/test-utils/render"; +import { act, fireEvent } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { renderWithProviders, screen } from "@/test-utils/render"; import { KnowledgeDateRangeFilter } from "./knowledge-date-range-filter"; -// Radix Popover does not open via click in JSDOM (portal + pointer-event -// restrictions). Mock it so PopoverContent renders inline, driven by the -// controlled open/onOpenChange props the component passes. -// vi.mock is hoisted before imports, so React must be imported inside the factory. -vi.mock("@/components/ui/popover", async () => { - const React = await import("react"); - - type CtxVal = { open: boolean; setOpen: (v: boolean) => void }; - const Ctx = React.createContext({ open: false, setOpen: () => {} }); - - function Popover({ - open = false, - onOpenChange = () => {}, - children, - }: { - open?: boolean; - onOpenChange?: (v: boolean) => void; - children?: React.ReactNode; - }) { - return ( - - {children} - - ); - } - - function PopoverTrigger({ - asChild, - children, - }: { - asChild?: boolean; - children: React.ReactElement; - }) { - const { open, setOpen } = React.useContext(Ctx); - const toggle = () => setOpen(!open); - if (asChild && React.isValidElement(children)) { - return React.cloneElement(children as React.ReactElement, { - onClick: (e: React.MouseEvent) => { - toggle(); - (children.props as any).onClick?.(e); - }, - }); - } - return ; - } +// Radix Popover ignores userEvent pointer sequences in JSDOM; fireEvent.click opens it. +// Button title-cases string children, hence the case-insensitive matchers. +async function click(el: HTMLElement) { + await act(async () => { + fireEvent.click(el); + }); +} - function PopoverContent({ - children, - className, - }: { - children?: React.ReactNode; - className?: string; - align?: string; - }) { - const { open } = React.useContext(Ctx); - return open ?
{children}
: null; - } +function renderFilter() { + renderWithProviders(, { + providers: ["knowledgeFilter"], + }); +} - return { Popover, PopoverTrigger, PopoverContent }; -}); +async function openPopover() { + await click(screen.getByRole("button", { name: /filter by date range/i })); +} describe("KnowledgeDateRangeFilter", () => { - it("renders the trigger button with accessible label", () => { - renderWithProviders(, { - providers: ["knowledgeFilter"], - }); + it("renders the trigger with the default label", () => { + renderFilter(); expect( screen.getByRole("button", { name: /filter by date range/i }), ).toBeInTheDocument(); - }); - - it("shows Date range label when no range is selected", () => { - renderWithProviders(, { - providers: ["knowledgeFilter"], - }); expect(screen.getByText("Date range")).toBeInTheDocument(); }); - it("opens the popover and shows presets on click", async () => { - const user = userEvent.setup(); - renderWithProviders(, { - providers: ["knowledgeFilter"], - }); - await user.click( - screen.getByRole("button", { name: /filter by date range/i }), - ); - expect(screen.getByText("Last 7 days")).toBeInTheDocument(); - expect(screen.getByText("Last 30 days")).toBeInTheDocument(); - expect(screen.getByText("This month")).toBeInTheDocument(); - }); - - it("applies Last 7 days preset and closes popover", async () => { - const user = userEvent.setup(); - renderWithProviders(, { - providers: ["knowledgeFilter"], - }); - await user.click( - screen.getByRole("button", { name: /filter by date range/i }), - ); - await user.click(screen.getByText("Last 7 days")); - expect(screen.queryByText("Last 7 days")).not.toBeInTheDocument(); - expect( - screen.getByRole("button", { name: /clear date range/i }), - ).toBeInTheDocument(); + it("shows presets when opened", async () => { + renderFilter(); + await openPopover(); + expect(screen.getByText(/last 7 days/i)).toBeInTheDocument(); + expect(screen.getByText(/last 30 days/i)).toBeInTheDocument(); + expect(screen.getByText(/this month/i)).toBeInTheDocument(); }); - it("applies Last 30 days preset and closes popover", async () => { - const user = userEvent.setup(); - renderWithProviders(, { - providers: ["knowledgeFilter"], - }); - await user.click( - screen.getByRole("button", { name: /filter by date range/i }), - ); - await user.click(screen.getByText("Last 30 days")); - expect(screen.queryByText("Last 30 days")).not.toBeInTheDocument(); - expect( - screen.getByRole("button", { name: /clear date range/i }), - ).toBeInTheDocument(); - }); - - it("applies This month preset and closes popover", async () => { - const user = userEvent.setup(); - renderWithProviders(, { - providers: ["knowledgeFilter"], - }); - await user.click( - screen.getByRole("button", { name: /filter by date range/i }), - ); - await user.click(screen.getByText("This month")); - expect(screen.queryByText("This month")).not.toBeInTheDocument(); + it.each([ + /last 7 days/i, + /last 30 days/i, + /this month/i, + ])("applies the %s preset and closes the popover", async (preset) => { + renderFilter(); + await openPopover(); + await click(screen.getByText(preset)); + expect(screen.queryByText(preset)).not.toBeInTheDocument(); + expect(screen.queryByText("Date range")).not.toBeInTheDocument(); expect( screen.getByRole("button", { name: /clear date range/i }), ).toBeInTheDocument(); }); - it("clears the date range when the clear button is clicked", async () => { - const user = userEvent.setup(); - renderWithProviders(, { - providers: ["knowledgeFilter"], - }); - await user.click( - screen.getByRole("button", { name: /filter by date range/i }), - ); - await user.click(screen.getByText("Last 7 days")); - await user.click(screen.getByRole("button", { name: /clear date range/i })); + it("clears the range from the external clear button", async () => { + renderFilter(); + await openPopover(); + await click(screen.getByText(/last 7 days/i)); + await click(screen.getByRole("button", { name: /clear date range/i })); expect( screen.queryByRole("button", { name: /clear date range/i }), ).not.toBeInTheDocument(); expect(screen.getByText("Date range")).toBeInTheDocument(); }); - it("shows a date range label in the trigger after selecting a preset", async () => { - const user = userEvent.setup(); - renderWithProviders(, { - providers: ["knowledgeFilter"], - }); - await user.click( - screen.getByRole("button", { name: /filter by date range/i }), - ); - await user.click(screen.getByText("Last 7 days")); - expect(screen.queryByText("Date range")).not.toBeInTheDocument(); + it("clears the range from the Clear button inside the popover", async () => { + renderFilter(); + await openPopover(); + await click(screen.getByText(/last 7 days/i)); + const trigger = screen + .getAllByRole("button") + .find((b) => b.getAttribute("aria-haspopup") === "dialog"); + if (!trigger) throw new Error("trigger not found"); + await click(trigger); + await click(screen.getByRole("button", { name: /^clear$/i })); + expect(screen.getByText("Date range")).toBeInTheDocument(); }); - it("shows a Clear button inside the popover when a range is active", async () => { - const user = userEvent.setup(); - renderWithProviders(, { - providers: ["knowledgeFilter"], - }); - await user.click( - screen.getByRole("button", { name: /filter by date range/i }), - ); - await user.click(screen.getByText("Last 7 days")); - - // Re-open: click trigger again (no longer has aria-label "filter by date range" - // because the label changed to show dates — find by role instead) - const buttons = screen.getAllByRole("button"); - const triggerBtn = buttons.find( - (b) => - b.getAttribute("aria-label") !== "Clear date range" && - !b.textContent?.includes("Clear"), - ); - if (triggerBtn) await user.click(triggerBtn); - - const clearButtons = screen.getAllByRole("button", { name: /clear/i }); - expect(clearButtons.length).toBeGreaterThanOrEqual(1); + it("selects a range by clicking two calendar days", async () => { + renderFilter(); + await openPopover(); + const enabledDays = screen + .getAllByRole("gridcell") + .filter( + (cell) => + cell.tagName === "BUTTON" && + !cell.hasAttribute("disabled") && + !cell.className.includes("day-outside"), + ); + await click(enabledDays[0]); + await click(enabledDays[1]); + expect(screen.queryByText("Date range")).not.toBeInTheDocument(); }); }); From ac6dabaa777f57128bc490b9b12f540f367f9c99 Mon Sep 17 00:00:00 2001 From: Visvesh Jegadheesh Date: Thu, 8 Oct 2026 10:27:09 -0700 Subject: [PATCH 07/38] calendar range fixing --- .../knowledge-date-range-filter.test.tsx | 22 +++++++---- src/api/files.py | 17 +++++++-- .../api/test_files_timestamp_validation.py | 37 +++++++++++++++++++ 3 files changed, 65 insertions(+), 11 deletions(-) create mode 100644 tests/unit/api/test_files_timestamp_validation.py diff --git a/frontend/components/knowledge-date-range-filter.test.tsx b/frontend/components/knowledge-date-range-filter.test.tsx index 6ad5f64e22..a1dac05388 100644 --- a/frontend/components/knowledge-date-range-filter.test.tsx +++ b/frontend/components/knowledge-date-range-filter.test.tsx @@ -33,9 +33,15 @@ describe("KnowledgeDateRangeFilter", () => { it("shows presets when opened", async () => { renderFilter(); await openPopover(); - expect(screen.getByText(/last 7 days/i)).toBeInTheDocument(); - expect(screen.getByText(/last 30 days/i)).toBeInTheDocument(); - expect(screen.getByText(/this month/i)).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /last 7 days/i }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /last 30 days/i }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /this month/i }), + ).toBeInTheDocument(); }); it.each([ @@ -45,8 +51,10 @@ describe("KnowledgeDateRangeFilter", () => { ])("applies the %s preset and closes the popover", async (preset) => { renderFilter(); await openPopover(); - await click(screen.getByText(preset)); - expect(screen.queryByText(preset)).not.toBeInTheDocument(); + await click(screen.getByRole("button", { name: preset })); + expect( + screen.queryByRole("button", { name: preset }), + ).not.toBeInTheDocument(); expect(screen.queryByText("Date range")).not.toBeInTheDocument(); expect( screen.getByRole("button", { name: /clear date range/i }), @@ -56,7 +64,7 @@ describe("KnowledgeDateRangeFilter", () => { it("clears the range from the external clear button", async () => { renderFilter(); await openPopover(); - await click(screen.getByText(/last 7 days/i)); + await click(screen.getByRole("button", { name: /last 7 days/i })); await click(screen.getByRole("button", { name: /clear date range/i })); expect( screen.queryByRole("button", { name: /clear date range/i }), @@ -67,7 +75,7 @@ describe("KnowledgeDateRangeFilter", () => { it("clears the range from the Clear button inside the popover", async () => { renderFilter(); await openPopover(); - await click(screen.getByText(/last 7 days/i)); + await click(screen.getByRole("button", { name: /last 7 days/i })); const trigger = screen .getAllByRole("button") .find((b) => b.getAttribute("aria-haspopup") === "dialog"); diff --git a/src/api/files.py b/src/api/files.py index 8194fee7b1..00ff97ca58 100644 --- a/src/api/files.py +++ b/src/api/files.py @@ -8,6 +8,7 @@ """ import json +import re from datetime import datetime from fastapi import Depends, HTTPException, Query @@ -19,18 +20,26 @@ logger = get_logger(__name__) +# fromisoformat also accepts basic forms (e.g. "20240101"), which OpenSearch's +# strict_date_optional_time mapping on indexed_time rejects. +_EXTENDED_ISO_RE = re.compile( + r"^\d{4}-\d{2}-\d{2}" + r"(T\d{2}:\d{2}(:\d{2}(\.\d{1,6})?)?)?" + r"(Z|[+-]\d{2}:\d{2})?$" +) + def _validate_iso_timestamp(value: str | None, param_name: str) -> None: """Raise HTTP 422 when *value* is present but is not a valid ISO 8601 timestamp.""" if not isinstance(value, str): return + detail = f"{param_name} must be a valid ISO 8601 timestamp" + if not _EXTENDED_ISO_RE.match(value): + raise HTTPException(status_code=422, detail=detail) try: datetime.fromisoformat(value.replace("Z", "+00:00")) except ValueError as err: - raise HTTPException( - status_code=422, - detail=f"{param_name} must be a valid ISO 8601 timestamp", - ) from err + raise HTTPException(status_code=422, detail=detail) from err def _parse_after_key(after_key: str | None) -> dict | None: diff --git a/tests/unit/api/test_files_timestamp_validation.py b/tests/unit/api/test_files_timestamp_validation.py new file mode 100644 index 0000000000..47b4001665 --- /dev/null +++ b/tests/unit/api/test_files_timestamp_validation.py @@ -0,0 +1,37 @@ +import pytest +from fastapi import HTTPException + +from api.files import _validate_iso_timestamp + + +@pytest.mark.parametrize( + "value", + [ + None, + "2024-01-01", + "2024-01-01T00:00", + "2024-01-01T00:00:00", + "2024-01-01T00:00:00.000Z", + "2024-01-31T23:59:59.999+05:30", + ], +) +def test_accepts_extended_iso_forms(value): + _validate_iso_timestamp(value, "created_after") + + +@pytest.mark.parametrize( + "value", + [ + "20240101", + "20240101T000000", + "2024-01-01T000000", + "2024-13-01", + "not-a-date", + "", + ], +) +def test_rejects_basic_and_invalid_forms(value): + with pytest.raises(HTTPException) as exc: + _validate_iso_timestamp(value, "created_after") + assert exc.value.status_code == 422 + assert "created_after" in exc.value.detail From 6edeee218a78e04bc9010ea6afe3c48dbdd7ed1d Mon Sep 17 00:00:00 2001 From: Visvesh Jegadheesh Date: Thu, 8 Oct 2026 10:38:58 -0700 Subject: [PATCH 08/38] pin date in calendar range test --- .../knowledge-date-range-filter.test.tsx | 59 ++++++++++++++----- 1 file changed, 45 insertions(+), 14 deletions(-) diff --git a/frontend/components/knowledge-date-range-filter.test.tsx b/frontend/components/knowledge-date-range-filter.test.tsx index a1dac05388..0db9c29143 100644 --- a/frontend/components/knowledge-date-range-filter.test.tsx +++ b/frontend/components/knowledge-date-range-filter.test.tsx @@ -1,5 +1,5 @@ import { act, fireEvent } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { renderWithProviders, screen } from "@/test-utils/render"; import { KnowledgeDateRangeFilter } from "./knowledge-date-range-filter"; @@ -85,19 +85,50 @@ describe("KnowledgeDateRangeFilter", () => { expect(screen.getByText("Date range")).toBeInTheDocument(); }); - it("selects a range by clicking two calendar days", async () => { - renderFilter(); - await openPopover(); - const enabledDays = screen - .getAllByRole("gridcell") - .filter( - (cell) => - cell.tagName === "BUTTON" && - !cell.hasAttribute("disabled") && - !cell.className.includes("day-outside"), + describe("calendar range selection", () => { + // Only Date is faked so act()/Radix timers keep running normally. + beforeEach(() => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date(2024, 5, 20, 12)); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it("selects a range by clicking two calendar days", async () => { + renderFilter(); + await openPopover(); + // June 2024 is the first month shown; July is fully disabled (after today). + const juneDay = (day: number) => { + const cell = screen + .getAllByRole("gridcell") + .find( + (c) => + c.tagName === "BUTTON" && + !c.hasAttribute("disabled") && + !c.className.includes("day-outside") && + c.textContent === String(day), + ); + if (!cell) throw new Error(`June ${day} not selectable`); + return cell; + }; + await click(juneDay(10)); + await click(juneDay(15)); + + const fmt = (d: Date) => + d.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + year: "numeric", + }); + const expected = `${fmt(new Date(2024, 5, 10))} – ${fmt(new Date(2024, 5, 15))}`; + expect(screen.queryByText("Date range")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: expected })).toHaveTextContent( + expected, ); - await click(enabledDays[0]); - await click(enabledDays[1]); - expect(screen.queryByText("Date range")).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: /last 7 days/i }), + ).not.toBeInTheDocument(); + }); }); }); From 5a4f9919e6e6c4611b288ebde85edcf20c782f19 Mon Sep 17 00:00:00 2001 From: Visvesh Jegadheesh Date: Thu, 8 Oct 2026 14:33:02 -0700 Subject: [PATCH 09/38] pass date range to search API server-side instead of filtering on client side --- frontend/app/api/queries/useGetSearchQuery.ts | 11 +++++- frontend/app/knowledge/page.tsx | 36 +++++-------------- src/api/search.py | 16 +++++++-- src/services/search_service.py | 16 +++++++++ 4 files changed, 48 insertions(+), 31 deletions(-) diff --git a/frontend/app/api/queries/useGetSearchQuery.ts b/frontend/app/api/queries/useGetSearchQuery.ts index a22b89589d..d3d5ada407 100644 --- a/frontend/app/api/queries/useGetSearchQuery.ts +++ b/frontend/app/api/queries/useGetSearchQuery.ts @@ -17,6 +17,8 @@ export interface SearchPayload { owners?: string[]; connector_types?: string[]; }; + created_after?: string; + created_before?: string; } export interface ChunkResult { @@ -125,6 +127,7 @@ export const useGetSearchQuery = ( UseQueryOptions, "queryKey" | "queryFn" >, + dateRange?: { createdAfter?: string; createdBefore?: string }, ) => { const queryClient = useQueryClient(); @@ -162,6 +165,12 @@ export const useGetSearchQuery = ( searchPayload.filters = buildSearchPayloadFilters(queryData.filters) ?? undefined; } + if (dateRange?.createdAfter) { + searchPayload.created_after = dateRange.createdAfter; + } + if (dateRange?.createdBefore) { + searchPayload.created_before = dateRange.createdBefore; + } const response = await fetch(`/api/search`, { method: "POST", @@ -278,7 +287,7 @@ export const useGetSearchQuery = ( return useQuery( { - queryKey: ["search", queryData, query], + queryKey: ["search", queryData, query, dateRange], placeholderData: (prev) => prev, staleTime: 0, queryFn: getFiles, diff --git a/frontend/app/knowledge/page.tsx b/frontend/app/knowledge/page.tsx index e04d34079b..a4c2cb3f52 100644 --- a/frontend/app/knowledge/page.tsx +++ b/frontend/app/knowledge/page.tsx @@ -668,9 +668,12 @@ function SearchPage() { isLoading: isSearchLoading, error: searchError, isError: isSearchError, - } = useGetSearchQuery(queryOverride, parsedFilterData, { - enabled: !isWildcardQuery, - }); + } = useGetSearchQuery( + queryOverride, + parsedFilterData, + { enabled: !isWildcardQuery }, + { createdAfter, createdBefore }, + ); const { files: searchFiles, warnings: searchWarnings } = searchData as SearchResult; @@ -681,32 +684,9 @@ function SearchPage() { const error = isWildcardQuery ? listFilesError : searchError; const isError = isWildcardQuery ? isListFilesError : isSearchError; - // When in search mode, filter client-side by date so the range picker applies - // to both list and search results. Compute bound timestamps once outside the - // predicate to avoid repeated Date construction per file. - const createdAfterMs = createdAfter - ? new Date(createdAfter).getTime() - : undefined; - const createdBeforeMs = createdBefore - ? new Date(createdBefore).getTime() - : undefined; - const dateFilteredSearchFiles = isWildcardQuery - ? [] - : searchFiles.filter((file) => { - if (createdAfterMs === undefined && createdBeforeMs === undefined) - return true; - const t = file.indexed_time - ? new Date(file.indexed_time).getTime() - : null; - if (t === null || Number.isNaN(t)) return true; - if (createdAfterMs !== undefined && t < createdAfterMs) return false; - if (createdBeforeMs !== undefined && t > createdBeforeMs) return false; - return true; - }); - const effectiveData: File[] = isWildcardQuery ? (listFilesData?.files ?? []) - : dateFilteredSearchFiles.slice( + : searchFiles.slice( (currentPage - 1) * currentPageSize, currentPage * currentPageSize, ); @@ -782,7 +762,7 @@ function SearchPage() { const serverTotal = isWildcardQuery ? (listFilesData?.total ?? 0) - : dateFilteredSearchFiles.length; + : searchFiles.length; const gridRows: File[] = fileResults; const totalPages = Math.max(1, Math.ceil(serverTotal / currentPageSize)); diff --git a/src/api/search.py b/src/api/search.py index 4ebdea7642..80ec15dd50 100644 --- a/src/api/search.py +++ b/src/api/search.py @@ -1,4 +1,4 @@ -from typing import Any, Dict +from typing import Any, Dict, Optional from fastapi import Depends from pydantic import BaseModel, Field @@ -13,6 +13,7 @@ require_permission, ) from session_manager import User +from api.files import _validate_iso_timestamp logger = get_logger(__name__) @@ -22,6 +23,8 @@ class SearchBody(BaseModel): filters: Dict[str, Any] = Field(default_factory=dict) limit: int = 10 scoreThreshold: float = Field(default=0, alias="scoreThreshold") + created_after: Optional[str] = None + created_before: Optional[str] = None model_config = {"populate_by_name": True} @@ -33,6 +36,15 @@ async def search( user: User = Depends(require_permission("search:use")), ): """Search for documents""" + _validate_iso_timestamp(body.created_after, "created_after") + _validate_iso_timestamp(body.created_before, "created_before") + + merged_filters: Dict[str, Any] = dict(body.filters) + if body.created_after: + merged_filters["created_after"] = body.created_after + if body.created_before: + merged_filters["created_before"] = body.created_before + try: jwt_token = user.jwt_token @@ -50,7 +62,7 @@ async def search( body.query, user_id=user.user_id, jwt_token=jwt_token, - filters=body.filters, + filters=merged_filters if merged_filters else None, limit=body.limit, score_threshold=body.scoreThreshold, ) diff --git a/src/services/search_service.py b/src/services/search_service.py index d5696d0ba3..591b703a67 100644 --- a/src/services/search_service.py +++ b/src/services/search_service.py @@ -345,6 +345,14 @@ async def search_tool(self, query: str, embedding_model: str = None) -> dict[str # Multiple values filter filter_clauses.append({"terms": {field_name: values}}) + date_range_clause: dict[str, Any] = {} + if filters.get("created_after"): + date_range_clause["gte"] = filters["created_after"] + if filters.get("created_before"): + date_range_clause["lte"] = filters["created_before"] + if date_range_clause: + filter_clauses.append({"range": {"indexed_time": date_range_clause}}) + try: seen_spaces: set[str] = set() for legacy in (False, True): @@ -527,6 +535,14 @@ async def embed_with_space( # Multiple values filter filter_clauses.append({"terms": {field_name: values}}) + date_range_clause: dict[str, Any] = {} + if filters.get("created_after"): + date_range_clause["gte"] = filters["created_after"] + if filters.get("created_before"): + date_range_clause["lte"] = filters["created_before"] + if date_range_clause: + filter_clauses.append({"range": {"indexed_time": date_range_clause}}) + # Build query body if is_wildcard_match_all: # Match all documents; still allow filters to narrow scope From 0e0479cb1fbc254596d4f9e728f135ee63395348 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 8 Oct 2026 21:33:45 +0000 Subject: [PATCH 10/38] style: ruff autofix (auto) --- src/api/search.py | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/src/api/search.py b/src/api/search.py index 80ec15dd50..68f0f1a6cb 100644 --- a/src/api/search.py +++ b/src/api/search.py @@ -1,30 +1,30 @@ from typing import Any, Dict, Optional from fastapi import Depends -from pydantic import BaseModel, Field from fastapi.responses import JSONResponse -from utils.logging_config import get_logger -from utils.opensearch_utils import OpenSearchDiskSpaceError, DISK_SPACE_ERROR_MESSAGE +from pydantic import BaseModel, Field +from api.files import _validate_iso_timestamp from dependencies import ( + get_current_user, get_search_service, get_session_manager, - get_current_user, require_permission, ) from session_manager import User -from api.files import _validate_iso_timestamp +from utils.logging_config import get_logger +from utils.opensearch_utils import DISK_SPACE_ERROR_MESSAGE, OpenSearchDiskSpaceError logger = get_logger(__name__) class SearchBody(BaseModel): query: str - filters: Dict[str, Any] = Field(default_factory=dict) + filters: dict[str, Any] = Field(default_factory=dict) limit: int = 10 scoreThreshold: float = Field(default=0, alias="scoreThreshold") - created_after: Optional[str] = None - created_before: Optional[str] = None + created_after: str | None = None + created_before: str | None = None model_config = {"populate_by_name": True} @@ -39,7 +39,7 @@ async def search( _validate_iso_timestamp(body.created_after, "created_after") _validate_iso_timestamp(body.created_before, "created_before") - merged_filters: Dict[str, Any] = dict(body.filters) + merged_filters: dict[str, Any] = dict(body.filters) if body.created_after: merged_filters["created_after"] = body.created_after if body.created_before: @@ -71,10 +71,7 @@ async def search( return JSONResponse({"error": DISK_SPACE_ERROR_MESSAGE}, status_code=507) except Exception as e: error_msg = str(e) - if ( - "AuthenticationException" in error_msg - or "access denied" in error_msg.lower() - ): + if "AuthenticationException" in error_msg or "access denied" in error_msg.lower(): return JSONResponse({"error": error_msg}, status_code=403) else: return JSONResponse({"error": error_msg}, status_code=500) From 7afb03173807dd178690864d45e31090dff24b70 Mon Sep 17 00:00:00 2001 From: Visvesh Jegadheesh Date: Thu, 8 Oct 2026 14:36:09 -0700 Subject: [PATCH 11/38] fix lint backend --- src/api/search.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api/search.py b/src/api/search.py index 68f0f1a6cb..766ee1a0c1 100644 --- a/src/api/search.py +++ b/src/api/search.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, Optional +from typing import Any from fastapi import Depends from fastapi.responses import JSONResponse From 37cc58747b6b2d3c1a4ca20881d8fe1fc2cf3904 Mon Sep 17 00:00:00 2001 From: Visvesh Jegadheesh Date: Thu, 8 Oct 2026 14:38:55 -0700 Subject: [PATCH 12/38] remove unused imports --- src/api/search.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/api/search.py b/src/api/search.py index 766ee1a0c1..12b143e925 100644 --- a/src/api/search.py +++ b/src/api/search.py @@ -6,7 +6,6 @@ from api.files import _validate_iso_timestamp from dependencies import ( - get_current_user, get_search_service, get_session_manager, require_permission, From ea3fe7e6411f24248affb92e1cee9cddbc508312 Mon Sep 17 00:00:00 2001 From: Visvesh Jegadheesh Date: Thu, 8 Oct 2026 14:42:11 -0700 Subject: [PATCH 13/38] remove duplicate type annotation --- src/services/search_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/search_service.py b/src/services/search_service.py index 591b703a67..a1782dd1b7 100644 --- a/src/services/search_service.py +++ b/src/services/search_service.py @@ -535,7 +535,7 @@ async def embed_with_space( # Multiple values filter filter_clauses.append({"terms": {field_name: values}}) - date_range_clause: dict[str, Any] = {} + date_range_clause = {} if filters.get("created_after"): date_range_clause["gte"] = filters["created_after"] if filters.get("created_before"): From 7ba998d528c7de722832b43250d4b3b18da7bbbe Mon Sep 17 00:00:00 2001 From: Visvesh Jegadheesh Date: Mon, 12 Oct 2026 12:20:11 -0700 Subject: [PATCH 14/38] feat: validation, SDK parity, timezone docs, narrow-view fix --- frontend/app/knowledge/page.tsx | 8 +- .../knowledge-date-range-filter.tsx | 22 +-- frontend/lib/time-utils.test.ts | 68 +++++++-- frontend/lib/time-utils.ts | 7 +- frontend/package-lock.json | 2 +- frontend/package.json | 1 + sdks/python/openrag_sdk/documents.py | 10 ++ sdks/typescript/src/documents.ts | 2 + sdks/typescript/src/types.ts | 4 + src/api/files.py | 40 ++++-- src/api/search.py | 7 +- src/services/search_service.py | 29 ++-- .../api/test_files_timestamp_validation.py | 130 +++++++++++++++++- 13 files changed, 274 insertions(+), 56 deletions(-) diff --git a/frontend/app/knowledge/page.tsx b/frontend/app/knowledge/page.tsx index c9e652c0db..1013f69327 100644 --- a/frontend/app/knowledge/page.tsx +++ b/frontend/app/knowledge/page.tsx @@ -57,8 +57,8 @@ import { } from "@/lib/knowledge-table-state"; import { parseTimestampMs, - toUtcDayEndISOString, - toUtcDayStartISOString, + toLocalDayEndISOString, + toLocalDayStartISOString, } from "@/lib/time-utils"; import { cn } from "@/lib/utils"; import { @@ -625,10 +625,10 @@ function SearchPage() { const filterPageResetKey = buildFilterPageResetKey(parsedFilterData); const createdAfter = dateRange?.from - ? toUtcDayStartISOString(dateRange.from) + ? toLocalDayStartISOString(dateRange.from) : undefined; const createdBefore = dateRange?.to - ? toUtcDayEndISOString(dateRange.to) + ? toLocalDayEndISOString(dateRange.to) : undefined; const { diff --git a/frontend/components/knowledge-date-range-filter.tsx b/frontend/components/knowledge-date-range-filter.tsx index 688f0b3b9e..fa721b4a3e 100644 --- a/frontend/components/knowledge-date-range-filter.tsx +++ b/frontend/components/knowledge-date-range-filter.tsx @@ -65,21 +65,23 @@ export function KnowledgeDateRangeFilter() { : null; return ( -
+
@@ -130,7 +132,7 @@ export function KnowledgeDateRangeFilter() { - -
- - - + +
+
+

+ Quick ranges +

+ {/* A scrolling row on narrow screens, a list beside the calendar otherwise. */} +
+ {PRESETS.map((preset) => { + const count = presetCount(preset.range(today)); + const active = preset.id === activePresetId; + return ( + + ); + })} +
+
+
+ +
+ + 1 upload + + + + 2–4 + + + + 5+ + +
+
- - {dateRange && ( -
- +
- )} +
{dateRange && ( diff --git a/frontend/components/ui/calendar.tsx b/frontend/components/ui/calendar.tsx index a295213ffe..c3bf9fb9cd 100644 --- a/frontend/components/ui/calendar.tsx +++ b/frontend/components/ui/calendar.tsx @@ -12,6 +12,7 @@ function Calendar({ className, classNames, showOutsideDays = true, + components, ...props }: CalendarProps) { return ( @@ -63,6 +64,7 @@ function Calendar({ components={{ IconLeft: () => , IconRight: () => , + ...components, }} {...props} /> diff --git a/frontend/lib/time-utils.ts b/frontend/lib/time-utils.ts index 6db44f5a99..69041ce853 100644 --- a/frontend/lib/time-utils.ts +++ b/frontend/lib/time-utils.ts @@ -79,3 +79,11 @@ export function toLocalDayEndISOString(date: Date): string { d.setHours(23, 59, 59, 999); return d.toISOString(); } + +// Matches the yyyy-MM-dd keys the upload histogram buckets by (viewer's time zone). +export function toLocalDayKey(date: Date): string { + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, "0"); + const d = String(date.getDate()).padStart(2, "0"); + return `${y}-${m}-${d}`; +} diff --git a/src/api/files.py b/src/api/files.py index 2de1de5eaa..c485ff8133 100644 --- a/src/api/files.py +++ b/src/api/files.py @@ -10,6 +10,7 @@ import json import re from datetime import UTC, datetime +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from fastapi import Depends, HTTPException, Query from fastapi.responses import JSONResponse @@ -182,3 +183,46 @@ async def search_files( {"error": "Failed to search files"}, status_code=500, ) + + +async def upload_histogram( + start: str = Query(..., description="Window start (ISO 8601, inclusive)"), + end: str = Query(..., description="Window end (ISO 8601, inclusive)"), + tz: str = Query("UTC", description="IANA time zone that defines calendar days"), + connector_type: list[str] | None = Query( + None, description="Filter by connector type (repeatable)" + ), + mimetype: list[str] | None = Query(None, description="Filter by MIME type (repeatable)"), + owner: list[str] | None = Query(None, description="Filter by owner (repeatable)"), + data_sources: list[str] | None = Query(None, description="Filename whitelist (repeatable)"), + file_service=Depends(get_file_service_v2), + user: User = Depends(get_current_user), +): + """Count distinct uploaded files per calendar day, plus the all-time total.""" + if _parse_iso_timestamp(start, "start") > _parse_iso_timestamp(end, "end"): + raise HTTPException(status_code=422, detail="start must be earlier than or equal to end") + try: + ZoneInfo(tz) + except (ZoneInfoNotFoundError, ValueError) as err: + raise HTTPException(status_code=422, detail="tz must be an IANA time zone") from err + + try: + result = await file_service.upload_histogram( + user_id=user.user_id, + jwt_token=user.jwt_token, + start=start, + end=end, + time_zone=tz, + connector_type=connector_type, + mimetype=mimetype, + owner=owner, + data_sources=data_sources, + ) + return JSONResponse(result) + except Exception as e: + logger.error("Failed to build upload histogram", error=str(e)) + from utils.opensearch_utils import AUTH_ERROR_MESSAGE, is_opensearch_auth_error + + if is_opensearch_auth_error(e): + return JSONResponse({"error": AUTH_ERROR_MESSAGE}, status_code=401) + return JSONResponse({"error": "Failed to build upload histogram"}, status_code=500) diff --git a/src/app/routes/internal.py b/src/app/routes/internal.py index a2ca7f93bc..6f80850974 100644 --- a/src/app/routes/internal.py +++ b/src/app/routes/internal.py @@ -161,6 +161,9 @@ def register_internal_routes(app: FastAPI): # File listing/search endpoints app.add_api_route("/files", files.list_files, methods=["GET"], tags=["internal"]) app.add_api_route("/files/search", files.search_files, methods=["GET"], tags=["internal"]) + app.add_api_route( + "/files/upload-histogram", files.upload_histogram, methods=["GET"], tags=["internal"] + ) # Knowledge Filter endpoints app.add_api_route( diff --git a/src/services/file_service_v2.py b/src/services/file_service_v2.py index a0b6ae8d54..2c5e9b9040 100644 --- a/src/services/file_service_v2.py +++ b/src/services/file_service_v2.py @@ -151,6 +151,66 @@ async def list_files( "after_key": next_after_key, } + async def upload_histogram( + self, + user_id: str, + jwt_token: str = None, + start: str = "", + end: str = "", + time_zone: str = "UTC", + connector_type: list[str] | None = None, + mimetype: list[str] | None = None, + owner: list[str] | None = None, + data_sources: list[str] | None = None, + ) -> dict[str, Any]: + """Distinct files per calendar day (in *time_zone*) within [start, end], plus the all-time total.""" + opensearch_client = self.session_manager.get_user_opensearch_client(user_id, jwt_token) + query = self._build_filter_query( + user_id, connector_type, mimetype, owner, None, data_sources + ) + # The window is a filter sub-agg rather than part of the query so the + # same request also yields the all-time total. + body = { + "size": 0, + "query": query, + "aggs": { + "total": {"cardinality": {"field": "filename", "precision_threshold": 3000}}, + "window": { + "filter": build_date_range_query("indexed_time", gte=start, lte=end), + "aggs": { + "days": { + "date_histogram": { + "field": "indexed_time", + "calendar_interval": "day", + "time_zone": time_zone, + "format": "yyyy-MM-dd", + "min_doc_count": 1, + }, + "aggs": { + "files": { + "cardinality": { + "field": "filename", + "precision_threshold": 3000, + } + } + }, + } + }, + }, + }, + } + result = await opensearch_client.search(index=get_index_name(), body=body) + aggs = result.get("aggregations", {}) + buckets = aggs.get("window", {}).get("days", {}).get("buckets", []) + return { + "total": aggs.get("total", {}).get("value", 0), + "days": { + b["key_as_string"]: b.get("files", {}).get("value", 0) + for b in buckets + if b.get("key_as_string") + }, + } + async def _list_files_by_chunk_count( self, opensearch_client: Any, diff --git a/tests/unit/api/test_files_upload_histogram.py b/tests/unit/api/test_files_upload_histogram.py new file mode 100644 index 0000000000..1386c4c368 --- /dev/null +++ b/tests/unit/api/test_files_upload_histogram.py @@ -0,0 +1,110 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest +from fastapi import HTTPException + +from api.files import upload_histogram +from services.file_service_v2 import FileServiceV2 + +USER = SimpleNamespace(user_id="u1", jwt_token="jwt") + + +def _call(file_service, **overrides): + params = { + "start": "2024-05-01T00:00:00Z", + "end": "2024-06-20T23:59:59Z", + "tz": "America/New_York", + "connector_type": None, + "mimetype": None, + "owner": None, + "data_sources": None, + "file_service": file_service, + "user": USER, + } + params.update(overrides) + return upload_histogram(**params) + + +@pytest.mark.asyncio +async def test_passes_window_timezone_and_filters_to_service(): + service = MagicMock() + service.upload_histogram = AsyncMock(return_value={"total": 2, "days": {"2024-06-01": 2}}) + + response = await _call(service, owner=["u1"]) + + assert response.status_code == 200 + kwargs = service.upload_histogram.await_args.kwargs + assert kwargs["time_zone"] == "America/New_York" + assert kwargs["start"] == "2024-05-01T00:00:00Z" + assert kwargs["owner"] == ["u1"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "overrides", + [ + {"start": "20240501"}, + {"end": "not-a-date"}, + {"start": "2024-07-01T00:00:00Z"}, + {"tz": "Mars/Olympus_Mons"}, + ], +) +async def test_rejects_bad_input(overrides): + service = MagicMock() + service.upload_histogram = AsyncMock() + with pytest.raises(HTTPException) as exc: + await _call(service, **overrides) + assert exc.value.status_code == 422 + service.upload_histogram.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_returns_500_when_service_fails(): + service = MagicMock() + service.upload_histogram = AsyncMock(side_effect=RuntimeError("down")) + response = await _call(service) + assert response.status_code == 500 + + +@pytest.mark.asyncio +async def test_service_buckets_by_local_day_and_keeps_all_time_total(): + client = MagicMock() + client.search = AsyncMock( + return_value={ + "aggregations": { + "total": {"value": 7}, + "window": { + "days": { + "buckets": [ + {"key_as_string": "2024-06-01", "files": {"value": 2}}, + {"key_as_string": "2024-06-03", "files": {"value": 5}}, + ] + } + }, + } + } + ) + session_manager = MagicMock() + session_manager.get_user_opensearch_client.return_value = client + + result = await FileServiceV2(session_manager).upload_histogram( + user_id="u1", + start="2024-05-01T00:00:00Z", + end="2024-06-20T23:59:59Z", + time_zone="Asia/Kolkata", + connector_type=["local"], + ) + + assert result == {"total": 7, "days": {"2024-06-01": 2, "2024-06-03": 5}} + body = client.search.await_args.kwargs["body"] + # The date window narrows only the histogram, never the all-time total. + assert "range" not in str(body["query"]) + assert {"term": {"connector_type": "local"}} in body["query"]["bool"]["filter"] + window = body["aggs"]["window"] + assert window["filter"] == { + "range": {"indexed_time": {"gte": "2024-05-01T00:00:00Z", "lte": "2024-06-20T23:59:59Z"}} + } + histogram = window["aggs"]["days"]["date_histogram"] + assert histogram["time_zone"] == "Asia/Kolkata" + assert histogram["calendar_interval"] == "day" From 0f759b72606cc42ff904e98bc39a4c7294d20916 Mon Sep 17 00:00:00 2001 From: Visvesh Jegadheesh Date: Wed, 14 Oct 2026 14:57:15 -0700 Subject: [PATCH 16/38] fix: inclusive date-bound docs and date filtering for file search --- src/api/files.py | 24 ++++- src/api/v2/files.py | 11 +- src/services/file_service_v2.py | 4 + .../unit/api/test_files_search_date_range.py | 101 ++++++++++++++++++ 4 files changed, 132 insertions(+), 8 deletions(-) create mode 100644 tests/unit/api/test_files_search_date_range.py diff --git a/src/api/files.py b/src/api/files.py index c485ff8133..7b5b45a7cb 100644 --- a/src/api/files.py +++ b/src/api/files.py @@ -30,6 +30,19 @@ ) +CREATED_AFTER_DESCRIPTION = ( + "Inclusive lower bound on indexed_time (ISO 8601). " + "A value with no time is UTC midnight at the start of that day. " + "Documents with no indexed_time are excluded." +) +CREATED_BEFORE_DESCRIPTION = ( + "Inclusive upper bound on indexed_time (ISO 8601). " + "A value with no time is UTC midnight at the start of that day, " + "so pass the next day to include the chosen day. " + "Documents with no indexed_time are excluded." +) + + def _parse_iso_timestamp(value: str, param_name: str) -> datetime: """Parse an extended ISO 8601 timestamp, raising HTTP 422 when it is not one.""" detail = f"{param_name} must be a valid ISO 8601 timestamp" @@ -101,10 +114,8 @@ async def list_files( search: str | None = Query(None, description="Search filename"), after_key: str | None = Query(None, description="Composite pagination cursor (JSON-encoded)"), data_sources: list[str] | None = Query(None, description="Filename whitelist (repeatable)"), - created_after: str | None = Query(None, description="Filter files created after ISO timestamp"), - created_before: str | None = Query( - None, description="Filter files created before ISO timestamp" - ), + created_after: str | None = Query(None, description=CREATED_AFTER_DESCRIPTION), + created_before: str | None = Query(None, description=CREATED_BEFORE_DESCRIPTION), file_service=Depends(get_file_service_v2), user: User = Depends(get_current_user), ): @@ -153,10 +164,13 @@ async def search_files( owner: list[str] | None = Query(None, description="Filter by owner (repeatable)"), after_key: str | None = Query(None, description="Composite pagination cursor (JSON-encoded)"), data_sources: list[str] | None = Query(None, description="Filename whitelist (repeatable)"), + created_after: str | None = Query(None, description=CREATED_AFTER_DESCRIPTION), + created_before: str | None = Query(None, description=CREATED_BEFORE_DESCRIPTION), file_service=Depends(get_file_service_v2), user: User = Depends(get_current_user), ): """Search files by name with fuzzy/partial matching (composite pagination).""" + _validate_date_range(created_after, created_before) parsed_after_key = _parse_after_key(after_key) try: @@ -171,6 +185,8 @@ async def search_files( owner=owner, after_key=parsed_after_key, data_sources=data_sources, + created_after=created_after, + created_before=created_before, ) return JSONResponse(result) except Exception as e: diff --git a/src/api/v2/files.py b/src/api/v2/files.py index c09b39f159..d7c9338250 100644 --- a/src/api/v2/files.py +++ b/src/api/v2/files.py @@ -9,6 +9,7 @@ from fastapi import Depends, Query +from api.files import CREATED_AFTER_DESCRIPTION, CREATED_BEFORE_DESCRIPTION from api.files import list_files as _internal_list_files from api.files import search_files as _internal_search_files from dependencies import get_file_service_v2, require_api_key_permission @@ -30,10 +31,8 @@ async def list_files( search: str | None = Query(None, description="Search filename"), after_key: str | None = Query(None, description="Composite pagination cursor (JSON-encoded)"), data_sources: list[str] | None = Query(None, description="Filename whitelist (repeatable)"), - created_after: str | None = Query(None, description="Filter files created after ISO timestamp"), - created_before: str | None = Query( - None, description="Filter files created before ISO timestamp" - ), + created_after: str | None = Query(None, description=CREATED_AFTER_DESCRIPTION), + created_before: str | None = Query(None, description=CREATED_BEFORE_DESCRIPTION), file_service=Depends(get_file_service_v2), user: User = Depends(require_api_key_permission("knowledge:read:own")), ): @@ -67,6 +66,8 @@ async def search_files( owner: list[str] | None = Query(None, description="Filter by owner (repeatable)"), after_key: str | None = Query(None, description="Composite pagination cursor (JSON-encoded)"), data_sources: list[str] | None = Query(None, description="Filename whitelist (repeatable)"), + created_after: str | None = Query(None, description=CREATED_AFTER_DESCRIPTION), + created_before: str | None = Query(None, description=CREATED_BEFORE_DESCRIPTION), file_service=Depends(get_file_service_v2), user: User = Depends(require_api_key_permission("knowledge:read:own")), ): @@ -80,6 +81,8 @@ async def search_files( owner=owner, after_key=after_key, data_sources=data_sources, + created_after=created_after, + created_before=created_before, file_service=file_service, user=user, ) diff --git a/src/services/file_service_v2.py b/src/services/file_service_v2.py index 2c5e9b9040..4401d7db98 100644 --- a/src/services/file_service_v2.py +++ b/src/services/file_service_v2.py @@ -277,6 +277,8 @@ async def search_files( owner: list[str] | None = None, after_key: dict | None = None, data_sources: list[str] | None = None, + created_after: str | None = None, + created_before: str | None = None, ) -> dict[str, Any]: """Search files by name with fuzzy/prefix matching.""" return await self.list_files( @@ -292,6 +294,8 @@ async def search_files( search=query, after_key=after_key, data_sources=data_sources, + created_after=created_after, + created_before=created_before, ) def _build_filter_query( diff --git a/tests/unit/api/test_files_search_date_range.py b/tests/unit/api/test_files_search_date_range.py new file mode 100644 index 0000000000..103e6d514b --- /dev/null +++ b/tests/unit/api/test_files_search_date_range.py @@ -0,0 +1,101 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest +from fastapi import HTTPException + +from api import files as internal_files +from api.v2 import files as v2_files +from services.file_service_v2 import FileServiceV2 + +USER = SimpleNamespace(user_id="u1", jwt_token="jwt") +AFTER = "2024-01-01T00:00:00Z" +BEFORE = "2024-01-31T23:59:59Z" + + +def _search_kwargs(**overrides): + kwargs = { + "q": "report", + "page": 1, + "page_size": 25, + "connector_type": None, + "mimetype": None, + "owner": None, + "after_key": None, + "data_sources": None, + "created_after": AFTER, + "created_before": BEFORE, + "user": USER, + } + kwargs.update(overrides) + return kwargs + + +def _service(): + service = MagicMock() + service.search_files = AsyncMock(return_value={"files": []}) + return service + + +@pytest.mark.asyncio +async def test_internal_search_forwards_date_bounds(): + service = _service() + response = await internal_files.search_files(**_search_kwargs(file_service=service)) + assert response.status_code == 200 + kwargs = service.search_files.await_args.kwargs + assert kwargs["created_after"] == AFTER + assert kwargs["created_before"] == BEFORE + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "overrides", + [ + {"created_after": BEFORE, "created_before": AFTER}, + {"created_after": "20240101"}, + ], +) +async def test_internal_search_rejects_bad_bounds(overrides): + service = _service() + with pytest.raises(HTTPException) as exc: + await internal_files.search_files(**_search_kwargs(file_service=service, **overrides)) + assert exc.value.status_code == 422 + service.search_files.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_v2_search_forwards_date_bounds(): + service = _service() + await v2_files.search_files(**_search_kwargs(file_service=service)) + kwargs = service.search_files.await_args.kwargs + assert kwargs["created_after"] == AFTER + assert kwargs["created_before"] == BEFORE + + +@pytest.mark.asyncio +async def test_service_search_passes_bounds_to_list_files(): + service = FileServiceV2(MagicMock()) + service.list_files = AsyncMock(return_value={"files": []}) + await service.search_files( + user_id="u1", query="report", created_after=AFTER, created_before=BEFORE + ) + kwargs = service.list_files.await_args.kwargs + assert kwargs["created_after"] == AFTER + assert kwargs["created_before"] == BEFORE + + +@pytest.mark.parametrize( + "handler", + [ + internal_files.list_files, + internal_files.search_files, + v2_files.list_files, + v2_files.search_files, + ], +) +def test_bounds_are_documented_as_inclusive(handler): + defaults = handler.__defaults__ or () + descriptions = [getattr(d, "description", "") or "" for d in defaults] + assert internal_files.CREATED_AFTER_DESCRIPTION in descriptions + assert internal_files.CREATED_BEFORE_DESCRIPTION in descriptions + assert "Inclusive" in internal_files.CREATED_BEFORE_DESCRIPTION From 907f9b147e6227ad799a039c526ff31010c7b221 Mon Sep 17 00:00:00 2001 From: Visvesh Jegadheesh Date: Wed, 14 Oct 2026 15:21:47 -0700 Subject: [PATCH 17/38] feat: show the knowledge date range filter on OSS only --- .../knowledge-date-range-filter.test.tsx | 21 +++++++++++++++ .../knowledge-date-range-filter.tsx | 10 +++++++ frontend/lib/date-range-filter.test.ts | 17 ++++++++++++ frontend/lib/date-range-filter.ts | 26 +++++++++++++++++++ 4 files changed, 74 insertions(+) create mode 100644 frontend/lib/date-range-filter.test.ts create mode 100644 frontend/lib/date-range-filter.ts diff --git a/frontend/components/knowledge-date-range-filter.test.tsx b/frontend/components/knowledge-date-range-filter.test.tsx index 2369d2aa5e..828a6f27f7 100644 --- a/frontend/components/knowledge-date-range-filter.test.tsx +++ b/frontend/components/knowledge-date-range-filter.test.tsx @@ -4,6 +4,17 @@ import { renderWithProviders, screen } from "@/test-utils/render"; import { KnowledgeDateRangeFilter } from "./knowledge-date-range-filter"; const histogramParams = vi.fn(); +let mockRunMode: string | null = "oss"; + +vi.mock("@/contexts/auth-context", async (importOriginal) => ({ + ...(await importOriginal()), + useAuth: () => ({ runMode: mockRunMode }), +})); + +vi.mock("@/contexts/brand-context", async (importOriginal) => ({ + ...(await importOriginal()), + useIsCloudBrand: () => false, +})); vi.mock("@/app/api/queries/useUploadHistogram", () => ({ useUploadHistogram: (params: unknown, options: { enabled?: boolean }) => { @@ -68,6 +79,7 @@ const short = (d: Date) => describe("KnowledgeDateRangeFilter", () => { // Only Date is faked so act()/Radix timers keep running normally. beforeEach(() => { + mockRunMode = "oss"; histogramParams.mockClear(); vi.useFakeTimers({ toFake: ["Date"] }); vi.setSystemTime(new Date(2024, 5, 20, 12)); @@ -76,6 +88,15 @@ describe("KnowledgeDateRangeFilter", () => { vi.useRealTimers(); }); + it.each(["saas", null])("is hidden when the run mode is %s", (runMode) => { + mockRunMode = runMode; + renderFilter(); + expect( + screen.queryByRole("button", { name: /filter by date range/i }), + ).not.toBeInTheDocument(); + expect(histogramParams).not.toHaveBeenCalled(); + }); + it("renders the trigger with the default label", () => { renderFilter(); expect( diff --git a/frontend/components/knowledge-date-range-filter.tsx b/frontend/components/knowledge-date-range-filter.tsx index 84b33c98c9..ca62fbf710 100644 --- a/frontend/components/knowledge-date-range-filter.tsx +++ b/frontend/components/knowledge-date-range-filter.tsx @@ -22,7 +22,10 @@ import { PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; +import { useAuth } from "@/contexts/auth-context"; +import { useIsCloudBrand } from "@/contexts/brand-context"; import { useKnowledgeFilter } from "@/contexts/knowledge-filter-context"; +import { isDateRangeFilterEnabled } from "@/lib/date-range-filter"; import { toLocalDayEndISOString, toLocalDayKey, @@ -125,6 +128,13 @@ function DensityMark({ } export function KnowledgeDateRangeFilter() { + const { runMode } = useAuth(); + const isCloudBrand = useIsCloudBrand(); + if (!isDateRangeFilterEnabled(runMode, { isCloudBrand })) return null; + return ; +} + +function DateRangeFilterPopover() { const { dateRange, setDateRange, parsedFilterData } = useKnowledgeFilter(); const [open, setOpen] = useState(false); // Captured once on mount so server/client renders agree on the same Date. diff --git a/frontend/lib/date-range-filter.test.ts b/frontend/lib/date-range-filter.test.ts new file mode 100644 index 0000000000..fda27d498b --- /dev/null +++ b/frontend/lib/date-range-filter.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { isDateRangeFilterEnabled } from "./date-range-filter"; + +describe("isDateRangeFilterEnabled", () => { + it("is enabled for OSS", () => { + expect(isDateRangeFilterEnabled("oss")).toBe(true); + }); + + it.each([ + "saas", + "", + null, + undefined, + ])("is disabled for run mode %s", (runMode) => { + expect(isDateRangeFilterEnabled(runMode)).toBe(false); + }); +}); diff --git a/frontend/lib/date-range-filter.ts b/frontend/lib/date-range-filter.ts new file mode 100644 index 0000000000..2eeabb7e63 --- /dev/null +++ b/frontend/lib/date-range-filter.ts @@ -0,0 +1,26 @@ +/** Knowledge date-range filter gate (mirrors the ingest-preview run-mode gate). */ + +import { IBM_THEME_DEV } from "@/lib/brand"; + +/** + * Eligible run modes for showing the Knowledge date-range filter. + * SaaS omitted until product approval — add "saas" here to restore. + */ +const DATE_RANGE_FILTER_RUN_MODES = new Set(["oss"]); + +/** + * Client-side gate for showing the date-range filter. Pass `isCloudBrand` so the + * local IBM theme switch hides it the same way real `OPENRAG_RUN_MODE=saas` would. + */ +export function isDateRangeFilterEnabled( + runMode: string | null | undefined, + options?: { isCloudBrand?: boolean }, +): boolean { + if (runMode == null || !DATE_RANGE_FILTER_RUN_MODES.has(runMode)) { + return false; + } + if (IBM_THEME_DEV && options?.isCloudBrand) { + return false; + } + return true; +} From 1dd388f805d4b521d447ed5a0cf7cb146a738c17 Mon Sep 17 00:00:00 2001 From: Visvesh Jegadheesh Date: Tue, 20 Oct 2026 13:00:32 -0700 Subject: [PATCH 18/38] test: replace useUploadHistogram module mock with MSW handler --- .../knowledge-date-range-filter.test.tsx | 53 ++++++++++--------- 1 file changed, 27 insertions(+), 26 deletions(-) diff --git a/frontend/components/knowledge-date-range-filter.test.tsx b/frontend/components/knowledge-date-range-filter.test.tsx index 828a6f27f7..be97337a3d 100644 --- a/frontend/components/knowledge-date-range-filter.test.tsx +++ b/frontend/components/knowledge-date-range-filter.test.tsx @@ -1,9 +1,10 @@ import { act, fireEvent } from "@testing-library/react"; +import { HttpResponse, http } from "msw"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { renderWithProviders, screen } from "@/test-utils/render"; +import { renderWithProviders, screen, waitFor } from "@/test-utils/render"; import { KnowledgeDateRangeFilter } from "./knowledge-date-range-filter"; -const histogramParams = vi.fn(); +let histogramRequests: URL[] = []; let mockRunMode: string | null = "oss"; vi.mock("@/contexts/auth-context", async (importOriginal) => ({ @@ -16,22 +17,21 @@ vi.mock("@/contexts/brand-context", async (importOriginal) => ({ useIsCloudBrand: () => false, })); -vi.mock("@/app/api/queries/useUploadHistogram", () => ({ - useUploadHistogram: (params: unknown, options: { enabled?: boolean }) => { - histogramParams(params, options); - return { - data: { - total: 10, - days: { - "2024-06-20": 1, - "2024-06-19": 3, - "2024-06-10": 6, - "2024-05-15": 2, - }, +const histogramHandler = http.get( + "/api/files/upload-histogram", + ({ request }) => { + histogramRequests.push(new URL(request.url)); + return HttpResponse.json({ + total: 10, + days: { + "2024-06-20": 1, + "2024-06-19": 3, + "2024-06-10": 6, + "2024-05-15": 2, }, - }; + }); }, -})); +); // Radix Popover ignores userEvent pointer sequences in JSDOM; fireEvent.click opens it. async function click(el: HTMLElement) { @@ -43,11 +43,17 @@ async function click(el: HTMLElement) { function renderFilter() { renderWithProviders(, { providers: ["knowledgeFilter"], + handlers: [histogramHandler], }); } async function openPopover() { await click(screen.getByRole("button", { name: /date range/i })); + await waitFor(() => + expect(screen.getByRole("button", { name: /any time/i })).toHaveTextContent( + "10", + ), + ); } function preset(name: RegExp) { @@ -80,7 +86,7 @@ describe("KnowledgeDateRangeFilter", () => { // Only Date is faked so act()/Radix timers keep running normally. beforeEach(() => { mockRunMode = "oss"; - histogramParams.mockClear(); + histogramRequests = []; vi.useFakeTimers({ toFake: ["Date"] }); vi.setSystemTime(new Date(2024, 5, 20, 12)); }); @@ -94,7 +100,7 @@ describe("KnowledgeDateRangeFilter", () => { expect( screen.queryByRole("button", { name: /filter by date range/i }), ).not.toBeInTheDocument(); - expect(histogramParams).not.toHaveBeenCalled(); + expect(histogramRequests).toHaveLength(0); }); it("renders the trigger with the default label", () => { @@ -107,15 +113,10 @@ describe("KnowledgeDateRangeFilter", () => { it("only fetches upload counts while the popover is open", async () => { renderFilter(); - expect(histogramParams).toHaveBeenLastCalledWith( - expect.anything(), - expect.objectContaining({ enabled: false }), - ); + expect(histogramRequests).toHaveLength(0); await openPopover(); - expect(histogramParams).toHaveBeenLastCalledWith( - expect.objectContaining({ tz: expect.any(String) }), - expect.objectContaining({ enabled: true }), - ); + expect(histogramRequests.length).toBeGreaterThan(0); + expect(histogramRequests[0].searchParams.get("tz")).toBeTruthy(); }); it("lists quick ranges with their upload counts", async () => { From 8960291bc5793e20b6e3fbe6ed7760014e4f73a5 Mon Sep 17 00:00:00 2001 From: Visvesh Jegadheesh Date: Tue, 20 Oct 2026 13:14:45 -0700 Subject: [PATCH 19/38] feat: add custom last-N-days input to date range filter --- .../knowledge-date-range-filter.test.tsx | 43 +++++++++++++++++++ .../knowledge-date-range-filter.tsx | 41 +++++++++++++++++- 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/frontend/components/knowledge-date-range-filter.test.tsx b/frontend/components/knowledge-date-range-filter.test.tsx index be97337a3d..16dcceee84 100644 --- a/frontend/components/knowledge-date-range-filter.test.tsx +++ b/frontend/components/knowledge-date-range-filter.test.tsx @@ -242,4 +242,47 @@ describe("KnowledgeDateRangeFilter", () => { await click(screen.getByRole("button", { name: "Apply filter" })); expect(screen.getByText("Date range")).toBeInTheDocument(); }); + + it("selects the last N days from a typed number and applies it", async () => { + renderFilter(); + await openPopover(); + await act(async () => { + fireEvent.change( + screen.getByRole("textbox", { name: "Number of days" }), + { + target: { value: "45" }, + }, + ); + }); + + expect( + screen.getByText( + `${short(new Date(2024, 4, 7))} – ${short(new Date(2024, 5, 20))}`, + ), + ).toBeInTheDocument(); + expect(screen.getByText("12 files")).toBeInTheDocument(); + + await click(screen.getByRole("button", { name: "Apply filter" })); + expect( + screen.getByRole("button", { + name: `${fmt(new Date(2024, 4, 7))} – ${fmt(new Date(2024, 5, 20))}`, + }), + ).toBeInTheDocument(); + }); + + it("ignores non-numeric input and clears the draft when emptied", async () => { + renderFilter(); + await openPopover(); + const input = screen.getByRole("textbox", { name: "Number of days" }); + await act(async () => { + fireEvent.change(input, { target: { value: "7a" } }); + }); + expect(input).toHaveValue("7"); + expect(preset(/last 7 days/i)).toHaveAttribute("aria-pressed", "true"); + + await act(async () => { + fireEvent.change(input, { target: { value: "" } }); + }); + expect(preset(/any time/i)).toHaveAttribute("aria-pressed", "true"); + }); }); diff --git a/frontend/components/knowledge-date-range-filter.tsx b/frontend/components/knowledge-date-range-filter.tsx index ca62fbf710..e3d379d27f 100644 --- a/frontend/components/knowledge-date-range-filter.tsx +++ b/frontend/components/knowledge-date-range-filter.tsx @@ -36,6 +36,7 @@ import { cn } from "@/lib/utils"; type Range = { from: Date; to: Date }; const EMPTY_DAYS: Record = {}; +const MAX_CUSTOM_DAYS = 3650; interface Preset { id: string; @@ -141,6 +142,7 @@ function DateRangeFilterPopover() { const [today] = useState(() => startOfDay(new Date())); const [draft, setDraft] = useState(); const [month, setMonth] = useState(today); + const [customDays, setCustomDays] = useState(""); // Cover the shown month and every preset (the earliest starts last month). const windowStart = min([ @@ -169,6 +171,7 @@ function DateRangeFilterPopover() { : undefined, ); setMonth(dateRange?.to ?? dateRange?.from ?? today); + setCustomDays(""); } setOpen(next); }; @@ -196,9 +199,23 @@ function DateRangeFilterPopover() { const handlePreset = (preset: Preset) => { const range = preset.range(today); setDraft(range ?? undefined); + setCustomDays(""); if (range) setMonth(range.to); }; + const handleCustomDays = (value: string) => { + const digits = value.replace(/\D/g, "").slice(0, 4); + setCustomDays(digits); + const n = Math.min(Number.parseInt(digits, 10), MAX_CUSTOM_DAYS); + if (!n) { + setDraft(undefined); + return; + } + const range = { from: subDays(today, n - 1), to: today }; + setDraft(range); + setMonth(range.to); + }; + const handleApply = () => { setDateRange(draftRange); setOpen(false); @@ -296,12 +313,34 @@ function DateRangeFilterPopover() { ); })}
+
+ + handleCustomDays(e.target.value)} + className="h-8 w-16 rounded-md border bg-background px-2 text-sm tabular-nums" + /> + days +
{ + setCustomDays(""); + setDraft(next); + }} month={month} onMonthChange={setMonth} toMonth={today} From 6a1c3c5df10c261b1fcb046608b77f74195b03ff Mon Sep 17 00:00:00 2001 From: Visvesh Jegadheesh Date: Tue, 20 Oct 2026 13:33:12 -0700 Subject: [PATCH 20/38] coderabbit suggestions --- .../knowledge-date-range-filter.tsx | 5 +++- src/services/document_index_writer.py | 3 ++- src/services/langflow_file_service.py | 2 ++ src/services/langflow_ingest_token_service.py | 2 ++ .../test_document_index_writer_ownership.py | 27 +++++++++++++++++++ tests/unit/test_langflow_ingest_callback.py | 16 +++++++++++ 6 files changed, 53 insertions(+), 2 deletions(-) diff --git a/frontend/components/knowledge-date-range-filter.tsx b/frontend/components/knowledge-date-range-filter.tsx index e3d379d27f..d89c8d721a 100644 --- a/frontend/components/knowledge-date-range-filter.tsx +++ b/frontend/components/knowledge-date-range-filter.tsx @@ -144,10 +144,13 @@ function DateRangeFilterPopover() { const [month, setMonth] = useState(today); const [customDays, setCustomDays] = useState(""); - // Cover the shown month and every preset (the earliest starts last month). + // Cover the shown month, every preset (last month, or 29 days back when that + // is earlier), and the draft start so typed/custom ranges count correctly. const windowStart = min([ startOfMonth(month), startOfMonth(subMonths(today, 1)), + subDays(today, 29), + ...(draft?.from ? [startOfDay(draft.from)] : []), ]); const { data: histogram } = useUploadHistogram( { diff --git a/src/services/document_index_writer.py b/src/services/document_index_writer.py index 0a25a991c3..476c5bf5d2 100644 --- a/src/services/document_index_writer.py +++ b/src/services/document_index_writer.py @@ -67,6 +67,7 @@ class DocumentIndexContext: parser: str | None = None chunk_size: int | None = None chunk_overlap: int | None = None + indexed_time: str | None = None @dataclass @@ -131,7 +132,7 @@ async def index_chunks( dimensions=dimensions, ) - now = datetime.datetime.now(datetime.UTC).isoformat() + now = context.indexed_time or datetime.datetime.now(datetime.UTC).isoformat() bulk_body: list[dict[str, Any]] = [] for chunk in chunks: if len(chunk.vector) != dimensions: diff --git a/src/services/langflow_file_service.py b/src/services/langflow_file_service.py index 47af817d05..2adde41182 100644 --- a/src/services/langflow_file_service.py +++ b/src/services/langflow_file_service.py @@ -1,4 +1,5 @@ import asyncio +import datetime import io import json import time @@ -300,6 +301,7 @@ def _configure_ingest_callback( chunk_size=chunk_size, chunk_overlap=chunk_overlap, connector_file_id=connector_file_id, + indexed_time=datetime.datetime.now(datetime.UTC).isoformat(), ) token = self.ingest_token_service.create_token(context) logger.info( diff --git a/src/services/langflow_ingest_token_service.py b/src/services/langflow_ingest_token_service.py index c6502e22d4..dd50e8ebab 100644 --- a/src/services/langflow_ingest_token_service.py +++ b/src/services/langflow_ingest_token_service.py @@ -169,6 +169,7 @@ def _context_to_payload(context: DocumentIndexContext) -> dict[str, Any]: "chunk_size": context.chunk_size, "chunk_overlap": context.chunk_overlap, "connector_file_id": context.connector_file_id, + "indexed_time": context.indexed_time, } @staticmethod @@ -206,4 +207,5 @@ def _payload_to_context(payload: dict[str, Any]) -> DocumentIndexContext: chunk_size=payload.get("chunk_size"), chunk_overlap=payload.get("chunk_overlap"), connector_file_id=payload.get("connector_file_id"), + indexed_time=payload.get("indexed_time"), ) diff --git a/tests/unit/test_document_index_writer_ownership.py b/tests/unit/test_document_index_writer_ownership.py index 5682998dc8..837948af42 100644 --- a/tests/unit/test_document_index_writer_ownership.py +++ b/tests/unit/test_document_index_writer_ownership.py @@ -119,3 +119,30 @@ async def test_new_chunks_persist_provider_qualified_embedding_space() -> None: assert document["embedding_provider"] == "azure" assert document["embedding_space_id"] == "azure:test-model" assert document["chunk_embedding_azure_test_model"] == [0.1, 0.2, 0.3] + + +@pytest.mark.asyncio +async def test_batches_of_one_ingest_run_share_the_context_indexed_time() -> None: + opensearch = InMemoryOpenSearch() + writer = DocumentIndexWriter(opensearch_client=opensearch) + context = make_context("user-a") + context.indexed_time = "2026-01-01T00:00:00+00:00" + + first = DocumentIndexChunk(chunk_id="doc_0", text="a", vector=[0.1, 0.2, 0.3]) + second = DocumentIndexChunk(chunk_id="doc_1", text="b", vector=[0.1, 0.2, 0.3]) + await writer.index_chunks(context, [first]) + await writer.index_chunks(context, [second]) + + assert {d["indexed_time"] for d in opensearch.documents.values()} == { + "2026-01-01T00:00:00+00:00" + } + + +@pytest.mark.asyncio +async def test_indexed_time_defaults_to_now_without_a_context_value() -> None: + opensearch = InMemoryOpenSearch() + writer = DocumentIndexWriter(opensearch_client=opensearch) + + await writer.index_chunks(make_context("user-a"), [make_chunk()]) + + assert next(iter(opensearch.documents.values()))["indexed_time"] diff --git a/tests/unit/test_langflow_ingest_callback.py b/tests/unit/test_langflow_ingest_callback.py index f077675d57..6797c61cab 100644 --- a/tests/unit/test_langflow_ingest_callback.py +++ b/tests/unit/test_langflow_ingest_callback.py @@ -443,3 +443,19 @@ def test_ingest_token_prefers_jwt_signing_key_over_session_secret(monkeypatch): session_service = LangflowIngestTokenService(secret=session_secret, ttl_seconds=60) with pytest.raises(ValueError, match="Invalid Langflow ingest token"): session_service.validate_token(token) + + +def test_ingest_token_round_trips_indexed_time(): + from services.langflow_ingest_token_service import LangflowIngestTokenService + + payload = LangflowIngestTokenService._context_to_payload( + DocumentIndexContext( + document_id="d", + mimetype="application/pdf", + embedding_model="m", + indexed_time="2026-01-01T00:00:00+00:00", + ) + ) + context = LangflowIngestTokenService._payload_to_context(payload) + + assert context.indexed_time == "2026-01-01T00:00:00+00:00" From 7583a9407771c9609eb91ee6f240740cc4663789 Mon Sep 17 00:00:00 2001 From: Visvesh Jegadheesh Date: Tue, 20 Oct 2026 15:00:38 -0700 Subject: [PATCH 21/38] fix frontend testing --- frontend/contexts/auth-context.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/frontend/contexts/auth-context.tsx b/frontend/contexts/auth-context.tsx index a764468f34..b99bdf29bc 100644 --- a/frontend/contexts/auth-context.tsx +++ b/frontend/contexts/auth-context.tsx @@ -291,7 +291,11 @@ export function AuthProvider({ children }: AuthProviderProps) { if (user || isNoAuthMode || isIbmAuthMode) { setPermissionsResolved(false); void fetchPermissions(); - } else { + } else if (!isLoading) { + // Only resolve with empty permissions once auth has finished loading. + // While isLoading=true the final user/mode is not known yet, so resolving + // early creates a transient state (permissionsResolved=true, roles=[]) that + // can race with the post-checkAuth effect run that actually fetches roles. resetPermissionState(); setPermissionsResolved(true); } @@ -299,6 +303,7 @@ export function AuthProvider({ children }: AuthProviderProps) { user, isNoAuthMode, isIbmAuthMode, + isLoading, fetchPermissions, resetPermissionState, ]); From 63de5677e6bbb8401654abb6825a7e2989bb3c7a Mon Sep 17 00:00:00 2001 From: Visvesh Jegadheesh Date: Tue, 20 Oct 2026 15:28:32 -0700 Subject: [PATCH 22/38] fix: apply date range to task rows and refresh today across midnight --- frontend/app/knowledge/page.tsx | 1 + .../knowledge-date-range-filter.test.tsx | 28 +++++++ .../knowledge-date-range-filter.tsx | 26 ++++++- frontend/lib/knowledge-table-state.test.ts | 75 +++++++++++++++++++ frontend/lib/knowledge-table-state.ts | 41 +++++++++- 5 files changed, 165 insertions(+), 6 deletions(-) diff --git a/frontend/app/knowledge/page.tsx b/frontend/app/knowledge/page.tsx index 1f9c359a7b..43c19477d0 100644 --- a/frontend/app/knowledge/page.tsx +++ b/frontend/app/knowledge/page.tsx @@ -756,6 +756,7 @@ function SearchPage() { effectiveData, taskFiles, Boolean(selectedFilter), + dateRange, ); const serverTotal = isWildcardQuery diff --git a/frontend/components/knowledge-date-range-filter.test.tsx b/frontend/components/knowledge-date-range-filter.test.tsx index 16dcceee84..27b5614d52 100644 --- a/frontend/components/knowledge-date-range-filter.test.tsx +++ b/frontend/components/knowledge-date-range-filter.test.tsx @@ -119,6 +119,34 @@ describe("KnowledgeDateRangeFilter", () => { expect(histogramRequests[0].searchParams.get("tz")).toBeTruthy(); }); + it("recomputes today when the picker is reopened after midnight", async () => { + renderFilter(); + await openPopover(); + await click(preset(/^today/i)); + await click(screen.getByRole("button", { name: /apply/i })); + + act(() => { + vi.setSystemTime(new Date(2024, 5, 21, 0, 5)); + }); + await click( + screen.getByRole("button", { + name: new RegExp(fmt(new Date(2024, 5, 20))), + }), + ); + await waitFor(() => + expect(screen.getByRole("button", { name: /any time/i })).toBeVisible(), + ); + + expect(juneDay(21)).not.toBeDisabled(); + await click(preset(/^today/i)); + await click(screen.getByRole("button", { name: /apply/i })); + expect( + screen.getByRole("button", { + name: new RegExp(fmt(new Date(2024, 5, 21))), + }), + ).toBeInTheDocument(); + }); + it("lists quick ranges with their upload counts", async () => { renderFilter(); await openPopover(); diff --git a/frontend/components/knowledge-date-range-filter.tsx b/frontend/components/knowledge-date-range-filter.tsx index d89c8d721a..66cfbaf78a 100644 --- a/frontend/components/knowledge-date-range-filter.tsx +++ b/frontend/components/knowledge-date-range-filter.tsx @@ -12,7 +12,7 @@ import { subMonths, } from "date-fns"; import { CalendarIcon, X } from "lucide-react"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import type { DateRange, DayContentProps } from "react-day-picker"; import { useUploadHistogram } from "@/app/api/queries/useUploadHistogram"; import { Button } from "@/components/ui/button"; @@ -138,8 +138,24 @@ export function KnowledgeDateRangeFilter() { function DateRangeFilterPopover() { const { dateRange, setDateRange, parsedFilterData } = useKnowledgeFilter(); const [open, setOpen] = useState(false); - // Captured once on mount so server/client renders agree on the same Date. - const [today] = useState(() => startOfDay(new Date())); + // Initialised in state so server/client renders agree; refreshed on open and + // at midnight below so a long-lived page never keeps yesterday as "today". + const [today, setToday] = useState(() => startOfDay(new Date())); + + useEffect(() => { + if (!open) return; + const rollover = () => { + const now = startOfDay(new Date()); + setToday((prev) => (isSameDay(prev, now) ? prev : now)); + }; + rollover(); + const nextMidnight = addDays(startOfDay(new Date()), 1); + const timer = setTimeout( + rollover, + nextMidnight.getTime() - Date.now() + 1000, + ); + return () => clearTimeout(timer); + }, [open, today]); const [draft, setDraft] = useState(); const [month, setMonth] = useState(today); const [customDays, setCustomDays] = useState(""); @@ -168,12 +184,14 @@ function DateRangeFilterPopover() { const handleOpenChange = (next: boolean) => { if (next) { + const freshToday = startOfDay(new Date()); + setToday(freshToday); setDraft( dateRange?.from ? { from: dateRange.from, to: dateRange.to } : undefined, ); - setMonth(dateRange?.to ?? dateRange?.from ?? today); + setMonth(dateRange?.to ?? dateRange?.from ?? freshToday); setCustomDays(""); } setOpen(next); diff --git a/frontend/lib/knowledge-table-state.test.ts b/frontend/lib/knowledge-table-state.test.ts index ff187e2ab9..323a587e69 100644 --- a/frontend/lib/knowledge-table-state.test.ts +++ b/frontend/lib/knowledge-table-state.test.ts @@ -171,6 +171,81 @@ describe("buildKnowledgeTableRows", () => { ]); }); + describe("date range", () => { + const yesterday = { + from: new Date(2026, 8, 7), + to: new Date(2026, 8, 7), + }; + + it("drops task files created outside the selected range", () => { + const rows = buildKnowledgeTableRows( + [], + [ + taskFile({ + filename: "today.pdf", + created_at: new Date(2026, 8, 8, 10).toISOString(), + }), + ], + false, + yesterday, + ); + expect(rows).toHaveLength(0); + }); + + it("keeps task files created inside the range, including its last day", () => { + const rows = buildKnowledgeTableRows( + [], + [ + taskFile({ + filename: "late.pdf", + created_at: new Date(2026, 8, 7, 23, 30).toISOString(), + }), + ], + false, + yesterday, + ); + expect(rows.map((r) => r.filename)).toEqual(["late.pdf"]); + }); + + it("applies an open-ended range", () => { + const created_at = new Date(2026, 8, 1, 10).toISOString(); + const files = [taskFile({ filename: "old.pdf", created_at })]; + expect( + buildKnowledgeTableRows([], files, false, { + from: new Date(2026, 8, 5), + }), + ).toHaveLength(0); + expect( + buildKnowledgeTableRows([], files, false, { to: new Date(2026, 8, 5) }), + ).toHaveLength(1); + }); + + it("keeps every task file when no range is set", () => { + const rows = buildKnowledgeTableRows([], [taskFile()], false, null); + expect(rows).toHaveLength(1); + }); + + it("keeps a task file whose timestamp is unparseable", () => { + const rows = buildKnowledgeTableRows( + [], + [taskFile({ created_at: "not-a-date" })], + false, + yesterday, + ); + expect(rows).toHaveLength(1); + }); + + it("never drops indexed backend rows, which the server already filtered", () => { + const rows = buildKnowledgeTableRows( + [searchFile({ filename: "indexed.pdf" })], + [], + false, + yesterday, + ); + expect(rows).toHaveLength(1); + }); + }); + it("labels a task file with no name or url as Untitled source", () => { const rows = buildKnowledgeTableRows( [], diff --git a/frontend/lib/knowledge-table-state.ts b/frontend/lib/knowledge-table-state.ts index 5b2592dfa4..5b9ecda123 100644 --- a/frontend/lib/knowledge-table-state.ts +++ b/frontend/lib/knowledge-table-state.ts @@ -188,14 +188,16 @@ export function buildKnowledgeTableRows( searchData: SearchFile[], taskFiles: TaskFile[], hasActiveFilter = false, + dateRange?: { from?: Date; to?: Date } | null, ): SearchFile[] { + const taskCreatedAt = new WeakMap(); const taskFilesAsFiles: SearchFile[] = taskFiles.map((taskFile) => { const normalizedFilename = taskFile.filename?.trim() || taskFile.source_url?.trim() || "Untitled source"; - return { + const asFile: SearchFile = { filename: normalizedFilename, mimetype: taskFile.mimetype, source_url: taskFile.source_url || "", @@ -208,6 +210,8 @@ export function buildKnowledgeTableRows( embedding_model: taskFile.embedding_model, embedding_dimensions: taskFile.embedding_dimensions, }; + if (taskFile.created_at) taskCreatedAt.set(asFile, taskFile.created_at); + return asFile; }); const taskFileMap = indexTaskFileOverlays(taskFilesAsFiles); @@ -284,7 +288,40 @@ export function buildKnowledgeTableRows( return backendFiles; } - return [...backendFiles, ...filteredTaskFiles]; + const rangeFrom = dateRange?.from; + const rangeTo = dateRange?.to; + const dateFilteredTaskFiles = + (rangeFrom ?? rangeTo) + ? filteredTaskFiles.filter((f) => { + const createdAt = taskCreatedAt.get(f); + if (!createdAt) return true; + const d = new Date(createdAt); + if (isNaN(d.getTime())) return true; + if (rangeFrom) { + const start = new Date( + rangeFrom.getFullYear(), + rangeFrom.getMonth(), + rangeFrom.getDate(), + ); + if (d < start) return false; + } + if (rangeTo) { + const end = new Date( + rangeTo.getFullYear(), + rangeTo.getMonth(), + rangeTo.getDate(), + 23, + 59, + 59, + 999, + ); + if (d > end) return false; + } + return true; + }) + : filteredTaskFiles; + + return [...backendFiles, ...dateFilteredTaskFiles]; } export function buildActiveSourceOptions( From 671e0505a46019a0bbde0cf20e67884dadc7c503 Mon Sep 17 00:00:00 2001 From: Visvesh Jegadheesh Date: Tue, 20 Oct 2026 15:38:06 -0700 Subject: [PATCH 23/38] coderabbit + github actions --- .../knowledge-date-range-filter.tsx | 334 +++++++++++------- frontend/lib/knowledge-table-state.test.ts | 9 + frontend/lib/knowledge-table-state.ts | 11 +- 3 files changed, 221 insertions(+), 133 deletions(-) diff --git a/frontend/components/knowledge-date-range-filter.tsx b/frontend/components/knowledge-date-range-filter.tsx index 66cfbaf78a..d552515ad3 100644 --- a/frontend/components/knowledge-date-range-filter.tsx +++ b/frontend/components/knowledge-date-range-filter.tsx @@ -135,11 +135,9 @@ export function KnowledgeDateRangeFilter() { return ; } -function DateRangeFilterPopover() { - const { dateRange, setDateRange, parsedFilterData } = useKnowledgeFilter(); - const [open, setOpen] = useState(false); - // Initialised in state so server/client renders agree; refreshed on open and - // at midnight below so a long-lived page never keeps yesterday as "today". +function useToday(open: boolean) { + // Refreshed while open so a page left running past midnight never keeps + // yesterday as "today". const [today, setToday] = useState(() => startOfDay(new Date())); useEffect(() => { @@ -156,6 +154,184 @@ function DateRangeFilterPopover() { ); return () => clearTimeout(timer); }, [open, today]); + + return [today, setToday] as const; +} + +function QuickRanges({ + today, + activePresetId, + presetCount, + customDays, + onPreset, + onCustomDays, +}: { + today: Date; + activePresetId: string | undefined; + presetCount: (range: Range | null) => number | undefined; + customDays: string; + onPreset: (preset: Preset) => void; + onCustomDays: (value: string) => void; +}) { + return ( +
+

+ Quick ranges +

+ {/* A scrolling row on narrow screens, a list beside the calendar otherwise. */} +
+ {PRESETS.map((preset) => { + const count = presetCount(preset.range(today)); + const active = preset.id === activePresetId; + return ( + + ); + })} +
+
+ + onCustomDays(e.target.value)} + className="h-8 w-16 rounded-md border bg-background px-2 text-sm tabular-nums" + /> + days +
+
+ ); +} + +function CalendarPanel({ + draft, + month, + today, + DayContent, + onSelect, + onMonthChange, +}: { + draft: DateRange | undefined; + month: Date; + today: Date; + DayContent: (props: DayContentProps) => React.JSX.Element; + onSelect: (next: DateRange | undefined) => void; + onMonthChange: (month: Date) => void; +}) { + return ( +
+ +
+ + 1 upload + + + + 2–4 + + + + 5+ + +
+
+ ); +} + +function PopoverFooter({ + footerCount, + footerRange, + onCancel, + onApply, +}: { + footerCount: number | undefined; + footerRange: string; + onCancel: () => void; + onApply: () => void; +}) { + return ( +
+

+ {footerCount !== undefined && ( + <> + + {footerCount} {footerCount === 1 ? "file" : "files"} + + {" · "} + + )} + {footerRange} +

+
+ + +
+
+ ); +} + +function DateRangeFilterPopover() { + const { dateRange, setDateRange, parsedFilterData } = useKnowledgeFilter(); + const [open, setOpen] = useState(false); + const [today, setToday] = useToday(open); const [draft, setDraft] = useState(); const [month, setMonth] = useState(today); const [customDays, setCustomDays] = useState(""); @@ -237,6 +413,11 @@ function DateRangeFilterPopover() { setMonth(range.to); }; + const handleCalendarSelect = (next: DateRange | undefined) => { + setCustomDays(""); + setDraft(next); + }; + const handleApply = () => { setDateRange(draftRange); setOpen(false); @@ -306,128 +487,29 @@ function DateRangeFilterPopover() { collisionPadding={16} >
-
-

- Quick ranges -

- {/* A scrolling row on narrow screens, a list beside the calendar otherwise. */} -
- {PRESETS.map((preset) => { - const count = presetCount(preset.range(today)); - const active = preset.id === activePresetId; - return ( - - ); - })} -
-
- - handleCustomDays(e.target.value)} - className="h-8 w-16 rounded-md border bg-background px-2 text-sm tabular-nums" - /> - days -
-
-
- { - setCustomDays(""); - setDraft(next); - }} - month={month} - onMonthChange={setMonth} - toMonth={today} - disabled={{ after: today }} - components={{ DayContent }} - classNames={{ - caption_label: "text-base font-semibold", - head_cell: - "w-10 text-xs font-normal text-muted-foreground sm:w-14", - cell: cn( - "relative h-11 w-10 p-0 text-center text-sm sm:h-12 sm:w-14", - "[&:has([aria-selected])]:bg-accent", - "first:[&:has([aria-selected])]:rounded-l-md", - "last:[&:has([aria-selected])]:rounded-r-md", - "focus-within:relative focus-within:z-20", - ), - day: "inline-flex h-11 w-10 items-center justify-center rounded-md p-0 font-normal hover:bg-accent/70 aria-selected:opacity-100 sm:h-12 sm:w-14", - }} - /> -
- - 1 upload - - - - 2–4 - - - - 5+ - -
-
-
-
-

- {footerCount !== undefined && ( - <> - - {footerCount} {footerCount === 1 ? "file" : "files"} - - {" · "} - - )} - {footerRange} -

-
- - -
+ +
+ setOpen(false)} + onApply={handleApply} + /> {dateRange && ( diff --git a/frontend/lib/knowledge-table-state.test.ts b/frontend/lib/knowledge-table-state.test.ts index 323a587e69..536ee52e03 100644 --- a/frontend/lib/knowledge-table-state.test.ts +++ b/frontend/lib/knowledge-table-state.test.ts @@ -220,6 +220,15 @@ describe("buildKnowledgeTableRows", () => { ).toHaveLength(1); }); + it("exposes the task creation time as indexed_time on task-only rows", () => { + const created_at = new Date(2026, 8, 7, 10).toISOString(); + const rows = buildKnowledgeTableRows( + [], + [taskFile({ filename: "pending.pdf", created_at })], + ); + expect(rows[0].indexed_time).toBe(created_at); + }); + it("keeps every task file when no range is set", () => { const rows = buildKnowledgeTableRows([], [taskFile()], false, null); expect(rows).toHaveLength(1); diff --git a/frontend/lib/knowledge-table-state.ts b/frontend/lib/knowledge-table-state.ts index 5b9ecda123..966441ea0b 100644 --- a/frontend/lib/knowledge-table-state.ts +++ b/frontend/lib/knowledge-table-state.ts @@ -190,15 +190,15 @@ export function buildKnowledgeTableRows( hasActiveFilter = false, dateRange?: { from?: Date; to?: Date } | null, ): SearchFile[] { - const taskCreatedAt = new WeakMap(); const taskFilesAsFiles: SearchFile[] = taskFiles.map((taskFile) => { const normalizedFilename = taskFile.filename?.trim() || taskFile.source_url?.trim() || "Untitled source"; - const asFile: SearchFile = { + return { filename: normalizedFilename, + indexed_time: taskFile.created_at || undefined, mimetype: taskFile.mimetype, source_url: taskFile.source_url || "", size: taskFile.size, @@ -210,8 +210,6 @@ export function buildKnowledgeTableRows( embedding_model: taskFile.embedding_model, embedding_dimensions: taskFile.embedding_dimensions, }; - if (taskFile.created_at) taskCreatedAt.set(asFile, taskFile.created_at); - return asFile; }); const taskFileMap = indexTaskFileOverlays(taskFilesAsFiles); @@ -293,9 +291,8 @@ export function buildKnowledgeTableRows( const dateFilteredTaskFiles = (rangeFrom ?? rangeTo) ? filteredTaskFiles.filter((f) => { - const createdAt = taskCreatedAt.get(f); - if (!createdAt) return true; - const d = new Date(createdAt); + if (!f.indexed_time) return true; + const d = new Date(f.indexed_time); if (isNaN(d.getTime())) return true; if (rangeFrom) { const start = new Date( From 525f9e1b16f29910348a29fc8ae2fb3cce851fe2 Mon Sep 17 00:00:00 2001 From: Visvesh Jegadheesh Date: Wed, 21 Oct 2026 10:07:00 -0700 Subject: [PATCH 24/38] fixing review comments --- .../knowledge-date-range-filter.tsx | 1 - frontend/lib/time-utils.test.ts | 2 +- frontend/lib/time-utils.ts | 4 +++- src/api/search.py | 6 ++++- src/services/search_service.py | 24 +++++++++---------- tests/unit/test_search_api_date_bounds.py | 13 ++++++++++ .../test_search_service_score_threshold.py | 18 ++++++++++++++ 7 files changed, 52 insertions(+), 16 deletions(-) create mode 100644 tests/unit/test_search_api_date_bounds.py diff --git a/frontend/components/knowledge-date-range-filter.tsx b/frontend/components/knowledge-date-range-filter.tsx index d552515ad3..5c75ad881d 100644 --- a/frontend/components/knowledge-date-range-filter.tsx +++ b/frontend/components/knowledge-date-range-filter.tsx @@ -146,7 +146,6 @@ function useToday(open: boolean) { const now = startOfDay(new Date()); setToday((prev) => (isSameDay(prev, now) ? prev : now)); }; - rollover(); const nextMidnight = addDays(startOfDay(new Date()), 1); const timer = setTimeout( rollover, diff --git a/frontend/lib/time-utils.test.ts b/frontend/lib/time-utils.test.ts index 583b053477..fed12c4ac9 100644 --- a/frontend/lib/time-utils.test.ts +++ b/frontend/lib/time-utils.test.ts @@ -49,7 +49,7 @@ describe("toLocalDayEndISOString", () => { it("returns a valid ISO 8601 string", () => { const date = new Date("2024-03-15"); const result = toLocalDayEndISOString(date); - expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); + expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.999999Z$/); expect(new Date(result).getTime()).not.toBeNaN(); }); diff --git a/frontend/lib/time-utils.ts b/frontend/lib/time-utils.ts index 69041ce853..030be8f012 100644 --- a/frontend/lib/time-utils.ts +++ b/frontend/lib/time-utils.ts @@ -77,7 +77,9 @@ export function toLocalDayStartISOString(date: Date): string { export function toLocalDayEndISOString(date: Date): string { const d = new Date(date); d.setHours(23, 59, 59, 999); - return d.toISOString(); + // The backend stores microsecond-precision timestamps; extend the inclusive + // bound through the day's final microsecond. + return d.toISOString().replace(/\.999Z$/, ".999999Z"); } // Matches the yyyy-MM-dd keys the upload histogram buckets by (viewer's time zone). diff --git a/src/api/search.py b/src/api/search.py index b0b64e1f3b..62a26dca05 100644 --- a/src/api/search.py +++ b/src/api/search.py @@ -1,6 +1,6 @@ from typing import Any -from fastapi import Depends +from fastapi import Depends, HTTPException from fastapi.responses import JSONResponse from pydantic import BaseModel, Field @@ -40,6 +40,10 @@ async def search( merged_filters["created_after"] = body.created_after if body.created_before: merged_filters["created_before"] = body.created_before + for bound in ("created_after", "created_before"): + value = merged_filters.get(bound) + if value is not None and not isinstance(value, str): + raise HTTPException(status_code=422, detail=f"{bound} must be a string") # Validate after merging: the bounds can also arrive inside `filters`. _validate_date_range(merged_filters.get("created_after"), merged_filters.get("created_before")) diff --git a/src/services/search_service.py b/src/services/search_service.py index 1b42b7da79..1517e9504a 100644 --- a/src/services/search_service.py +++ b/src/services/search_service.py @@ -619,17 +619,17 @@ async def embed_with_space( knn_queries = [] for field_identity, embedding_vector in query_embeddings.items(): field_name = get_embedding_field_name(field_identity) - knn_queries.append( - { - "knn": { - field_name: { - "vector": embedding_vector, - "k": 50, - "num_candidates": 1000, - } - } - } - ) + knn_clause: dict[str, Any] = { + "vector": embedding_vector, + "k": 50, + "num_candidates": 1000, + } + # Filter inside the kNN clause so neighbors are chosen among + # in-range documents; the outer bool filter alone is a + # post-filter over the global top-k. + if filter_clauses: + knn_clause["filter"] = {"bool": {"filter": filter_clauses}} + knn_queries.append({"knn": {field_name: knn_clause}}) # KNN clauses naturally apply only to documents carrying their # vector field. Keep all other documents eligible for keyword @@ -647,7 +647,7 @@ async def embed_with_space( # Hybrid search (semantic + keyword) when embeddings are available; # keyword-only fallback when none succeeded. When falling back, bump # the multi_match boost so keyword scoring isn't artificially damped. - should_clauses = [] + should_clauses: list[dict[str, Any]] = [] if knn_queries: should_clauses.append( { diff --git a/tests/unit/test_search_api_date_bounds.py b/tests/unit/test_search_api_date_bounds.py new file mode 100644 index 0000000000..23f56c631c --- /dev/null +++ b/tests/unit/test_search_api_date_bounds.py @@ -0,0 +1,13 @@ +import pytest +from fastapi import HTTPException + +from api.search import SearchBody, search + + +@pytest.mark.asyncio +@pytest.mark.parametrize("bound", ["created_after", "created_before"]) +async def test_non_string_bound_in_filters_is_rejected(bound): + body = SearchBody(query="*", filters={bound: 123}) + with pytest.raises(HTTPException) as exc: + await search(body, search_service=None, session_manager=None, user=None) + assert exc.value.status_code == 422 diff --git a/tests/unit/test_search_service_score_threshold.py b/tests/unit/test_search_service_score_threshold.py index f65eea6009..475e9ab980 100644 --- a/tests/unit/test_search_service_score_threshold.py +++ b/tests/unit/test_search_service_score_threshold.py @@ -155,3 +155,21 @@ async def test_num_candidates_retry_keeps_the_threshold(monkeypatch): assert all("num_candidates" not in params for params in _knn_params(retry["query"])) assert retry["query"]["function_score"]["min_score"] == 1.0 assert "min_score" not in retry + + +@pytest.mark.asyncio +async def test_filters_are_pushed_into_each_knn_clause(monkeypatch): + """Filters must constrain kNN neighbor selection, not only post-filter the top-k.""" + os_client = _OpenSearch() + service = _make_service(os_client, monkeypatch) + _set_context(0) + set_search_filters({"created_after": "2024-01-01T00:00:00Z"}) + + await service.search_tool("MCP") + + params = _knn_params(os_client.bodies[-1]["query"]) + assert params + for knn in params: + assert knn["filter"] == { + "bool": {"filter": [{"range": {"indexed_time": {"gte": "2024-01-01T00:00:00Z"}}}]} + } From f148849d6c22b8edd401b411599008bffe5f636b Mon Sep 17 00:00:00 2001 From: Visvesh Jegadheesh Date: Wed, 21 Oct 2026 10:42:22 -0700 Subject: [PATCH 25/38] fix: make date range picker responsive with different widths/formats --- .../knowledge-date-range-filter.tsx | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/frontend/components/knowledge-date-range-filter.tsx b/frontend/components/knowledge-date-range-filter.tsx index 5c75ad881d..ba62d809be 100644 --- a/frontend/components/knowledge-date-range-filter.tsx +++ b/frontend/components/knowledge-date-range-filter.tsx @@ -173,12 +173,12 @@ function QuickRanges({ onCustomDays: (value: string) => void; }) { return ( -
-

+

+

Quick ranges

{/* A scrolling row on narrow screens, a list beside the calendar otherwise. */} -
+
{PRESETS.map((preset) => { const count = presetCount(preset.range(today)); const active = preset.id === activePresetId; @@ -189,7 +189,7 @@ function QuickRanges({ aria-pressed={active} onClick={() => onPreset(preset)} className={cn( - "flex flex-shrink-0 items-center justify-between gap-4 whitespace-nowrap rounded-md px-3 py-2 text-sm transition-colors hover:bg-accent sm:rounded-none sm:px-4 sm:py-2.5", + "flex flex-shrink-0 items-center justify-between gap-4 whitespace-nowrap rounded-md px-3 py-2 text-sm transition-colors hover:bg-accent md:rounded-none md:px-4 md:py-2.5", active && "bg-accent font-medium text-foreground", )} > @@ -201,7 +201,7 @@ function QuickRanges({ ); })}
-
+