Skip to content

Commit cf13388

Browse files
authored
Merge pull request #4785 from guardian/js-add-aggregations-and-filter-feedback
Add GNM-owned, agency picks and filtered matches to AI search
2 parents da27bd1 + bf5336d commit cf13388

4 files changed

Lines changed: 71 additions & 24 deletions

File tree

kahuna/public/js/search/results.html

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@
2222

2323
<div class="results-toolbar-item results-toolbar-item--static
2424
image-results-count">
25-
{{ctrl.totalResults | toLocaleString}} matches
25+
<span ng-if="ctrl.isAiSearch">Best {{ctrl.aiResultsShown | toLocaleString}} of {{ctrl.totalResults | toLocaleString}} matches</span>
26+
<span ng-if="! ctrl.isAiSearch">{{ctrl.totalResults | toLocaleString}} matches</span>
2627
<button ng-if="ctrl.newImagesCount > 0"
2728
ng-click="ctrl.revealNewImages()"
2829
class="image-results-count__new"

kahuna/public/js/search/results.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,9 @@ results.controller('SearchResultsCtrl', [
248248
function initialiseAiResults(images) {
249249
const totalLength = images.data.length;
250250
ctrl.imagesAll = new Array(totalLength);
251+
// Number of results actually shown (the top k), so we can display
252+
// "Best k of N matches" where N is ctrl.totalResults.
253+
ctrl.aiResultsShown = totalLength;
251254

252255
// AI search returns a single fixed result set rather than a paged/lazy-loaded one,
253256
// so we populate the full backing array up front to avoid placeholder rows.
@@ -293,6 +296,8 @@ results.controller('SearchResultsCtrl', [
293296
checkForNewImages();
294297
}
295298

299+
ctrl.isAiSearch = isAiSearch;
300+
296301
updateLastSearchBoundary();
297302

298303
return images;

media-api/app/controllers/MediaApi.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -611,7 +611,7 @@ class MediaApi(
611611
data = imageEntities,
612612
offset = Some(0),
613613
total = Some(searchResults.total),
614-
maybeExtraCounts = None,
614+
maybeExtraCounts = searchResults.extraCounts,
615615
links = Nil
616616
)
617617
}

media-api/app/lib/elasticsearch/ElasticSearch.scala

Lines changed: 63 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -341,7 +341,16 @@ class ElasticSearch(
341341
case _ => fusedLexicalAndSemanticSearch(query, queryEmbedding, k, numCandidates, vecWeight, filterOpt)
342342
}
343343

344-
searchResults.andThen { case _ =>
344+
// Run in parallel: how many images match the filters in total (so we can
345+
// show "Best k of N matches"). The ticker count badges are computed over
346+
// the top-k results we actually return, not the whole filtered set.
347+
val filterTotal = countMatchingFilter(filterOpt)
348+
349+
(for {
350+
results <- searchResults
351+
total <- filterTotal
352+
extraCounts <- extraCountsForIds(results.hits.map(_._1))
353+
} yield results.copy(total = total, extraCounts = Some(extraCounts))).andThen { case _ =>
345354
val elapsed = stopwatch.elapsed
346355
logger.info(
347356
combineMarkers(logMarker, elapsed),
@@ -351,6 +360,32 @@ class ElasticSearch(
351360
}
352361
}
353362

363+
// How many images match the active filters in total, so we can show
364+
// "Best k of N matches".
365+
private def countMatchingFilter(filterOpt: Option[Query])(implicit ex: ExecutionContext, logMarker: LogMarker): Future[Long] = {
366+
val countRequest = ElasticDsl.count(imagesCurrentAlias).query(filterOpt.getOrElse(matchAllQuery()))
367+
368+
executeAndLog(countRequest, "hybrid AI search filter count").map(_.result.count)
369+
}
370+
371+
// The ticker count badges restricted to the given (top-k) result ids.
372+
// In principle, we could compute this here, without an extra request.
373+
// But it would require awkward duplication of the search logic
374+
// from `maybeOrgOwnedExtraCount` and `maybeAgencyPicksExtraCount`
375+
private def extraCountsForIds(ids: Seq[String])(implicit ex: ExecutionContext, logMarker: LogMarker): Future[ExtraCounts] = {
376+
if (ids.isEmpty) Future.successful(ExtraCounts(tickerCounts = Map.empty))
377+
else {
378+
val searchRequest = ElasticDsl.search(imagesCurrentAlias)
379+
.query(filters.ids(ids.toList))
380+
.size(0)
381+
.aggregations(extraCountAggregations)
382+
383+
executeAndLog(withSearchQueryTimeout(searchRequest), "hybrid AI search ticker counts").map { r =>
384+
extraCountsFrom(r.result.aggregations)
385+
}
386+
}
387+
}
388+
354389
def search(params: SearchParams)(implicit ex: ExecutionContext, request: AuthenticatedRequest[AnyContent, Principal], logMarker: LogMarker = MarkerMap()): Future[SearchResults] = {
355390
val query: Query = queryBuilder.makeQuery(params.structuredQuery)
356391

@@ -391,10 +426,7 @@ class ElasticSearch(
391426
.runtimeMappings(runtimeMappings)
392427
.storedFields("_source") // this needs to be explicit when using script fields
393428
.scriptfields(graphicImagesScriptFields)
394-
.aggregations(aggregationsNameToSearchClauseMap.map {
395-
case (name, ExtraCountConfig(searchClause, _, maybeSubAggregation)) =>
396-
filterAgg(name, queryBuilder.makeQuery(Parser.run(searchClause))).subAggregations(maybeSubAggregation)
397-
})
429+
.aggregations(extraCountAggregations)
398430
.from(params.offset)
399431
.size(params.length)
400432
.sortBy(sort)
@@ -408,27 +440,36 @@ class ElasticSearch(
408440
SearchResults(
409441
hits = imageHits,
410442
total = if (trackTotalHits) r.result.totalHits else 0,
411-
extraCounts = Some(ExtraCounts(
412-
tickerCounts = aggregationsNameToSearchClauseMap.map {
413-
case (name, ExtraCountConfig(searchClause, backgroundColour, maybeSubAggregation)) =>
414-
val aggResult = r.result.aggregations.filter(name)
415-
val maybeSubAggResult = maybeSubAggregation.map(_.name).map(aggResult.result[Terms])
416-
name -> ExtraCount(
417-
value = aggResult.docCount,
418-
searchClause,
419-
backgroundColour,
420-
subCounts = maybeSubAggResult.map { termsAgg =>
421-
ListMap(termsAgg.buckets.sortBy(_.docCount).reverse.map { bucket =>
422-
(bucket.key, bucket.docCount)
423-
}: _*) + ("other" -> termsAgg.otherDocCount)
424-
}.filter(_.exists { case (_, count) => count > 0 })
425-
)
426-
}
427-
))
443+
extraCounts = Some(extraCountsFrom(r.result.aggregations))
428444
)
429445
}
430446
}
431447

448+
// The aggregations used to compute the ticker count badges ("GNM-owned",
449+
// "agency picks" etc) shown above search results.
450+
private def extraCountAggregations = aggregationsNameToSearchClauseMap.map {
451+
case (name, ExtraCountConfig(searchClause, _, maybeSubAggregation)) =>
452+
filterAgg(name, queryBuilder.makeQuery(Parser.run(searchClause))).subAggregations(maybeSubAggregation)
453+
}
454+
455+
private def extraCountsFrom(aggregations: Aggregations): ExtraCounts = ExtraCounts(
456+
tickerCounts = aggregationsNameToSearchClauseMap.map {
457+
case (name, ExtraCountConfig(searchClause, backgroundColour, maybeSubAggregation)) =>
458+
val aggResult = aggregations.filter(name)
459+
val maybeSubAggResult = maybeSubAggregation.map(_.name).map(aggResult.result[Terms])
460+
name -> ExtraCount(
461+
value = aggResult.docCount,
462+
searchClause,
463+
backgroundColour,
464+
subCounts = maybeSubAggResult.map { termsAgg =>
465+
ListMap(termsAgg.buckets.sortBy(_.docCount).reverse.map { bucket =>
466+
(bucket.key, bucket.docCount)
467+
}: _*) + ("other" -> termsAgg.otherDocCount)
468+
}.filter(_.exists { case (_, count) => count > 0 })
469+
)
470+
}
471+
)
472+
432473
def usageForSupplier(id: String, numDays: Int)(implicit ex: ExecutionContext, logMarker: LogMarker): Future[SupplierUsageSummary] = {
433474
val supplier = Agencies.get(id)
434475
val supplierName = supplier.supplier

0 commit comments

Comments
 (0)