Skip to content

Add natural language search #3

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
Open
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
45 changes: 44 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ It's not officially supported, but you may also be able to use it for
certain _OGC API - Records_ and _OGC API - Features_ compliant servers.

**Please note that STAC Browser is currently with limited funding for both maintenance, bug fixes and improvements. This means issues and PRs may be addressed very slowly.
If you care about STAC Browser and have some funds to support the future of STAC Browser, please contact [email protected]**
If you care about STAC Browser and have some funds to support the future of STAC Browser, please contact <[email protected]>**

**Table of Contents:**

Expand All @@ -37,6 +37,7 @@ If you care about STAC Browser and have some funds to support the future of STAC
- [Translation](#translation)
- [Customize through root catalog](#customize-through-root-catalog)
- [Custom extensions](#custom-extensions)
- [Natural Language Search](#natural-language-search)
- [Docker](#docker)
- [Contributing](#contributing)
- [Adding a new language](#adding-a-new-language)
Expand Down Expand Up @@ -269,6 +270,48 @@ STAC Browser supports some non-standardized extensions to the STAC specification
Add a `name` field and it will be used as title in the tab header, the same applies for the core Asset Object.
3. A link with relation type `icon` and a Browser-supported media type in any STAC entity will show an icon in the header and the lists of Catalogs, Collections and Items.

### Natural Language Search

STAC Browser supports natural language search functionality that allows users to search for STAC items using descriptive queries in natural language (e.g., "satellite images of forests in California from 2023").

To enable natural language search:

1. **Configure the API URL**: Set the `semanticSearchApiUrl` option in your configuration file:

```javascript
// config.js
module.exports = {
// ... other config options
semanticSearchApiUrl: "https://your-semantic-search-api.com",
// ... other config options
};
```

2. **API Requirements**: Your semantic search API should accept POST requests to `/items/search` with the following format:

```json
{
"query": "your natural language query",
"limit": 10
}
```

And return results in this format:

```json
{
"results": {
"items": [
// Array of STAC items
]
}
}
```

3. **User Interface**: Once configured, users will see the "Natural Language Query" search interface where they can enter descriptive queries.

**Note**: Natural language search is disabled by default. The feature will only appear in the interface when `semanticSearchApiUrl` is configured with a valid API URL.

## Docker

You can use the Docker to work with STAC Browser. Please read [Docker documentation](docs/docker.md) for more details.
Expand Down
3 changes: 2 additions & 1 deletion config.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,5 +48,6 @@ module.exports = {
requestQueryParameters: {},
socialSharing: ['email', 'bsky', 'mastodon', 'x'],
preprocessSTAC: null,
authConfig: null
authConfig: null,
semanticSearchApiUrl: false,
};
3 changes: 3 additions & 0 deletions src/components/ApiCapabilitiesMixin.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@ export default {
canFilterFreeText() {
return this.supportsConformance(this.conformances.FreeText);
},
canSupportNaturalLanguage() {
return true;
},
cql() {
if (!this.supportsConformance(this.conformances.CqlFilters)) {
return null;
Expand Down
14 changes: 14 additions & 0 deletions src/components/Map.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
<l-tile-layer v-for="xyz of xyzLinks" ref="xyzOverlays" :key="xyz.url" layerType="overlay" v-bind="xyz" />
<LWMSTileLayer v-for="wms of wmsLinks" ref="wmsOverlays" :key="wms.url" layerType="overlay" v-bind="wms" />
<l-geo-json v-if="geojson" ref="geojson" :geojson="geojson" :options="{onEachFeature: showPopup}" :optionsStyle="{color: secondaryColor, weight: secondaryWeight}" />
<l-geo-json v-if="intersectsPolygon" ref="intersectsLayer" :geojson="intersectsPolygon" :options="{onEachFeature: showIntersectsPopup}" :optionsStyle="{color: '#3B82F6', weight: 2, fillColor: '#3B82F6', fillOpacity: 0.15, opacity: 0.8, dashArray: '5, 5'}" />
</l-map>
<b-popover
v-if="popover && selectedItem" placement="left" triggers="manual" :show="selectedItem !== null"
Expand Down Expand Up @@ -180,6 +181,15 @@ export default {
}
}
return wmsLinks;
},
naturalLanguageDescription() {
return this.naturalLanguageExplanation;
},
intersectsPolygon() {
if (this.stacLayerData && this.stacLayerData.intersects) {
return this.stacLayerData.intersects;
}
return null;
}
},
watch: {
Expand Down Expand Up @@ -452,6 +462,10 @@ export default {
}
layer.bindPopup(html);
},
showIntersectsPopup(feature, layer) {
const html = `<h3>${this.$t('search.naturalLanguageSearchArea')}</h3><p>${this.$t('search.naturalLanguageSearchAreaDescription')}</p>`;
layer.bindPopup(html);
},
addBoundsSelector() {
this.areaSelect = L.areaSelect({ // eslint-disable-line
width: 300,
Expand Down
151 changes: 146 additions & 5 deletions src/components/SearchFilter.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,32 @@

<b-card-title v-if="title" :title="title" />

<!-- Natural Language Search -->
<b-form-group v-if="canSupportNaturalLanguage" class="natural-language-search" :label="$t('search.naturalLanguageQuery')" :label-for="ids.naturalLanguage" :description="naturalLanguageDescription">
<div class="alert alert-info mb-3">
{{ $t('search.naturalLanguageInfo') }}
</div>
<div class="d-flex">
<b-form-input
:id="ids.naturalLanguage"
v-model="naturalLanguageQuery"
type="text"
:placeholder="$t('search.enterNaturalLanguageQuery')"
@keyup.enter="applyNaturalLanguageQuery"
@keydown.enter.prevent
class="flex-grow-1 mr-2"
/>
<b-button variant="primary" @click="applyNaturalLanguageQuery" :disabled="naturalLanguageLoading">
<span v-if="naturalLanguageLoading">
{{ $t('search.processing') }}
</span>
<span v-else>
{{ $t('search.applyNaturalLanguageQuery') }}
</span>
</b-button>
</div>
</b-form-group>

<b-form-group v-if="canFilterFreeText" class="filter-freetext" :label="$t('search.freeText')" :label-for="ids.q" :description="$t('search.freeTextDescription')">
<multiselect
:id="ids.q" :value="query.q" @input="setSearchTerms"
Expand Down Expand Up @@ -116,7 +142,7 @@
</template>

<script>
import { BBadge, BDropdown, BDropdownItem, BForm, BFormGroup, BFormInput, BFormCheckbox, BFormRadioGroup } from 'bootstrap-vue';
import { BBadge, BDropdown, BDropdownItem, BForm, BFormGroup, BFormInput, BFormCheckbox, BFormRadioGroup, BButton } from 'bootstrap-vue';
import Multiselect from 'vue-multiselect';
import { mapGetters, mapState } from "vuex";
import refParser from '@apidevtools/json-schema-ref-parser';
Expand Down Expand Up @@ -144,7 +170,8 @@ function getQueryDefaults() {
ids: [],
collections: [],
sortby: null,
filters: null
filters: null,
naturalLanguageQuery: ''
};
}

Expand All @@ -156,7 +183,10 @@ function getDefaults() {
query: getQueryDefaults(),
filtersAndOr: 'and',
filters: [],
selectedCollections: []
selectedCollections: [],
naturalLanguageQuery: '',
naturalLanguageExplanation: '',
naturalLanguageLoading: false
};
}

Expand All @@ -173,6 +203,7 @@ export default {
BFormInput,
BFormCheckbox,
BFormRadioGroup,
BButton,
QueryableInput: () => import('./QueryableInput.vue'),
Loading,
Map: () => import('./Map.vue'),
Expand Down Expand Up @@ -209,12 +240,16 @@ export default {
hasAllCollections: false,
collections: [],
collectionsLoadingTimer: null,
additionalCollectionCount: 0
additionalCollectionCount: 0,
naturalLanguageLoading: false
}, getDefaults());
},
computed: {
...mapState(['itemsPerPage', 'maxItemsPerPage', 'uiLanguage']),
...mapGetters(['canSearchCollections', 'supportsConformance']),
canSupportNaturalLanguage() {
return Boolean(this.$store.state.semanticSearchApiUrl);
},
collectionSelectOptions() {
let taggable = !this.hasAllCollections;
let isResult = this.collections.length > 0 && !this.hasAllCollections;
Expand Down Expand Up @@ -245,7 +280,7 @@ export default {
},
ids() {
let obj = {};
['q', 'datetime', 'bbox', 'collections', 'ids', 'sort', 'limit']
['q', 'datetime', 'bbox', 'collections', 'ids', 'sort', 'limit', 'naturalLanguage']
.forEach(field => obj[field] = field + formId);
return obj;
},
Expand Down Expand Up @@ -289,6 +324,9 @@ export default {
set(val) {
this.query.datetime = Array.isArray(val) ? val.map(d => Utils.dateToUTC(d)) : null;
}
},
naturalLanguageDescription() {
return this.naturalLanguageExplanation;
}
},
watch: {
Expand Down Expand Up @@ -349,6 +387,100 @@ export default {
Promise.all(promises).finally(() => this.loaded = true);
},
methods: {
async applyNaturalLanguageQuery(event) {
// Prevent form submission
if (event) {
event.preventDefault();
event.stopPropagation();
}

if (this.naturalLanguageQuery) {
this.naturalLanguageLoading = true;
try {
const SEMANTIC_SEARCH_API_URL = this.$store.state.semanticSearchApiUrl;
const response = await fetch(`${SEMANTIC_SEARCH_API_URL}/items/search`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
query: this.naturalLanguageQuery,
limit: 10
})
});

if (!response.ok) {
throw new Error(`API request failed with status ${response.status}`);
}

const responseData = await response.json();

// Store the explanation if available in the response
if (responseData.explanation) {
this.naturalLanguageExplanation = responseData.explanation;
} else if (responseData.results && responseData.results.explanation) {
this.naturalLanguageExplanation = responseData.results.explanation;
}

// Extract datetime from search_params if available
if (responseData.results && responseData.results.search_params && responseData.results.search_params.datetime) {
const datetimeString = responseData.results.search_params.datetime;
// Parse datetime string in format '2021-01-01/2022-12-31'
const dates = datetimeString.split('/');
if (dates.length === 2) {
const startDate = dates[0] === '..' ? null : new Date(dates[0]);
const endDate = dates[1] === '..' ? null : new Date(dates[1]);

// Set the datetime in the query
this.query.datetime = [startDate, endDate];
}
}

// Extract collections from search_params if available
if (responseData.results && responseData.results.search_params && responseData.results.search_params.collections) {
const collectionIds = responseData.results.search_params.collections;
if (Array.isArray(collectionIds) && collectionIds.length > 0) {
// Remove duplicates and set collections in the query
const uniqueCollectionIds = [...new Set(collectionIds)];
this.$set(this.query, 'collections', uniqueCollectionIds);

// Update selectedCollections to match the query collections
this.selectedCollections = uniqueCollectionIds.map(id => {
// Try to find existing collection in the collections array
let existingCollection = this.collections.find(c => c.value === id);
if (existingCollection) {
return existingCollection;
}
// If not found, create a new collection option
return this.collectionToMultiSelect({id});
});
}
}

// Extract max_items from search_params if available
if (responseData.results && responseData.results.search_params && responseData.results.search_params.max_items) {
const maxItems = parseInt(responseData.results.search_params.max_items, 10);
if (!isNaN(maxItems) && maxItems > 0) {
// Ensure the value doesn't exceed the maximum allowed
const limitedMaxItems = Math.min(maxItems, this.maxItems);
this.$set(this.query, 'limit', limitedMaxItems);
}
}

// Emit the natural language search results
this.$emit('natural-language-results', {
features: responseData.results.items,
type: 'Feature',
intersects: responseData.results.search_params?.intersects || null
});
} catch (error) {
console.error('Error in semantic search:', error);
this.$emit('natural-language-error', error.message);
} finally {
this.naturalLanguageLoading = false;
}
}
},
resetSearchCollection() {
clearTimeout(this.collectionsLoadingTimer);
this.collectionsLoadingTimer = null;
Expand Down Expand Up @@ -502,10 +634,14 @@ export default {
}
let filters = this.buildFilter();
this.$set(this.query, 'filters', filters);
this.$set(this.query, 'naturalLanguageQuery', this.naturalLanguageQuery);
this.$emit('input', this.query, false);
},
async onReset() {
Object.assign(this, getDefaults());
this.naturalLanguageQuery = '';
this.naturalLanguageExplanation = '';
this.naturalLanguageLoading = false;
this.$emit('input', {}, true);
},
setLimit(limit) {
Expand Down Expand Up @@ -610,6 +746,11 @@ $primary-color: map-get($theme-colors, "primary");
> label {
font-weight: 600;
}

// Add styling for fieldset > legend
legend {
font-weight: 600;
}
}
}
</style>
7 changes: 7 additions & 0 deletions src/locales/en/texts.json
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,13 @@
"enterCollections": "Enter one or more Collection IDs...",
"enterItemIds": "Enter one or more Item IDs...",
"enterSearchTerms": "Enter one or more search terms...",
"enterNaturalLanguageQuery": "e.g., 'satellite images of California from 2023'",
"naturalLanguageQuery": "AI Search",
"naturalLanguageInfo": "Enter a natural language query to search. This will replace any existing filter selections and return AI-powered search results.",
"applyNaturalLanguageQuery": "Search",
"naturalLanguageSearchArea": "Natural Language Search Area",
"naturalLanguageSearchAreaDescription": "This area represents the spatial extent used in your natural language search query.",
"processing": "Processing...",
"equalTo": "equal to",
"filterBySpatialExtent": "Filter by spatial extent",
"filterCollection": "Filter Collection",
Expand Down
Loading