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(