Add chips and filtering to AI Search#4752
Conversation
There was a problem hiding this comment.
Pull request overview
This PR updates Grid’s AI search flow so it respects the existing structured query “chips”/filters, and adds first-class structured parsing support for similar: queries. It splits AI queries into (a) semantic free text to embed and (b) structured filter conditions to apply to Elasticsearch, reusing existing query-building/filter logic so AI mode behaves consistently with standard search.
Changes:
- Added
similar:as a structured query filter in the server-side parser + model, with corresponding parser tests. - Introduced
AiQueryPartsto split structured queries into semantic text, filter conditions, and optional similar-image ID; updated Media API AI search to use these parts and apply filters to hybrid/KNN queries. - Updated Kahuna UI to re-enable filters in AI mode and add frontend guards for unsupported AI query shapes.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| media-api/test/lib/querysyntax/ParserTest.scala | Adds coverage to ensure similar: is parsed into structured conditions. |
| media-api/app/lib/querysyntax/QuerySyntax.scala | Extends parser grammar to recognize similar: as a filter. |
| media-api/app/lib/querysyntax/model.scala | Adds SimilarField / SimilarValue to the structured query model. |
| media-api/app/lib/elasticsearch/QueryBuilder.scala | Handles SimilarField explicitly and centralizes construction of request-level filter queries via buildFilterOpt. |
| media-api/app/lib/elasticsearch/ElasticSearchModel.scala | Adds AiQueryParts and exposes SearchParams.aiQueryParts for AI query splitting. |
| media-api/app/lib/elasticsearch/ElasticSearch.scala | Threads filterOpt through KNN + hybrid queries; uses QueryBuilder.buildFilterOpt during standard search. |
| media-api/app/controllers/MediaApi.scala | Switches AI mode detection and filtering to use SearchParams.aiQueryParts; blocks unsupported similar:+semantic combinations. |
| kahuna/public/js/search/structured-query/syntax.ts | Adds low-level query validation helpers for AI mode (positive unfielded text vs similar:). |
| kahuna/public/js/search/results.js | Updates AI “needs query” guard logic using the new structured-query helpers. |
| kahuna/public/js/search/query.html | Makes filter UI visible in AI mode (except sort UI remains hidden for AI). |
| kahuna/public/js/components/gr-chips/gr-chips.js | Updates AI placeholder text to reflect that filters are now available. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| def search(params: SearchParams)(implicit ex: ExecutionContext, request: AuthenticatedRequest[AnyContent, Principal], logMarker: LogMarker = MarkerMap()): Future[SearchResults] = { | ||
| val query: Query = queryBuilder.makeQuery(params.structuredQuery) | ||
|
|
||
| val uploadTimeFilter = filters.date("uploadTime", params.since, params.until) | ||
| val lastModTimeFilter = filters.date("lastModified", params.modifiedSince, params.modifiedUntil) | ||
| val takenTimeFilter = filters.date("metadata.dateTaken", params.takenSince, params.takenUntil) | ||
| // we only inject filters if there are actual date parameters | ||
| val dateFilterList = List(uploadTimeFilter, lastModTimeFilter, takenTimeFilter).flatten.toNel | ||
| val dateFilter = dateFilterList.map(dateFilters => filters.and(dateFilters.list.toList: _*)) | ||
|
|
||
| val idsFilter = params.ids.map(filters.ids) | ||
| val labelFilter = params.labels.toNel.map(filters.terms("labels", _)) | ||
| val metadataFilter = params.hasMetadata.map(metadataField).toNel.map(filters.exists) | ||
| val archivedFilter = params.archived.map(filters.existsOrMissing(editsField("archived"), _)) | ||
| val hasExports = params.hasExports.map(filters.existsOrMissing("exports", _)) | ||
| val hasIdentifier = params.hasIdentifier.map(idName => filters.exists(NonEmptyList(identifierField(idName)))) | ||
| val missingIdentifier = params.missingIdentifier.map(idName => filters.missing(NonEmptyList(identifierField(idName)))) | ||
| val uploadedByFilter = params.uploadedBy.map(uploadedBy => filters.terms("uploadedBy", NonEmptyList(uploadedBy))) | ||
| val simpleCostFilter = params.free.flatMap(free => if (free) searchFilters.freeFilter else searchFilters.nonFreeFilter) | ||
| val costFilter = params.payType match { | ||
| case Some(PayType.Free) => searchFilters.freeFilter | ||
| case Some(PayType.MaybeFree) => searchFilters.maybeFreeFilter | ||
| case Some(PayType.Pay) => searchFilters.nonFreeFilter | ||
| case _ => None | ||
| } | ||
|
|
||
| val printUsageFilter = params.printUsageFilters.map(searchFilters.printUsageFilters) | ||
|
|
||
| val hasRightsCategory = params.hasRightsCategory.filter(_ == true).map(_ => searchFilters.hasRightsCategoryFilter) | ||
|
|
||
| val validityFilter = params.valid.map(valid => if (valid) searchFilters.validFilter else searchFilters.invalidFilter) | ||
|
|
||
| val persistFilter = params.persisted map { | ||
| case true => searchFilters.persistedFilter | ||
| case false => searchFilters.nonPersistedFilter | ||
| } | ||
|
|
||
| val usageFilter: Iterable[Query] = | ||
| params.usageStatus.toNel.map(status => filters.terms("usagesStatus", status.map(_.toString))).toOption ++ | ||
| params.usagePlatform.toNel.map(filters.terms("usagesPlatform", _)).toOption | ||
|
|
||
| val syndicationStatusFilter = params.syndicationStatus.map(status => syndicationFilter.statusFilter(status)) | ||
|
|
||
| // Port of special case code in elastic1 sorts. Using the dateAddedToCollection sort implies an additional filter for reasons unknown | ||
| val dateAddedToCollectionFilter = { | ||
| params.orderBy match { | ||
| case Some("dateAddedToCollection") => { | ||
| val pathHierarchyOpt = params.structuredQuery.flatMap { | ||
| case Match(HierarchyField, Phrase(value)) => Some(value) | ||
| case _ => None | ||
| }.headOption | ||
|
|
||
| pathHierarchyOpt.map { pathHierarchy => | ||
| termQuery("collections.pathHierarchy", pathHierarchy) | ||
| } | ||
| } | ||
| case _ => None | ||
| } | ||
| } | ||
|
|
||
| val filterOpt = ( | ||
| metadataFilter.toOption.toList | ||
| ++ persistFilter | ||
| ++ labelFilter.toOption | ||
| ++ archivedFilter | ||
| ++ uploadedByFilter | ||
| ++ idsFilter | ||
| ++ validityFilter | ||
| ++ simpleCostFilter | ||
| ++ costFilter | ||
| ++ hasExports | ||
| ++ hasIdentifier | ||
| ++ missingIdentifier | ||
| ++ dateFilter.toOption | ||
| ++ usageFilter | ||
| ++ hasRightsCategory | ||
| ++ searchFilters.tierFilter(params.tier) | ||
| ++ syndicationStatusFilter | ||
| ++ dateAddedToCollectionFilter | ||
| ++ printUsageFilter | ||
| ).toNel.map(filter => filter.list.toList.reduceLeft(filters.and(_, _))).toOption | ||
| val filterOpt: Option[Query] = queryBuilder.buildFilterOpt(params, searchFilters, syndicationFilter) | ||
|
|
| ctrl.needsQuery = $stateParams.useAISearch && ( | ||
| !hasPositiveAiTextQuery($stateParams.query) || | ||
| hasSimilarAndPositiveAiTextQuery($stateParams.query) | ||
| ); |
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
|
Yeah it's tricky. The problem with "200 relevant" is we don't know that all 200 are relevant! They're just the best 200 we could manage. In fact it's very rare they will all be relevant. |
Yes, of course. I struggled the come up with a word (just like for “Sort” order: I think they should be the same word). But what matters here more than the fact that the word isn’t perfect is that it’s there at all: it signals a) it’s a bound subset b) of a corpus you can actively shape, c) has to do with this AI thing and d) is about ordering. Especially b) ( And [EDIT: sorry deleted doubled comment] |
|
Seen on auth, image-loader, metadata-editor, cropper, collections, kahuna (merged by @ellenmuller 9 minutes and 38 seconds ago) Please check your changes! |
|
Seen on thrall, usage, media-api (merged by @ellenmuller 9 minutes and 47 seconds ago) Please check your changes! |
|
Seen on leases (merged by @ellenmuller 9 minutes and 51 seconds ago) Please check your changes! |


What does this change?
This PR makes AI search respect Grid’s existing chip/filter behaviour and adds structured support for
similar:queries.Previously, 'Use AI search' embedded the raw query, including any chips, so structured chips such as file type and other filters did not work as one might expect. This branch changes the frontend and backend flow so AI search separates semantic text from structured filters, applies those filters to Elasticsearch, and handles
similar:as part of the structured query model rather than via ad hoc string parsing.Changes
How should a reviewer test this change?
How can success be measured?
Who should look at this?
Tested? Documented?