Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions kahuna/app/controllers/KahunaController.scala
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ package controllers
import com.gu.mediaservice.lib.argo.ArgoHelpers
import com.gu.mediaservice.lib.auth.Authentication.Principal
import com.gu.mediaservice.lib.auth.{Authentication, Authorisation, BaseControllerWithLoginRedirects}
import lib.{EnableAISearch, ExampleSwitch, FeatureSwitches, KahunaConfig, UseCqlChips}
import lib.{EnableAISearch, EnableAISearchManualMode, ExampleSwitch, FeatureSwitches, KahunaConfig, UseCqlChips}
import play.api.mvc.ControllerComponents
import play.api.libs.json._

Expand Down Expand Up @@ -38,7 +38,7 @@ class KahunaController(

val isIFramed = request.headers.get("Sec-Fetch-Dest").contains("iframe")
val featureSwitches = new FeatureSwitches(
List(ExampleSwitch, UseCqlChips, EnableAISearch)
List(ExampleSwitch, UseCqlChips, EnableAISearch, EnableAISearchManualMode)
)
val featureSwitchesWithClientValues = featureSwitches.getClientSwitchValues(featureSwitches.getFeatureSwitchCookies(request.cookies.get))
val featureSwitchesJson = Json.stringify(Json.toJson(featureSwitches.getFeatureSwitchesToStringify(featureSwitchesWithClientValues)))
Expand Down
6 changes: 6 additions & 0 deletions kahuna/app/lib/FeatureSwitch.scala
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ object EnableAISearch extends FeatureSwitch(
default = false
)

object EnableAISearchManualMode extends FeatureSwitch(
key = "enable-ai-search-manual-mode",
title = "Expose the AI search manual/hybrid toggle (off = always hybrid)",
default = false
)

class FeatureSwitches(featureSwitches: List[FeatureSwitch]){
// Feature switches are defined here, but updated by setting a cookie following the pattern e.g. "feature-switch-my-key"
// for a switch called "my-key".
Expand Down
8 changes: 1 addition & 7 deletions kahuna/public/js/components/gr-chips/gr-chips.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ 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.onChange = () => onChangeExpr($scope, {$chips: $grChipsCtrl.items});
Expand Down Expand Up @@ -63,9 +62,7 @@ grChips.controller('grChipsCtrl', ['$scope', function($scope) {
});

$grChipsCtrl.getPlaceholder = function() {
return $grChipsCtrl.useAiSearch ?
$grChipsCtrl.aiSearchPlaceholder :
$grChipsCtrl.defaultPlaceholder;
return $grChipsCtrl.defaultPlaceholder;
};

$grChipsCtrl.isEmpty = function() {
Expand Down Expand Up @@ -179,9 +176,6 @@ grChips.directive('grChips', ['$parse', function($parse) {
template: template,
controller: 'grChipsCtrl',
controllerAs: '$grChipsCtrl',
bindToController: {
useAiSearch: '<?'
},
compile: function compile(element, attrs) {
const autoCompleteExpr = $parse(attrs.grAutocomplete);
const onChangeExpr = $parse(attrs.grOnChange);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<span class="more-like-this" ng-if="ctrl.showMoreLikeThis">
<a class="side-padded"
ui-sref="search.results({ query: ctrl.getMoreLikeThisQuery(), useAISearch: true })"
ui-sref="search.results({ useAISearch: true, aiQuery: ctrl.getMoreLikeThisQuery() })"
gr-tooltip="Search for a selection of images with similarities to this"
aria-label="More like this"
>
Expand Down
1 change: 1 addition & 0 deletions kahuna/public/js/search/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ search.config(['$stateProvider', '$urlMatcherFactoryProvider',
'until',
'orderBy',
'useAISearch',
'aiQuery',
'vecWeight',
'dateField',
'takenSince',
Expand Down
64 changes: 60 additions & 4 deletions kahuna/public/js/search/query.html
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
<form class="search">
<span id="ai-search-checkbox" class="search__modifier-item search__modifier-checkbox"
ng-if="searchQuery.shouldDisplayAISearchOption">
<label>
ng-if="searchQuery.shouldDisplayAISearchOption">
<label title="Search using AI vector similarity">
<input type="checkbox"
ng-model="searchQuery.useAISearch"/>
Use AI search
</label>
</span>

<span class="search-query">
<gr-icon class="search-query__magnifier search-query__icon">search</gr-icon>
<gr-structured-query class="search-query__query"
Expand All @@ -22,6 +23,61 @@
</button>
</span>

<!-- Manual mode only: a second, dedicated AI/KNN box. The lexical box above is used as a filter. -->
<span class="search-query search-query--ai"
ng-if="searchQuery.useAISearch && searchQuery.aiMode === 'manual'"
title="Rank by AI/vector similarity. Combine with the metadata search to filter and re-rank.">
<gr-icon class="search-query__magnifier search-query__icon">auto_awesome</gr-icon>
<input class="search-query__query search-query__query--ai"
type="text"
ng-model="searchQuery.filter.aiQuery"
ng-model-options="{ debounce: 300 }"
placeholder="AI search (concepts, e.g. 'in the rain')"
aria-label="AI search query"/>
<button class="search-query__icon search-query__clear clear-button"
type="button"
title="Clear AI query"
ng-show="searchQuery.filter.aiQuery"
ng-click="searchQuery.filter.aiQuery = ''"
aria-label="Clear AI search">
<gr-icon>cancel</gr-icon>
</button>
</span>

<!-- AI search controls: mode toggle + (hybrid) optional vector/lexical weight slider. -->
<span class="search__ai-controls" ng-if="searchQuery.useAISearch">
<span class="search__ai-mode-toggle"
ng-if="searchQuery.shouldDisplayAIManualModeOption"
gr-tooltip="Switch between automatic hybrid ranking and manual KNN+filter ranking">
<label>
<input type="radio" name="aiMode" ng-model="searchQuery.aiMode" value="hybrid"/>
Hybrid
</label>
<label>
<input type="radio" name="aiMode" ng-model="searchQuery.aiMode" value="manual"/>
Manual
</label>
</span>

<span ng-if="searchQuery.aiMode === 'hybrid'" class="search__ai-weight">
<button type="button"
class="search__ai-weight-toggle"
ng-click="searchQuery.showWeightSlider = !searchQuery.showWeightSlider"
gr-tooltip="Show/hide the vector vs lexical mix slider">
<gr-icon>tune</gr-icon>
</button>
<span ng-if="searchQuery.showWeightSlider" class="search__ai-weight-slider">
<span class="search__ai-weight-label">lexical</span>
<input type="range" min="0" max="1" step="0.05"
ng-model="searchQuery.vecWeightModel"
ng-model-options="{ debounce: 200, getterSetter: true }"
aria-label="Vector weight"/>
<span class="search__ai-weight-label">vector</span>
<span class="search__ai-weight-value">{{ searchQuery.vecWeight | number:2 }}</span>
</span>
</span>
</span>

<button class="search__advanced-toggle" ng-class="searchQuery.usePermissionsFilter ? 'search__advanced-toggle-lbl' : ''"
type="button"
ng-click="searchInfo = !searchInfo"
Expand All @@ -30,7 +86,7 @@
<gr-icon>info_outline</gr-icon>
</button>

<div ng-if="!searchQuery.usePermissionsFilter && !searchQuery.useAISearch" class="search__modifier-container">
<div ng-if="!searchQuery.usePermissionsFilter && !(searchQuery.useAISearch && searchQuery.aiMode === 'hybrid')" class="search__modifier-container">
<button class="search__modifier-toggle"
type="button"
ng-click="filtersShown = !filtersShown ">
Expand Down Expand Up @@ -92,7 +148,7 @@
</ul>
</div>

<div ng-if="!searchQuery.usePermissionsFilter && !searchQuery.useAISearch" class="search__modifier-container">
<div ng-if="!searchQuery.usePermissionsFilter && !(searchQuery.useAISearch && searchQuery.aiMode === 'hybrid')" class="search__modifier-container">
<div class="search__modifier-sort">
<sort-control class="sort-control" props="searchQuery.sortProps"></sort-control>
</div>
Expand Down
55 changes: 48 additions & 7 deletions kahuna/public/js/search/query.js
Original file line number Diff line number Diff line change
Expand Up @@ -80,13 +80,30 @@ query.controller('SearchQueryCtrl', [
ctrl.initialShowPaidEvent = ($stateParams.nonFree === undefined && ctrl.usePermissionsFilter) ? false : true;

ctrl.shouldDisplayAISearchOption = getFeatureSwitchActive("enable-ai-search");
ctrl.shouldDisplayAIManualModeOption = getFeatureSwitchActive("enable-ai-search-manual-mode");
if (!ctrl.shouldDisplayAISearchOption) {
ctrl.useAISearch = false;
ctrl.vecWeight = undefined;
ctrl.aiMode = 'hybrid';
} else {
ctrl.useAISearch = ($stateParams.useAISearch === 'true' || $stateParams.useAISearch === true) ? true : false;
ctrl.vecWeight = $stateParams.vecWeight;
// vecWeight defaults to 1.0 (fully vector) when in hybrid mode.
const parsedWeight = parseFloat($stateParams.vecWeight);
ctrl.vecWeight = Number.isFinite(parsedWeight) ? parsedWeight : 1.0;
// Manual mode is implied by an existing aiQuery in the URL; otherwise default to hybrid.
// When the manual-mode feature switch is off, force hybrid regardless of URL params.
ctrl.aiMode = (ctrl.shouldDisplayAIManualModeOption && $stateParams.aiQuery && $stateParams.aiQuery.trim()) ? 'manual' : 'hybrid';
}
ctrl.showWeightSlider = false;

// Bridge for the range input so two-way binding can debounce numerically.
ctrl.vecWeightModel = function (newValue) {
if (arguments.length) {
const n = parseFloat(newValue);
ctrl.vecWeight = Number.isFinite(n) ? n : 1.0;
}
return ctrl.vecWeight;
};

//--react - angular interop events--
function raisePayableImagesEvent(showPaid) {
Expand Down Expand Up @@ -288,7 +305,7 @@ query.controller('SearchQueryCtrl', [
}
ctrl.filter.nonFree = nonFreeCheck;

sendTelemetryForQuery(ctrl.filter.query, nonFreeCheck, uploadedByMe, ctrl.useAISearch);
sendTelemetryForQuery(ctrl.filter.query, nonFreeCheck, uploadedByMe, !!ctrl.filter.aiQuery);
if (ctrl.collectionSearch && !curCollectionSearch) {
storage.setJs("orderBy", CollectionSortOption.value);
ctrl.ordering["orderBy"] = CollectionSortOption.value;
Expand Down Expand Up @@ -389,8 +406,6 @@ query.controller('SearchQueryCtrl', [
Object.keys($stateParams).
// Exclude date-related filters, managed separately in dateFilter
filter(key => dateFilterParams.indexOf(key) === -1).
// Exclude useAISearch, managed separately by its own dedicated watcher
filter(key => key !== 'useAISearch').
forEach(setAndWatchParam);

// URL parameters are not decoded when taken out of the params.
Expand Down Expand Up @@ -451,20 +466,46 @@ query.controller('SearchQueryCtrl', [
$scope.$watch(() => ctrl.ordering.orderBy, onValChange(newVal => {
$state.go('search.results', {...ctrl.filter, orderBy: newVal});
}));

$scope.$watch(() => ctrl.useAISearch, () => {
// Note: $watch expressions execute at least once during initialization, so this is executed on page refresh.
// This is the behaviour we want so that the URL is updated based on the AI search toggle
if (ctrl.useAISearch) {
// When entering AI mode, include the current mode/weight in the URL but DON'T inject an
// aiQuery unless we're already in manual mode with one set.
$state.go('search.results', {
...ctrl.filter,
useAISearch: true,
vecWeight: ctrl.vecWeight
vecWeight: ctrl.aiMode === 'hybrid' ? ctrl.vecWeight : null,
aiQuery: ctrl.aiMode === 'manual' ? ctrl.filter.aiQuery : null
});
} else {
$state.go('search.results', {...ctrl.filter, useAISearch: null});
// When leaving AI mode, clear all AI-specific params so we don't carry stale state.
$state.go('search.results', {...ctrl.filter, useAISearch: null, vecWeight: null, aiQuery: null});
}
});

// Toggling between hybrid and manual AI modes adjusts which URL params are live.
$scope.$watch(() => ctrl.aiMode, onValChange((newMode) => {
if (!ctrl.useAISearch) { return; }
if (newMode === 'hybrid') {
// Hybrid: clear the manual aiQuery so the backend takes the hybrid path.
ctrl.filter.aiQuery = '';
$state.go('search.results', {...ctrl.filter, aiQuery: null, vecWeight: ctrl.vecWeight});
} else {
// Manual: stop sending vecWeight (it's a hybrid-only knob).
$state.go('search.results', {...ctrl.filter, vecWeight: null});
}
}));

// Hybrid mode slider for the vector/lexical weight (only meaningful while useAISearch is on
// and the manual aiQuery dual-box is not in use).
$scope.$watch(() => ctrl.vecWeight, onValChange((newVal) => {
if (ctrl.useAISearch && ctrl.aiMode === 'hybrid') {
$state.go('search.results', {...ctrl.filter, vecWeight: newVal});
}
}));

$scope.$watchCollection(() => ctrl.dateFilter, onValChange(({field, since, until}) => {
// Translate dateFilter to actual state and query params
$state.go('search.results', {...ctrl.filter, ...{
Expand Down Expand Up @@ -529,7 +570,7 @@ query.controller('SearchQueryCtrl', [


const { nonFree, uploadedByMe } = ctrl.filter;
sendTelemetryForQuery(ctrl.filter.query, nonFree, uploadedByMe, ctrl.useAISearch);
sendTelemetryForQuery(ctrl.filter.query, nonFree, uploadedByMe, !!ctrl.filter.aiQuery);
}]);

query.directive('searchQuery', [function() {
Expand Down
10 changes: 6 additions & 4 deletions kahuna/public/js/search/results.js
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ results.controller('SearchResultsCtrl', [
ctrl.newImagesCount = 0;
ctrl.newImagesLastCheckedMoment = moment();

ctrl.needsQuery = $stateParams.useAISearch && (!$stateParams.query || !$stateParams.query.trim());
ctrl.needsQuery = false;

// Map to track image->position and help remove duplicates
let imagesPositions;
Expand Down Expand Up @@ -303,7 +303,8 @@ results.controller('SearchResultsCtrl', [
// (i.e. the uploadTime of the newest result in the set)

// TODO: avoid this initial search (two API calls to init!)
const isAiSearch = !!$stateParams.useAISearch;
// AI/KNN search activates when the useAISearch flag is set.
const isAiSearch = !!$stateParams.useAISearch && $stateParams.useAISearch !== 'false';
const initialSearchParams = isAiSearch
? {offset: 0, length: $window._clientConfig.aiSearchResultLimit}
: {length: 1, orderBy: 'newest'};
Expand All @@ -318,7 +319,7 @@ results.controller('SearchResultsCtrl', [
});

ctrl.loadRange = function(start, end) {
if ($stateParams.useAISearch) {
if (!!$stateParams.useAISearch && $stateParams.useAISearch !== 'false') {
return;
}

Expand Down Expand Up @@ -424,7 +425,7 @@ results.controller('SearchResultsCtrl', [
function checkForNewImages() {
// Polling for new images is meaningless for AI search — results are
// ranked by vector similarity, not upload time.
if ($stateParams.useAISearch) { return; }
if (!!$stateParams.useAISearch && $stateParams.useAISearch !== 'false') { return; }

$timeout(() => {
// Use explicit `until`, or blank it to find new images
Expand Down Expand Up @@ -578,6 +579,7 @@ results.controller('SearchResultsCtrl', [
length: length,
orderBy: orderBy,
useAISearch: $stateParams.useAISearch,
aiQuery: $stateParams.aiQuery,
vecWeight: $stateParams.vecWeight,
hasRightsAcquired: $stateParams.hasRightsAcquired,
hasCrops: $stateParams.hasCrops,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,7 @@ grStructuredQuery.directive("grStructuredQuery", [
ng-model="ctrl.structuredQuery"
gr:valid-keys="ctrl.filterFields"
gr:on-change="ctrl.structuredQueryChanged($chips)"
gr:autocomplete="ctrl.getSuggestions($chip)"
use-ai-search="searchQuery.useAISearch">
gr:autocomplete="ctrl.getSuggestions($chip)">
</gr-chips>
<gr-cql-input ng-if="ctrl.useCql"
on-change="ctrl.handleChange"
Expand Down
3 changes: 2 additions & 1 deletion kahuna/public/js/services/api/media-api.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ mediaApi.factory('mediaApi',
payType, uploadedBy, offset, length, orderBy,
takenSince, takenUntil,
modifiedSince, modifiedUntil, hasRightsAcquired, hasCrops,
syndicationStatus, countAll, persisted, useAISearch, vecWeight} = {}) {
syndicationStatus, countAll, persisted, useAISearch, aiQuery, vecWeight} = {}) {
return root.follow('search', {
q: query,
since: since,
Expand All @@ -66,6 +66,7 @@ mediaApi.factory('mediaApi',
countAll,
persisted,
useAISearch: maybeStringToBoolean(useAISearch),
aiQuery: aiQuery || undefined,
vecWeight: vecWeight
}).get();
}
Expand Down
Loading
Loading