diff --git a/kahuna/public/js/components/gr-chips/gr-chips.js b/kahuna/public/js/components/gr-chips/gr-chips.js index 5911eee453e..d3db7528aad 100644 --- a/kahuna/public/js/components/gr-chips/gr-chips.js +++ b/kahuna/public/js/components/gr-chips/gr-chips.js @@ -27,7 +27,7 @@ grChips.controller('grChipsCtrl', ['$scope', function($scope) { $grChipsCtrl.configureNgModel = function(ngModelCtrl, onChangeExpr, autoCompleteExpr, validKeysExpr, autofocus, placeholder) { $grChipsCtrl.defaultPlaceholder = placeholder || 'Search for images... (type + for advanced search)'; - $grChipsCtrl.aiSearchPlaceholder = 'Search for conceptual images using AI search... (no filters available)'; + $grChipsCtrl.aiSearchPlaceholder = 'Search for conceptual images using AI search...'; $grChipsCtrl.onChange = () => onChangeExpr($scope, {$chips: $grChipsCtrl.items}); diff --git a/kahuna/public/js/search/query.html b/kahuna/public/js/search/query.html index 8cfde8b1157..a01d32be66f 100644 --- a/kahuna/public/js/search/query.html +++ b/kahuna/public/js/search/query.html @@ -30,7 +30,7 @@ info_outline -
+
diff --git a/kahuna/public/js/search/results.js b/kahuna/public/js/search/results.js index 1268fd9a9e2..6721bf53ba5 100644 --- a/kahuna/public/js/search/results.js +++ b/kahuna/public/js/search/results.js @@ -1,7 +1,6 @@ import angular from 'angular'; import Rx from 'rx'; import moment from 'moment'; - import '../services/scroll-position'; import '../services/panel'; import '../util/async'; diff --git a/media-api/app/controllers/MediaApi.scala b/media-api/app/controllers/MediaApi.scala index 5b32930b4a6..12c99aaeeae 100644 --- a/media-api/app/controllers/MediaApi.scala +++ b/media-api/app/controllers/MediaApi.scala @@ -16,6 +16,7 @@ import com.gu.mediaservice.lib.play.RequestLoggingFilter import com.gu.mediaservice.model._ import com.gu.mediaservice.syntax.MessageSubjects import com.gu.mediaservice.{GridClient, JsonDiff} +import com.sksamuel.elastic4s.requests.searches.queries.Query import lib._ import lib.elasticsearch._ import org.apache.http.entity.ContentType @@ -575,16 +576,26 @@ class MediaApi( val canViewDeletedImages = _searchParams.query.contains("is:deleted") && !hasDeletePermission sealed trait AiSearchMode - case class TextSearch(query: String) extends AiSearchMode + case object TextSearch extends AiSearchMode case class SimilarSearch(imageId: String) extends AiSearchMode - def parseAiSearchMode(query: String): AiSearchMode = { - query - .split("\\s+") - .find(_.startsWith("similar:")) - .flatMap(_.split(":", 2).lift(1).filter(_.nonEmpty)) - .map(SimilarSearch) - .getOrElse(TextSearch(query)) + def parseAiSearchMode(params: SearchParams): AiSearchMode = + params.aiQueryParts.similarImageId.map(SimilarSearch).getOrElse(TextSearch) + + def buildAiFilter(params: SearchParams): Option[Query] = { + val filterConditions = params.aiQueryParts.filterConditions + + val chipFilterOpt = + if (filterConditions.nonEmpty) Some(elasticSearch.queryBuilder.makeQuery(filterConditions)) + else None + + val requestFilterOpt = elasticSearch.queryBuilder.buildFilterOpt( + params, + elasticSearch.searchFilters, + elasticSearch.syndicationFilter + ) + + elasticSearch.searchFilters.filterAndFilter(chipFilterOpt, requestFilterOpt) } def emptyAiSearchResponse = @@ -601,7 +612,9 @@ class MediaApi( ) } - def semanticSearchByImage(imageId: String, k: Int): Future[SearchResults] = { + def semanticSearchByImage(imageId: String, k: Int, params: SearchParams): Future[SearchResults] = { + val filterOpt = buildAiFilter(params) + for { maybeImage <- elasticSearch.getImageById(imageId) maybeEmbedding = maybeImage @@ -612,52 +625,68 @@ class MediaApi( searchResults <- maybeEmbedding match { // If we have an embedding, perform the KNN search. If not, return an empty result set. case Some(embedding) => - elasticSearch.knnSearch(embedding, k = k, numCandidates = Math.max(k * 2, 100)) + elasticSearch.knnSearch(embedding, k = k, numCandidates = Math.max(k * 2, 100), filterOpt = filterOpt) case None => Future.successful(SearchResults(Nil, total = 0, extraCounts = None)) } } yield searchResults } - def semanticSearchByText(query: String, k: Int, vecWeight: Option[Double]): Future[SearchResults] = { - // Normalise key so that "Dogs" and "dogs " share a cache entry. - val cacheKey = query.trim.toLowerCase + def semanticSearchByText(k: Int, params: SearchParams): Future[SearchResults] = { + // Separate the chips from the main query text + // So that we can embed just the query text + params.aiQueryParts.semanticQuery match { + case None => + logger.info(logMarker, s"No semantic query found in structured query; returning no AI results") + Future.successful(SearchResults(Nil, total = 0, extraCounts = None)) + case Some(semanticQuery) => + // Normalise key so that "Dogs" and "dogs " share a cache entry. + val cacheKey = semanticQuery.trim.toLowerCase + val markers = logMarker ++ Map("query" -> semanticQuery) + + if (embeddingCache.getIfPresent(cacheKey).isDefined) { + logger.info(markers, s"AI search embedding cache hit query=$semanticQuery") + } else { + logger.info(markers, s"AI search embedding cache miss query=$semanticQuery") + } - val markers = logMarker ++ Map("query" -> query) + val weight = params.vecWeight.getOrElse(1.0) - if (embeddingCache.getIfPresent(cacheKey).isDefined) { - logger.info(markers, s"AI search embedding cache hit query=$query") - } else { - logger.info(markers, s"AI search embedding cache miss query=$query") - } + // cache.get(key) is atomic: if two requests race on the same key, only one + // load fires and both callers receive the same Future. + val embeddingFuture = embeddingCache.get(cacheKey) - val weight = vecWeight.getOrElse(1.0) + val filterOpt = buildAiFilter(params) - // cache.get(key) is atomic: if two requests race on the same key, only one - // load fires and both callers receive the same Future. - val embeddingFuture = embeddingCache.get(cacheKey) - - logger.info(markers, s"vecWeight for query '$query' is $weight") - for { - embedding <- embeddingFuture - searchResults <- elasticSearch.hybridSearch( - query = query, - queryEmbedding = embedding, - k = k, - numCandidates = Math.max(k * 2, 100), - vecWeight = weight, - ) - } yield searchResults + logger.info(markers, s"vecWeight for query '$semanticQuery' is $weight") + for { + embedding <- embeddingFuture + searchResults <- elasticSearch.hybridSearch( + query = semanticQuery, + queryEmbedding = embedding, + k = k, + numCandidates = Math.max(k * 2, 100), + vecWeight = weight, + filterOpt = filterOpt + ) + } yield searchResults + } } - def performAiSearchAndRespond(query: String, vecWeight: Option[Double]): Future[Result] = { - val k = config.aiSearchResultLimit - val searchResultsFuture = parseAiSearchMode(query) match { - case SimilarSearch(imageId) => semanticSearchByImage(imageId, k) - case TextSearch(textQuery) => semanticSearchByText(textQuery, k, vecWeight) - } + def performAiSearchAndRespond(params: SearchParams): Future[Result] = { + params.aiQueryParts.validationError match { + case Left(errorMessage) => + logger.info(logMarker, s"Invalid AI search query: $errorMessage") + Future.successful(respondError(UnprocessableEntity, "invalid-ai-search", errorMessage)) + case scala.util.Right(_) => + val k = config.aiSearchResultLimit + val searchResultsFuture = parseAiSearchMode(params) match { + case SimilarSearch(imageId) => semanticSearchByImage(imageId, k, params) + case TextSearch => semanticSearchByText(k, params) + } - searchResultsFuture.map(aiSearchResponseFromResults) + searchResultsFuture.map(aiSearchResponseFromResults) + } } if (_searchParams.useAISearch.contains(true)) { @@ -666,7 +695,7 @@ class MediaApi( case _ if _searchParams.length == 0 => emptyAiSearchResponse case Some(q) if !q.isBlank => - performAiSearchAndRespond(q, _searchParams.vecWeight) + performAiSearchAndRespond(_searchParams) // Empty queries do not make sense for AI search as we can // only rank results once we have a meaningful vector to compare with. // So return 0 results if the query was empty. diff --git a/media-api/app/lib/elasticsearch/ElasticSearch.scala b/media-api/app/lib/elasticsearch/ElasticSearch.scala index 175fe3d63c6..190b8a3205e 100644 --- a/media-api/app/lib/elasticsearch/ElasticSearch.scala +++ b/media-api/app/lib/elasticsearch/ElasticSearch.scala @@ -178,12 +178,12 @@ class ElasticSearch( } } - def knnSearch(queryEmbedding: List[Float], k: Int, numCandidates: Int) + def knnSearch(queryEmbedding: List[Float], k: Int, numCandidates: Int, filterOpt: Option[Query]) (implicit ex: ExecutionContext, logMarker: LogMarker): Future[SearchResults] = { - val knn = Knn("embedding.cohereEmbedV4.image") - .queryVector(queryEmbedding.map(_.toDouble)) - .k(k) - .numCandidates(numCandidates) + val knn = Knn("embedding.cohereEmbedV4.image", filter = filterOpt) + .queryVector(queryEmbedding.map(_.toDouble)) + .k(k) + .numCandidates(numCandidates) val searchRequest = ElasticDsl.search(imagesCurrentAlias) .knn(knn) @@ -211,9 +211,12 @@ class ElasticSearch( // than cosine similarity (knn). So we get the max BM25 score for the query and use that to calculate // the scaling factor for the lexical part of the query, so that BM25 and knn scores are both between 0-1 scale // and can be effectively combined in a hybrid query. - private def fetchMaxBm25Score(query: String)(implicit ex: ExecutionContext, logMarker: LogMarker): Future[Double] = { + private def fetchMaxBm25Score(query: String, filterOpt: Option[Query])(implicit ex: ExecutionContext, logMarker: LogMarker): Future[Double] = { + val baseQuery = createMultiMatchQuery(query) + val filteredQuery = filterOpt.map(filter => boolQuery().must(baseQuery).filter(filter)).getOrElse(baseQuery) + val maxScoreRequest = ElasticDsl.search(imagesCurrentAlias) - .query(createMultiMatchQuery(query)) + .query(filteredQuery) executeAndLog(withSearchQueryTimeout(maxScoreRequest), "max BM25 score").map { r => logger.info(logMarker, s"Max BM25 score for query '$query' is ${r.result.hits.maxScore}") @@ -227,9 +230,10 @@ class ElasticSearch( k: Int, numCandidates: Int, vecWeight: Double, - maxScore: Double + maxScore: Double, + filterOpt: Option[Query] )(implicit logMarker: LogMarker): SearchRequest = { - val knn = Knn("embedding.cohereEmbedV4.image") + val knn = Knn("embedding.cohereEmbedV4.image", filter = filterOpt) .queryVector(queryEmbedding) .k(k) .numCandidates(numCandidates) @@ -242,9 +246,9 @@ class ElasticSearch( // BM25 score to bring it to the same range as the cosine similarity. val scalingFactor = if (maxScore > 0.0) 1.0 / maxScore else 1.0 - // We want to apply only one boost if we can help it, so we scale the - // multi_match boost to be in line with the max_score and the desired - // lexical_weight/vec_weight balance + // We want to apply only one boost if we can help it, so we scale the + // multi_match boost to be in line with the max_score and the desired + // lexical_weight/vec_weight balance val multiMatchBoost = if (vecWeight > 0.0) (lexicalWeight / vecWeight) * scalingFactor else 1.0 logger.info(logMarker, s"Scaling factor for BM25 score is $scalingFactor, multi-match boost is $multiMatchBoost") @@ -252,7 +256,7 @@ class ElasticSearch( val multiMatchQuery = createMultiMatchQuery(query, boost = Some(multiMatchBoost)) ElasticDsl.search(imagesCurrentAlias) - .bool(BoolQuery().should(Seq(multiMatchQuery, knn))) + .bool(BoolQuery().should(Seq(multiMatchQuery, knn)).filter(filterOpt)) .size(k) } @@ -262,6 +266,7 @@ class ElasticSearch( k: Int, numCandidates: Int, vecWeight: Double, + filterOpt: Option[Query] )( implicit ex: ExecutionContext, logMarker: LogMarker @@ -269,8 +274,8 @@ class ElasticSearch( val queryEmbeddingDouble: List[Double] = queryEmbedding.map(_.toDouble) for { - maxScore <- fetchMaxBm25Score(query) - searchRequest = makeHybridSearchRequest(query, queryEmbeddingDouble, k, numCandidates, vecWeight, maxScore) + maxScore <- fetchMaxBm25Score(query, filterOpt) + searchRequest = makeHybridSearchRequest(query, queryEmbeddingDouble, k, numCandidates, vecWeight, maxScore, filterOpt) result <- executeAndLog(withSearchQueryTimeout(searchRequest), "hybrid search") } yield { val imageHits = result.result.hits.hits.map(resolveHit).toSeq.flatten.map(i => (i.instance.id, i)) @@ -281,84 +286,7 @@ class ElasticSearch( 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) val withFilter = filterOpt.map { f => boolQuery() must (query) filter f diff --git a/media-api/app/lib/elasticsearch/ElasticSearchModel.scala b/media-api/app/lib/elasticsearch/ElasticSearchModel.scala index 4bac2fb3f44..e6dd8fed5b0 100644 --- a/media-api/app/lib/elasticsearch/ElasticSearchModel.scala +++ b/media-api/app/lib/elasticsearch/ElasticSearchModel.scala @@ -5,7 +5,7 @@ import com.gu.mediaservice.lib.auth.{Authentication, Tier} import com.gu.mediaservice.lib.formatting.{parseDateFromQuery, printDateTime} import com.gu.mediaservice.model.usage.UsageStatus import com.gu.mediaservice.model.{Image, PrintUsageFilters, SyndicationStatus} -import lib.querysyntax.{Condition, Parser} +import lib.querysyntax.{AnyField, Condition, Match, Parser, Phrase, SimilarField, SimilarValue, Words} import org.joda.time.DateTime import play.api.libs.json.{Json, OWrites} import play.api.mvc.{AnyContent, Request} @@ -17,6 +17,67 @@ case class SearchResults(hits: Seq[(String, SourceWrapper[Image])], total: Long, case class AggregateSearchResults(results: Seq[BucketResult], total: Long) +case class AiQueryParts( + semanticQuery: Option[String], + filterConditions: List[Condition], + similarImageId: Option[String] +) { + def hasSimilarAndSemanticText: Boolean = similarImageId.nonEmpty && semanticQuery.nonEmpty + + // A KNN bit is the only thing AI search can rank by, so a query must contain either + // a text query (text KNN) or a similar image (image KNN) to be valid. + def hasKnn: Boolean = similarImageId.nonEmpty || semanticQuery.nonEmpty + + // Returns Left(error message) if the AI query cannot be satisfied, otherwise Right(()). + def validationError: Either[String, Unit] = { + if (hasSimilarAndSemanticText) + Left( + "AI search can't combine a similar image search with a text query because the two rankings can't be merged. " + + "Please use either a text query or a similar image, not both." + ) + else if (!hasKnn) + Left( + "AI search needs something to rank by. " + + "Add a text query or a similar image alongside your filters." + ) + else + Right(()) + } +} + +object AiQueryParts { + def from(conditions: List[Condition]): AiQueryParts = { + val initial = AiQueryParts( + semanticQuery = None, + filterConditions = List.empty, + similarImageId = None, + ) + + conditions.foldLeft(initial) { + case (parts, Match(AnyField, Words(value))) => + appendSemanticText(parts, value) + case (parts, Match(AnyField, Phrase(value))) => + appendSemanticText(parts, value) + case (parts, Match(SimilarField, SimilarValue(imageId))) if imageId.trim.nonEmpty => + parts.copy(similarImageId = Some(imageId.trim)) + case (parts, Match(SimilarField, _)) => + parts + case (parts, condition) => + parts.copy(filterConditions = parts.filterConditions :+ condition) + } + } + + private def appendSemanticText(parts: AiQueryParts, value: String): AiQueryParts = { + val trimmedValue = value.trim + + if (trimmedValue.isEmpty) parts + else { + val semanticQuery = parts.semanticQuery.fold(trimmedValue)(existing => s"$existing $trimmedValue") + parts.copy(semanticQuery = Some(semanticQuery)) + } + } +} + case class CompletionSuggestionResult(key: String, score: Float) object CompletionSuggestionResult { @@ -87,7 +148,9 @@ case class SearchParams( shouldFlagGraphicImages: Boolean = false, useAISearch: Option[Boolean] = None, vecWeight: Option[Double] = None -) +) { + lazy val aiQueryParts: AiQueryParts = AiQueryParts.from(structuredQuery) +} case class InvalidUriParams(message: String) extends Throwable object InvalidUriParams { diff --git a/media-api/app/lib/elasticsearch/QueryBuilder.scala b/media-api/app/lib/elasticsearch/QueryBuilder.scala index 7b53c02c4c2..ed8989282b1 100644 --- a/media-api/app/lib/elasticsearch/QueryBuilder.scala +++ b/media-api/app/lib/elasticsearch/QueryBuilder.scala @@ -1,7 +1,7 @@ package lib.elasticsearch import com.gu.mediaservice.lib.ImageFields -import com.gu.mediaservice.lib.elasticsearch.IndexSettings +import com.gu.mediaservice.lib.elasticsearch.filters import com.gu.mediaservice.lib.formatting.printDateTime import com.gu.mediaservice.lib.logging.GridLogging import com.gu.mediaservice.model.Agency @@ -12,6 +12,8 @@ import com.sksamuel.elastic4s.requests.searches.queries.Query import com.sksamuel.elastic4s.requests.searches.queries.matches.{MultiMatchQuery, MultiMatchQueryBuilderType} import lib.querysyntax._ import lib.MediaApiConfig +import scalaz.NonEmptyList +import scalaz.syntax.std.list._ class QueryBuilder(matchFields: Seq[String], overQuotaAgencies: () => List[Agency], config: MediaApiConfig) extends ImageFields with GridLogging { @@ -82,6 +84,9 @@ class QueryBuilder(matchFields: Seq[String], overQuotaAgencies: () => List[Agenc matchNoneQuery() } } + case SimilarField => + logger.info(s"Cannot perform SIMILAR query on ${condition.value} outside AI search mode") + matchNoneQuery() } def makeQuery(conditions: List[Condition]) = conditions match { @@ -122,4 +127,87 @@ class QueryBuilder(matchFields: Seq[String], overQuotaAgencies: () => List[Agenc listOfNestedToQueries(negationNested).foldLeft(queryWithNestedAndNormal) { case (q, negNestedQ) => q.withNot(negNestedQ) } } } + + def buildFilterOpt(params: SearchParams, searchFilters: SearchFilters, syndicationFilter: SyndicationFilter): Option[Query] = { + + 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 + filterOpt + } } diff --git a/media-api/app/lib/querysyntax/QuerySyntax.scala b/media-api/app/lib/querysyntax/QuerySyntax.scala index cb62b5346e6..73dfc89080d 100644 --- a/media-api/app/lib/querysyntax/QuerySyntax.scala +++ b/media-api/app/lib/querysyntax/QuerySyntax.scala @@ -33,6 +33,7 @@ class QuerySyntax(val input: ParserInput) extends Parser with ImageFields { def Filter = rule { HasMatch ~> Match | IsMatch ~> Match | + SimilarMatch ~> Match | DateConstraintMatch | DateRangeMatch ~> Match | AtMatch | FileTypeMatch ~> Match | @@ -51,6 +52,11 @@ class QuerySyntax(val input: ParserInput) extends Parser with ImageFields { def IsFieldName = rule { "is" } def IsMatchValue = rule { QuotedString ~> IsValue | String ~> IsValue } + def SimilarMatch = rule { SimilarMatchField ~ ':' ~ SimilarMatchValue } + def SimilarMatchField = rule { capture(SimilarFieldName) ~> (_ => SimilarField) } + def SimilarFieldName = rule { "similar" } + def SimilarMatchValue = rule { QuotedString ~> SimilarValue | String ~> SimilarValue } + def NestedMatch = rule { ParentField ~ "@" ~ NestedField ~ ':' ~ ExactMatchValue } def NestedDateMatch = rule { ParentField ~ "@" ~ DateConstraintMatch ~> ( (parentField: Field, dateMatch: Match) => { diff --git a/media-api/app/lib/querysyntax/model.scala b/media-api/app/lib/querysyntax/model.scala index fe22886154d..871cb79e5ed 100644 --- a/media-api/app/lib/querysyntax/model.scala +++ b/media-api/app/lib/querysyntax/model.scala @@ -15,6 +15,7 @@ final case class SingleField(name: String) extends Field final case class MultipleField(names: List[String]) extends Field final case object HasField extends Field final case object IsField extends Field +final case object SimilarField extends Field sealed trait Value final case class Words(string: String) extends Value @@ -23,3 +24,4 @@ final case class Date(date: DateTime) extends Value final case class DateRange(startDate: DateTime, endDate: DateTime) extends Value final case class HasValue(string: String) extends Value final case class IsValue(string: String) extends Value +final case class SimilarValue(string: String) extends Value diff --git a/media-api/test/lib/elasticsearch/AiQueryPartsTest.scala b/media-api/test/lib/elasticsearch/AiQueryPartsTest.scala new file mode 100644 index 00000000000..306b670fd68 --- /dev/null +++ b/media-api/test/lib/elasticsearch/AiQueryPartsTest.scala @@ -0,0 +1,130 @@ +package lib.elasticsearch + +import com.gu.mediaservice.lib.ImageFields +import lib.querysyntax.{AnyField, IsField, IsValue, Match, Negation, NegationNested, Nested, Parser, Phrase, SingleField, Words} +import org.scalatest.funspec.AnyFunSpec +import org.scalatest.matchers.should.Matchers + +class AiQueryPartsTest extends AnyFunSpec with Matchers with ImageFields { + + // The parser always appends these two negations to every parsed query, so they + // always show up as part of the derived filter conditions. + private val standardNegations = List( + Negation(Match(IsField, IsValue("deleted"))), + NegationNested(Nested(SingleField("usages"), SingleField("usages.status"), Phrase("replaced"))) + ) + + private val jpegFilter = Match(SingleField(getFieldPath("mimeType")), Words("image/jpeg")) + private val negatedAngela = Negation(Match(AnyField, Words("angela"))) + + // Build the AiQueryParts the same way the controller does: parse the raw query + // string with the production parser, then derive the AI search parts from it. + private def aiPartsFor(query: String): AiQueryParts = + AiQueryParts.from(Parser.run(query)) + + describe("AiQueryParts validation") { + + describe("valid queries (each contains a KNN ranking signal)") { + + it("allows a similar-image-only query (image KNN)") { + val parts = aiPartsFor("similar:abc123") + + parts.similarImageId should be(Some("abc123")) + parts.semanticQuery should be(None) + parts.hasKnn should be(true) + parts.validationError should be(Right(())) + } + + it("allows a text-only query (text KNN)") { + val parts = aiPartsFor("keir starmer") + + parts.semanticQuery should be(Some("keir starmer")) + parts.similarImageId should be(None) + parts.hasKnn should be(true) + parts.validationError should be(Right(())) + } + + it("allows chips/filters combined with a text query (pre-filter then text KNN)") { + val parts = aiPartsFor("fileType:jpeg keir starmer") + + parts.semanticQuery should be(Some("keir starmer")) + parts.filterConditions should be(jpegFilter :: standardNegations) + parts.hasKnn should be(true) + parts.validationError should be(Right(())) + } + + it("allows chips/filters combined with a similar image (pre-filter then image KNN)") { + val parts = aiPartsFor("similar:abc123 fileType:jpeg") + + parts.similarImageId should be(Some("abc123")) + parts.filterConditions should be(jpegFilter :: standardNegations) + parts.hasKnn should be(true) + parts.validationError should be(Right(())) + } + + it("allows a negative term combined with a text query (negative is a lexical pre-filter, then text KNN)") { + val parts = aiPartsFor("keir starmer -angela") + + parts.semanticQuery should be(Some("keir starmer")) + // The negation is treated as a filter condition, not a KNN ranking signal. + parts.filterConditions should be(negatedAngela :: standardNegations) + parts.hasKnn should be(true) + parts.validationError should be(Right(())) + } + + it("allows a negative term combined with a similar image (negative pre-filter, then image KNN)") { + val parts = aiPartsFor("similar:abc123 -angela") + + parts.similarImageId should be(Some("abc123")) + parts.filterConditions should be(negatedAngela :: standardNegations) + parts.hasKnn should be(true) + parts.validationError should be(Right(())) + } + } + + describe("invalid queries that combine two rankings") { + + it("rejects a similar image combined with a text query (rankings can't be merged)") { + val parts = aiPartsFor("similar:abc123 keir starmer") + + parts.similarImageId should be(Some("abc123")) + parts.semanticQuery should be(Some("keir starmer")) + parts.hasSimilarAndSemanticText should be(true) + parts.validationError.isLeft should be(true) + } + } + + describe("invalid queries that contain no KNN ranking signal") { + + it("rejects a chips/filters-only query") { + val parts = aiPartsFor("fileType:jpeg") + + parts.semanticQuery should be(None) + parts.similarImageId should be(None) + parts.filterConditions should be(jpegFilter :: standardNegations) + parts.hasKnn should be(false) + parts.validationError.isLeft should be(true) + } + + it("rejects a negative-only query") { + val parts = aiPartsFor("-angela") + + parts.semanticQuery should be(None) + parts.similarImageId should be(None) + parts.filterConditions should be(negatedAngela :: standardNegations) + parts.hasKnn should be(false) + parts.validationError.isLeft should be(true) + } + + it("rejects a negative term combined with chips/filters") { + val parts = aiPartsFor("-angela fileType:jpeg") + + parts.semanticQuery should be(None) + parts.similarImageId should be(None) + parts.filterConditions should be(negatedAngela :: jpegFilter :: standardNegations) + parts.hasKnn should be(false) + parts.validationError.isLeft should be(true) + } + } + } +} diff --git a/media-api/test/lib/querysyntax/ParserTest.scala b/media-api/test/lib/querysyntax/ParserTest.scala index 4e5aa9ddbfa..4cfea97da10 100644 --- a/media-api/test/lib/querysyntax/ParserTest.scala +++ b/media-api/test/lib/querysyntax/ParserTest.scala @@ -486,6 +486,24 @@ class ParserTest extends AnyFunSpec with Matchers with BeforeAndAfter with Image } } + describe("similar filter") { + it("should parse similar queries") { + Parser.run("similar:abc123") should be (List( + Match(SimilarField, SimilarValue("abc123")), + ) ++ standardNegations + ) + } + + it("should parse similar queries with other filters and text") { + Parser.run("similar:abc123 fileType:jpeg hello") should be (List( + Match(SimilarField, SimilarValue("abc123")), + Match(SingleField(getFieldPath("mimeType")), Words("image/jpeg")), + Match(AnyField, Words("hello")), + ) ++ standardNegations + ) + } + } + describe("fileType filter") { it("should find jpegs images") { Parser.run("fileType:jpeg") should be (List(